SDKs
One class, six methods
An
Invoiceclient withissue(),consult(),cancel(),reissue(),correct()andinvalidate()— nothing else to instantiate.Typed models
Each item is a typed
Product, with Brazil-specific fiscal fields validated client-side before the request leaves.Same contract in every language
Python, Go, Node and PHP follow the same API contract, checked in CI on every change — methods, errors and resources behave identically, so switching languages doesn't mean relearning stackin.
Just getting started?
Create a free account, generate an SDK key and issue your first document in homologation — free and unlimited, no card.
pip install stackin-python-sdkOne dependency, with no third-party SDK underneath.
Authentication
The SDK authenticates with an SDK key — pass it once when creating the client, the library handles every request after that. You never construct a URL or set a header yourself.
Getting an SDK key
- 1Sign up and log in at .
- 2Create a company — this is required before a key.
- 3In the dashboard, open the company and go to Company → API keys, leaving the context on
sdk. No certificate is needed at this step — the key can be created and used right away; the certificate is only required later, at issuance. - 4Name the key and choose its environment —
homologationorproduction. Both are required at creation, and the environment is fixed for the key’s whole lifetime. - 5Copy the key immediately — it's shown , at creation time, and can't be retrieved again. Losing it means revoking it and creating a new one.
from stackin import Invoice
client = Invoice(api_key="$STACKIN_API_KEY")A company holds up to 3 keys at a time, in any mix of context (sdk/api) and environment (homologation/production) — revoking one frees a slot.
A missing or invalid token gets a 401 — see below.
Environments
Every SDK key is created for exactly one environment — or or — chosen when you generate it in the dashboard, and fixed for that key's lifetime. There's no environment field in SDK calls: the key you authenticate with decides where the document goes.
Free and unlimited — it's the real SEFAZ/ADN test environment, not a mock — so integrate and test fully before switching to production.
For real documents. Make sure everything works in homologation before switching.
A company can hold keys for both environments at once (up to 3 total).
Idempotency
Issuing is the one call you must not repeat blindly. If the response is lost — a timeout, a dropped connection — the document may well have been authorized, and a second attempt issues a second fiscal document: another credit, another series number burned, and undoing it means cancelling, which has a deadline.
Pass idempotency_key (named per each language's convention) to make the retry safe. Repeat with the same key and the same body and you get the first response back — no second document, no credit consumed. Available on issue and reissue.
Generate the key yourself — one UUID per business event, not per HTTP request — and keep it for as long as you might retry. Keys live 24 hours; after that the same key is treated as new.
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 | optionalyour key, up to 255 characters. The SDK only forwards it as a header — it never generates or stores anything.
- replay
- same key, same bodysame key, same body: returns the recorded response without reprocessing.
- APIError 422
- same key, other bodysame key, different body. Reusing a key on another request is a client bug, not a retry.
- APIError 409
- still runningthe first call is still running. Consult before trying again.
Rate limits
Limits are per API key. Each company has its own count — one client's volume never spends another's allowance.
Issuing has a tighter limit than consulting, and consulting a tighter one than listing history. The more an operation depends on the SEFAZ, the fewer calls per minute it takes.
Going over raises APIError with status 429. The SDK does not expose the response headers, so wait a few seconds before trying again — and reuse the same idempotency key, so the retry can't produce a second document.
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 / minproduces a document and consumes credit.
- consult / cancel
- 120 / minreaches the SEFAZ, but produces no document.
- APIError 429
- over the limitwait, then repeat with the same idempotency key.
Errors
The SDKs translate each failure into a distinct type: APIError for whatever the API answered (carrying status and detail), ConnectionFailedError for a network failure before any response, and the language's own validation error for what's caught client-side, before the request leaves.
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 | When |
|---|---|---|
| 400 | Invalid configuration | The issuer's configuration (certificate, address, fiscal fields) is missing or invalid. |
| 401 | Unauthorized | The Bearer token is missing, expired, invalid, or wasn't created for the host you're calling (api.stackin.io vs sdk.stackin.io). |
| 402 | Quota reached | The company's plan quota for production invoices was reached and the plan has no overage price (trial). |
| 404 | Not found | No document with that access key or invoice_id belongs to the company behind your key. |
| 409 | Operation conflict | The requested operation doesn't apply to that document_type (e.g. an nfse-only operation on an nfe). |
| 422 | Validation error | The request body failed validation — a required field is missing or malformed. |
| 429 | Rate limited | Too many calls for this key in the current window. The Retry-After header says how many seconds to wait. |
| 501 | Not implemented | Signing for that document type/country isn't implemented on the server yet. |
| 502 | Authorizer rejectedAuthorizer rejection | The authorizer (SEFAZ/state or ADN) rejected the request. detail.message carries the authorizer's own error, and detail.invoice_id (issuance only) lets you look the rejected attempt up. |
Item values
unit_price is the price of one unit. amount is the gross total of the line's products, before discount, freight, insurance and other expenses.
Send either. Sending both asserts they agree: the API compares quantity x unit_price against amount and refuses the document before transmission if they differ. In the SDKs decimals travel as strings, so a ten-place unit price reaches the API exactly as typed.
Also refused: neither field (ITEM_AMOUNT_MISSING), values at or below zero, a discount larger than the gross amount (ITEM_DISCOUNT_EXCEEDS_GROSS) and precision the XML cannot carry (ITEM_PRECISION_UNSUPPORTED). Nothing has to migrate: amount keeps the meaning it always had.
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 — tax reform
The tax-reform group, per item. Required from 2026 for the regime normal. cst and classification (cClassTrib) are your fiscal decision; the API derives the amounts from the rates.
The three rates are percentages: rate_state (pIBSUF), rate_city (pIBSMun) and rate_federal (pCBS). base is optional — without it, the base is the item amount. An item without the group emits nothing, which is what an NF-e looked like before the reform.
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,
),
)Item tax groups
ICMS, IPI, PIS and COFINS per item, through Tax. Each group has its class per CST/CSOSN — IcmsSn102 for Simples Nacional without a credit, PisNt and CofinsNt for untaxed, and so on.
stackin never infers taxation: which group applies is the issuer’s fiscal decision, and getting it wrong produces wrong tax, not a schema error. Without tax, no tax group reaches the 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")),
)Invoices
Issue an invoice
Issues a new document. items[].product.br (ncm, cfop) is required for , ignored for , ignored for — a service has no tax classification code, so nfse only reads description and amount off each item.
The same call works for nfse — just change 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",
),
)Consult an invoice
Looks up a document's current status directly at the authorizer by its access key. Requires as a query param.
result = client.consult(
document_type=DocumentType.NFE,
access_key="42260831112223330001815500000012341123456789",
)Cancel an invoice
Cancels a previously authorized document. must be at least 15 characters for nfe (the authorizer's xJust field requires it). Cancelling doesn't refund plan quota — the authorizer capacity was already spent on issuance.
result = client.cancel(
document_type=DocumentType.NFE,
access_key="42260831112223330001815500000012341123456789",
reason="Duplicate order, cancelled by the buyer within 24h",
)Reissue invoice
Retries a failed submission by the document's local `id` (not its access key — a rejected invoice doesn't have one). Useful for retrying after fixing a configuration problem or an authorizer outage. Consumes credit like a fresh issuance, since it's a new transmission.
result = client.reissue(
invoice_id="9f2c1e3a-4b5d-6e7f-8a9b-0c1d2e3f4a5b",
)Correct invoice
Corrects an already-authorized document with a correction letter (CC-e), without cancelling and reissuing — it consumes no credit and burns no series number. It covers mistakes that don't change the tax: a product name, transport details, extra information.
The text runs 15 to 1000 characters, checked by the SDK before the call. A CC-e cannot fix anything that changes the tax owed, the seller, the buyer, or the issue date — those still mean cancelling and reissuing. The original document doesn't change: the correction is attached to it. At most 20 per document, numbered for you. NF-e only; NFS-e raises APIError 409.
result = client.correct(
"35240912345678000199550010000000011000000017",
document_type=DocumentType.NFE,
correction="Transportadora corrigida para Rapido Ltda",
)Invalidate range
NF-e numbering is sequential and the SEFAZ expects it to have no gaps. The number is reserved the moment issuing starts, so a submission that fails afterwards — a rejection, a timeout — leaves a hole in the series. Invalidating the range is how that hole gets closed.
The reason runs 15 to 255 characters and the range is inclusive — the SDK checks both, and that the end isn't below the start, before the call. A number that already reached the SEFAZ can't be invalidated: that comes back as APIError 409 naming them. NF-e only, and no access key: there is no document to point at.
result = client.invalidate(
series="1",
number_start=10,
number_end=12,
reason="Numeracao reservada e nao utilizada por falha no ERP",
)Document PDF
The printed representation the authorizer generates — DANFE for NF-e, DANFSe for NFS-e. Returns raw bytes, the only method that does not return an object: write it to a file or serve it directly.
The XML remains the legally valid document; the PDF is a convenience. The authorizer’s endpoint for it is unstable, so a 502 here means the authorizer is unavailable, not that the invoice is wrong.
pdf = client.pdf(access_key, document_type=DocumentType.NFSE)
with open("nota.pdf", "wb") as file:
file.write(pdf)History
What this company issued, newest first. It is the counterpart of received: that one lists what was issued against you, this one what you issued.
Filter by document_type and status (issued, rejected, cancelled), and page with limit and offset. The answer carries the standard pagination envelope.
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"])Attempts
Every attempt made for one invoice and what came back from the authorizer. consult gives the status; this gives the reason — the rejection code and the XML that was sent.
It takes the invoice_id, not the access key: a rejected document never had one. This is how you find out that a rejection was, say, 972.
attempts = client.submissions(invoice["id"])
for attempt in attempts:
print(attempt["status"], attempt["detail"])Received invoices
Documents other companies issued against your tax id. It reads what the API already collected — it does not call the authorizer on the spot.
Collection runs on a schedule on the API side because the SEFAZ caps how many times a tax id may ask per day. Reading here does not spend that quota.
page = client.received(limit=20)
for document in page["data"]:
print(document["access_key"], document["issuer_name"], document["amount"])Manifestation
The recipient’s formal answer: confirmation, acknowledgement, unknown operation or operation not performed.
Only operation not performed takes a reason — and it requires one. The SDK checks that before leaving your machine, rather than spending a round trip to be told.
from stackin import Manifestation
client.manifest(access_key, manifestation=Manifestation.CONFIRMACAO)
client.manifest(
access_key,
manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
reason="Mercadoria nao recebida",
)