SDKs
Una clase, seis métodos
Un cliente
Invoiceconissue(),consult(),cancel(),reissue(),correct()einvalidate()— nada más que instanciar.Modelos tipados
Cada ítem es un
Producttipado, con campos fiscales de Brasil validados en el cliente antes de que salga la solicitud.El mismo contrato en cada lenguaje
Python, Go, Node y PHP siguen el mismo contrato de API, verificado en CI en cada cambio — métodos, errores y recursos se comportan igual, así que cambiar de lenguaje no significa reaprender stackin.
¿Recién empezando?
Cree una cuenta gratuita, genere una SDK key y emita su primer documento en homologation — gratis e ilimitado, sin tarjeta.
pip install stackin-python-sdkUna dependencia, sin SDK de terceros debajo.
Autenticación
El SDK autentica con una SDK key — pásela una vez al crear el client, la librería se encarga del resto. Nunca construye una URL ni define un header manualmente.
Cómo obtener una SDK key
- 1Regístrese e inicie sesión en .
- 2Cree una empresa — obligatorio antes de la key.
- 3En el dashboard, abra la empresa y vaya a Empresa → API keys, dejando el contexto en
sdk. No hace falta certificado en este paso — la key puede crearse y usarse enseguida; el certificado solo se exige después, en la emisión. - 4Nombre la key y elija el entorno —
homologationoproduction. Ambos son obligatorios en la creación, y el entorno es fijo por el resto de la vida de la key. - 5Copie la key de inmediato — se muestra , al crearla, y no se puede recuperar después. Si la pierde, revóquela y cree otra.
from stackin import Invoice
client = Invoice(api_key="$STACKIN_API_KEY")Una empresa mantiene hasta 3 keys a la vez, en cualquier combinación de contexto (sdk/api) y entorno (homologation/production) — revocar una libera el cupo.
Un token ausente o inválido devuelve 401 — ver abajo.
Entornos
Cada SDK key se crea para exactamente un entorno — o or — elegido al generarla en el dashboard, fijo durante toda la vida de la key. No hay campo de entorno en las llamadas del SDK: la key con la que se autentica decide a dónde va el documento.
Gratis e ilimitado — es el entorno de prueba real de SEFAZ/ADN, no un mock — así que integra y prueba todo antes de cambiar a production.
Para documentos reales. Asegúrate de que todo funcione en homologation antes de cambiar.
Una empresa puede tener keys de ambos entornos a la vez (hasta 3 en total).
Idempotencia
Emitir es la única llamada que no debes repetir a ciegas. Si la respuesta se pierde — timeout, conexión caída — el documento bien puede haber sido autorizado, y un segundo intento emite una segunda factura: otro crédito, otro número de serie quemado, y deshacerlo exige cancelar, que tiene plazo.
Pasa idempotency_key (con el nombre que corresponda a cada lenguaje) para que reintentar sea seguro. Al repetir con la misma clave y el mismo cuerpo recibes de vuelta la primera respuesta — sin segundo documento, sin consumir crédito. Disponible en issue y reissue.
Genera la clave tú mismo — un UUID por evento de negocio, no por petición HTTP — y consérvala mientras todavía puedas reintentar. Las claves viven 24 horas; después de eso la misma clave se trata como nueva.
result = client.issue(
document_type=DocumentType.NFSE,
client_name="Maria Silva",
tax_id="12345678909",
items=[Product(description="Consultoria", unit_price=Decimal("1500.00"))],
idempotency_key=key,
)- idempotency_key
- string | optionaltu clave, hasta 255 caracteres. El SDK solo la reenvía en el header — nunca genera ni guarda nada.
- replay
- same key, same bodymisma clave, mismo cuerpo: devuelve la respuesta guardada, sin reprocesar.
- APIError 422
- same key, other bodymisma clave, cuerpo distinto. Reutilizar una clave en otra petición es un error del cliente, no un reintento.
- APIError 409
- still runningla primera llamada sigue en curso. Consulta antes de volver a intentar.
Límites de uso
Los límites son por clave de API. Cada empresa tiene su propio contador — el volumen de un cliente nunca gasta la cuota de otro.
Emitir tiene un límite más estricto que consultar, y consultar más estricto que listar el historial. Cuanto más depende una operación de la SEFAZ, menos llamadas por minuto acepta.
Al excederlo, el SDK lanza APIError con estado 429. No expone los headers de la respuesta, así que espera unos segundos antes de volver a intentar — y reutiliza la misma clave de idempotencia, para que el reintento no genere un segundo documento.
try:
result = client.issue(...)
except APIError as error:
if error.status_code == 429:
# Wait before calling again — reuse the same idempotency_key.
raise- issue / reissue
- 60 / mingenera documento y consume crédito.
- consult / cancel
- 120 / minllega a la SEFAZ, pero no genera documento.
- APIError 429
- over the limitespera y repite con la misma clave de idempotencia.
Errores
Los SDKs traducen cada falla en un tipo distinto: APIError para lo que respondió la API (con status y detail), ConnectionFailedError para una falla de red antes de cualquier respuesta, y el error de validación del propio lenguaje para lo que se detecta en el cliente, antes de que salga la solicitud.
from stackin import APIError, ConnectionFailedError
try:
result = client.issue(...)
except ValueError as error:
# Caught before the request left — empty items, missing ncm/cfop,
# incomplete recipient_address on an NFE.
print(f"Invalid request: {error}")
except APIError as error:
# 4xx/5xx from the API. 502 carries the authorizer's own message.
print(f"[{error.status_code}] {error.detail}")
except ConnectionFailedError as error:
print(f"Could not reach the API: {error}")| HTTP | Error | Cuándo |
|---|---|---|
| 400 | Configuración inválida | La configuración del emisor (certificado, dirección, campos fiscales) está ausente o es inválida. |
| 401 | No autorizado | El Bearer token está ausente, expirado, es inválido o no fue creado para el host que estás llamando (api.stackin.io vs sdk.stackin.io). |
| 402 | Límite alcanzado | La cuota del plan de la empresa para notas de production fue alcanzada y el plan no tiene precio de excedente (trial). |
| 404 | No encontrado | Ningún documento con esa clave de acceso o invoice_id pertenece a la empresa de tu key. |
| 409 | Conflicto de operación | La operación solicitada no aplica a ese document_type (ej.: una operación exclusiva de nfse en una nfe). |
| 422 | Error de validación | El cuerpo de la solicitud falló la validación — campo obligatorio faltante o malformado. |
| 429 | Límite de peticiones | Demasiadas llamadas para esta key en la ventana actual. El header Retry-After dice cuántos segundos esperar. |
| 501 | No implementado | La firma para ese tipo de documento/país aún no está implementada en el servidor. |
| 502 | El autorizador rechazóRechazo del autorizador | El autorizador (SEFAZ/estado o ADN) rechazó la solicitud. detail.message trae el error del propio autorizador, y detail.invoice_id (solo en la emisión) permite consultar el intento rechazado. |
Valores del ítem
unit_price es el precio de una unidad. amount es el valor bruto total de los productos de la línea, antes de descuento, flete, seguro y otros gastos.
Envíe uno de los dos. Enviar ambos afirma que coinciden: la API compara quantity x unit_price con amount y rechaza el documento antes de la transmisión si difieren. En los SDK los decimales viajan como texto, así que un precio con 10 decimales llega a la API exactamente como se escribió.
También se rechazan: ninguno de los dos campos (ITEM_AMOUNT_MISSING), valores iguales o menores que cero, un descuento mayor que el valor bruto (ITEM_DISCOUNT_EXCEEDS_GROSS) y precisión que el XML no admite (ITEM_PRECISION_UNSUPPORTED). Nada debe migrar: amount conserva el significado que siempre tuvo.
from decimal import Decimal
# unit_price: what one unit costs — the line is 2 x 120.00 = 240.00
Product(description="Teclado", quantity=Decimal("2"), unit_price=Decimal("120.00"))
# amount: the line's gross total (the original contract)
Product(description="Teclado", quantity=Decimal("3"), amount=Decimal("50.00"))
# both, checked against each other before transmission
Product(
description="Teclado",
quantity=Decimal("2"),
unit_price=Decimal("120.00"),
amount=Decimal("240.00"),
)IBS/CBS — reforma tributaria
El grupo de la reforma tributaria, por ítem. Obligatorio desde 2026 para el régimen normal. cst y classification (cClassTrib) son su decisión fiscal; la API deriva los importes de las alícuotas.
Las tres alícuotas son porcentajes: rate_state (pIBSUF), rate_city (pIBSMun) y rate_federal (pCBS). base es opcional — sin ella, la base es el valor del ítem. Un ítem sin el grupo no emite nada, que es como era la NF-e antes de la reforma.
from stackin.br import IbsCbs, Product
Product(
description="Teclado",
quantity=Decimal("2"),
unit_price=Decimal("120.00"),
ncm="84716052",
cfop="5102",
ibs_cbs=IbsCbs(
cst="000",
classification="000001",
rate_state=0.1,
rate_city=0.0,
rate_federal=0.9,
),
)Grupos de impuesto del ítem
ICMS, IPI, PIS y COFINS por ítem, a través de Tax. Cada grupo tiene su clase por CST/CSOSN — IcmsSn102 para Simples Nacional sin crédito, PisNt y CofinsNt para no gravado, y así.
stackin nunca infiere tributación: qué grupo aplica es decisión fiscal del emisor, y equivocarse produce impuesto equivocado, no un error de esquema. Sin tax, ningún grupo de impuesto llega al XML.
from stackin.br import CofinsNt, IcmsSn102, PisNt, Product, Tax
Product(
description="Teclado",
quantity=Decimal("2"),
unit_price=Decimal("120.00"),
ncm="84716052",
cfop="5102",
tax=Tax(icms=IcmsSn102(csosn="102"), pis=PisNt(cst="07"), cofins=CofinsNt(cst="07")),
)Facturas
Emitir factura
Emite un nuevo documento. items[].product.br (ncm, cfop) es obligatorio para , se ignora en , ignored for — un servicio no tiene código de clasificación fiscal, así que nfse solo lee description y amount de cada ítem.
La misma llamada funciona para nfse — solo cambia document_type.
from decimal import Decimal
from stackin import Address, DocumentType, Invoice
from stackin.br import Product
client = Invoice(api_key="$STACKIN_API_KEY")
result = client.issue(
document_type=DocumentType.NFE,
client_name="Buyer Company Ltd",
tax_id="11222333000181",
items=[
Product(
description="Rosa Holambra Vermelha",
unit_price=Decimal("112.44"),
ncm="06031100",
cfop="5102",
)
],
recipient_address=Address(
street="Rua das Flores",
number="1200",
neighborhood="Centro",
city="Joinville",
state="SC",
zip_code="89201100",
city_code="4209102",
),
)Consultar factura
Consulta el estado actual de un documento directamente en el autorizador por su access key. Requiere como query param.
result = client.consult(
document_type=DocumentType.NFE,
access_key="42260831112223330001815500000012341123456789",
)Cancelar factura
Cancela un documento previamente autorizado. debe tener al menos 15 caracteres para nfe (el campo xJust del autorizador lo exige). Cancelar no devuelve la cuota del plan — la capacidad del autorizador ya se gastó en la emisión.
result = client.cancel(
document_type=DocumentType.NFE,
access_key="42260831112223330001815500000012341123456789",
reason="Duplicate order, cancelled by the buyer within 24h",
)Reemitir nota
Reenvía una emisión fallida por el `id` local del documento (no por su clave de acceso — una nota rechazada no tiene una). Útil para reintentar después de corregir un problema de configuración o una caída del autorizador. Consume crédito como una emisión nueva, ya que es una nueva transmisión.
result = client.reissue(
invoice_id="9f2c1e3a-4b5d-6e7f-8a9b-0c1d2e3f4a5b",
)Corregir factura
Corrige un documento ya autorizado con una carta de corrección (CC-e), sin cancelar ni reemitir — no consume crédito ni quema número de serie. Sirve para errores que no cambian el impuesto: nombre de producto, datos de transporte, información complementaria.
El texto va de 15 a 1000 caracteres, verificado por el SDK antes de la llamada. Una CC-e no corrige nada que cambie el valor del impuesto, el emisor, el destinatario o la fecha de emisión — en esos casos sigue siendo cancelar y reemitir. El documento original no cambia: la corrección queda adjunta. Como máximo 20 por documento, numeradas automáticamente. Solo NF-e; para NFS-e llega APIError 409.
result = client.correct(
"35240912345678000199550010000000011000000017",
document_type=DocumentType.NFE,
correction="Transportadora corrigida para Rapido Ltda",
)Inutilizar rango
La numeración de la NF-e es secuencial y la SEFAZ exige que no tenga huecos. El número se reserva cuando empieza la emisión, así que una emisión que falla después — rechazo, timeout — deja un vacío en la serie. Inutilizar el rango es como se cierra ese vacío.
El motivo va de 15 a 255 caracteres y el rango es inclusivo — el SDK verifica ambos, y que el fin no sea menor que el inicio, antes de la llamada. Un número que ya llegó a la SEFAZ no puede inutilizarse: llega como APIError 409 diciendo cuáles son. Solo NF-e, y sin clave de acceso: no hay documento al que apuntar.
result = client.invalidate(
series="1",
number_start=10,
number_end=12,
reason="Numeracao reservada e nao utilizada por falha no ERP",
)PDF del documento
La representación impresa que genera la autorizadora — DANFE para NF-e, DANFSe para NFS-e. Devuelve bytes crudos, el único método que no devuelve un objeto: guárdelo en un archivo o sírvalo directamente.
El XML sigue siendo el documento con valor jurídico; el PDF es una conveniencia. El endpoint de la autorizadora es inestable, así que un 502 aquí significa autorizadora no disponible, no que la nota esté mal.
pdf = client.pdf(access_key, document_type=DocumentType.NFSE)
with open("nota.pdf", "wb") as file:
file.write(pdf)Historial
Lo que esta empresa emitió, lo más reciente primero. Es la contraparte de received: aquel lista lo que emitieron contra usted, este lo que usted emitió.
Filtre por document_type y status (issued, rejected, cancelled), y pagine con limit y offset. La respuesta trae el envelope de paginación estándar.
page = client.history(
document_type=DocumentType.NFE,
status="issued",
limit=20,
offset=0,
)
for row in page["data"]:
print(row["access_key"], row["client_name"], row["amount"])Intentos
Cada intento hecho para una nota y lo que volvió de la autorizadora. consult da el estado; este da el motivo — el código del rechazo y el XML que se envió.
Recibe el invoice_id, no la clave de acceso: un documento rechazado nunca tuvo una. Así se descubre que un rechazo fue, por ejemplo, 972.
attempts = client.submissions(invoice["id"])
for attempt in attempts:
print(attempt["status"], attempt["detail"])Notas recibidas
Documentos que otras empresas emitieron contra su identificación fiscal. Lee lo que la API ya recolectó — no llama a la autorizadora en el momento.
La recolección corre en agenda del lado de la API porque la SEFAZ limita cuántas veces una identificación fiscal puede consultar por día. Leer aquí no gasta esa cuota.
page = client.received(limit=20)
for document in page["data"]:
print(document["access_key"], document["issuer_name"], document["amount"])Manifestación
La respuesta formal de quien recibió: confirmación, conocimiento, desconocimiento u operación no realizada.
Solo operación no realizada acepta justificación — y la exige. El SDK lo verifica antes de salir de su máquina, en vez de gastar una ida a la API para que se lo digan.
from stackin import Manifestation
client.manifest(access_key, manifestation=Manifestation.CONFIRMACAO)
client.manifest(
access_key,
manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
reason="Mercadoria nao recebida",
)