API Reference
Issue, consult, cancel and correct
Issuing, consulting, cancelling, reissuing, correction letters and numbering invalidation — NF-e and NFS-e, each with what it supports.
JSON over HTTPS
Requests and responses are JSON, authenticated with a Bearer API key.
Scoped to one company
Every request is scoped to one company — the issuer's UF, address, and digital certificate are resolved server-side from the API key, so a request only needs the business fields (buyer, items, amounts).
Just getting started?
Create a free account, generate an API key and issue your first document in homologation — free and unlimited, no card.
https://api.stackin.io/api/v1CopyAll API endpoints are relative to this base URL.
Download the Postman collectionGenerated from the same OpenAPI as this page. Import it and fill in the api_key variable.
Authentication
The API authenticates with an API key — same mechanism as the SDK, just with its context set to . No login, no JWT — one Bearer header on every request.
Getting an API 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. The context starts as
sdk— switch it toapi. - 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.
curl https://api.stackin.io/api/v1/invoices \
-H "Authorization: Bearer $STACKIN_API_KEY"A missing or invalid token gets a 401 — see below.
Environments
Every API key is created for exactly one environment — or or — chosen when you generate it, and fixed for that key's lifetime.
There's no environment field in API requests: the key 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.
Send an Idempotency-Key header 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. It works on POST /invoices and on POST /invoices/{id}/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.
curl -X POST https://api.stackin.io/api/v1/invoices \
-H "Authorization: Bearer $STACKIN_API_KEY" \
-H "Idempotency-Key: 3f7c1e9a-2b4d-4f6a-8c1e-9d0b5a7f2c34" \
-H "Content-Type: application/json" \
-d '{
"document_type": "nfse",
"client_name": "John Doe",
"tax_id": "00000000000",
"items": [
{ "description": "Technical consulting - 10 hours", "unit_price": "1500.00" }
]
}'- Idempotency-Key
- header | optionalyour key, up to 255 characters. It is scoped to your company: one account's key never collides with another's.
- 201
- replaysame key, same body: returns the recorded response without reprocessing.
- 422
- mismatchsame key, different body. Reusing a key on another request is a client bug, not a retry.
- 409
- in progressthe 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 reading history. The more an operation depends on the SEFAZ, the fewer calls per minute it takes.
Every response carries the limit, what's left, and when the window reopens. Going over returns 429 with Retry-After in seconds — wait that long before trying again.
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1788556800{
"detail": "Rate limit reached: 60 requests per 60s for this operation. Retry after the window resets."
}- POST /invoices
- 60 / minproduces a document and consumes credit.
- POST /invoices/{id}/reissue
- 60 / minproduces a document and consumes credit.
- GET /invoices/{access_key}
- 120 / minreaches the SEFAZ, but produces no document.
- POST /invoices/{access_key}/cancel
- 120 / minreaches the SEFAZ, but produces no document.
- GET /invoices
- 600 / minnever leaves stackin.
Webhooks
Instead of polling to learn how an issuance ended, register an https URL and stackin tells you. An endpoint with events: [] gets all of them; an unknown event name is refused at registration.
Every delivery is signed in Stackin-Signature, shaped t=<epoch>,v1=<hmac>. The HMAC is SHA-256 over {timestamp}.{body} with your secret. Check it before processing: the signature is what separates our delivery from whoever learned your URL.
Delivery is at least once — on failure we retry 6 times, waiting from 30s up to 6h. Use Stackin-Delivery-Id to ignore what you already handled. The history, including what we gave up on, is at GET /webhooks/deliveries.
curl -X POST https://api.stackin.io/api/v1/webhooks \
-H "Authorization: Bearer $STACKIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://seu-erp.com.br/stackin", "secret": "um-segredo-longo-e-aleatorio", "events": []}'{
"event": "invoice.authorized",
"data": {
"invoice_id": "9f2c1e3a-4b5d-6e7f-8a9b-0c1d2e3f4a5b",
"document_type": "nfe",
"access_key": "35240912345678000199550010000000011000000017",
"status": "issued",
"amount": 1500.0
}
}import hashlib, hmac, time
def verify(secret: str, body: str, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
if abs(int(time.time()) - int(parts["t"])) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f'{parts["t"]}.{body}'.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])- invoice.authorized
- eventthe SEFAZ authorized the document.
- invoice.rejected
- eventthe SEFAZ refused it. The reason is on the invoice's history.
- invoice.cancelled
- eventthe cancellation went through.
- invoice.corrected
- eventa correction letter was filed.
- Stackin-Signature
- headerthe timestamp is part of what is signed, so a captured delivery cannot be replayed later.
- Stackin-Delivery-Id
- headeridentifies the delivery. Two with the same id are the same thing, sent again.
Versioning
The path is /api/v1. Inside it we may add — a new response field, a new enum value, a new endpoint, a new optional parameter — without changing versions.
Your side of that: treat an unknown field as data, not as an error. All four official SDKs already do, each with a test locking the behaviour. If you call the API directly, don't validate the response against a closed schema.
Removing something, changing a type, tightening a validation or changing what a value means requires a new version. When a /v2 exists, /v1 keeps answering for at least 12 months — and says so on every response, with the headers alongside.
Deprecation: Fri, 01 Jan 2027 00:00:00 GMT
Sunset: Sat, 01 Jan 2028 00:00:00 GMT
Link: <https://docs.stackin.io/api#versioning>; rel="deprecation"- additive
- no new versionnew field, new enum value, new endpoint, new optional parameter.
- breaking
- requires /v2removing something, changing a type, tightening a validation, changing what a value means, making an optional parameter required.
- Deprecation
- HTTP-datewhen the version was marked to go. It is an HTTP-date, not ISO.
- Sunset
- HTTP-datewhen it stops answering. At least 12 months after the announcement.
Pagination
GET /invoices is paginated with and and query params (default query params (default , max , max ).
The response envelope always includes:
- total
- integertotal rows matching the filters.
- total_pages
- integer
- next_page / prev_page
- integer | null
- data
- arraythe page's rows.
Errors
The API uses standard HTTP status codes. In most cases the response carries a detail object. SEFAZ or ADN rejections return extra information about the authorizer.
Issuing answers 201 Created, not 200. So do invalidating numbering and registering a webhook. Deleting a webhook answers 204, with no body.
Guide:NF-e rejection 539: what it means and how to fix it · NF-e rejection 204: duplicate NF-e, what it means and how to fix it · SEFAZ unavailable: how to confirm it and issue NF-e in contingency
Example response
{
"detail": "This company has no state set — required for NFe. Set it via PATCH /companies/{id}."
}| HTTP | Error | When | Learn more |
|---|---|---|---|
| 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). | See details |
| 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. | See details |
| 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. | See details |
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: quantity x unit_price is compared with amount and the document is refused before transmission if they differ. Decimal values may be sent as strings to keep their precision — unit_price takes up to 10 places.
# unit_price: what one unit costs
{
"description": "Teclado",
"product": { "quantity": 2, "unit": "UN", "br": { "ncm": "84716052", "cfop": "5102" } },
"unit_price": "120.00"
}
# -> qCom 2.0000 vUnCom 120.0000000000 vProd 240.00
# amount: the line's gross total (the original contract)
{
"description": "Teclado",
"product": { "quantity": 3 },
"amount": "50.00"
}
# -> qCom 3.0000 vUnCom 16.6666666667 vProd 50.00
# both, checked against each other before transmission
{
"description": "Teclado",
"product": { "quantity": 2 },
"unit_price": "120.00",
"amount": "240.00"
}When they do not agree the answer is 422, naming the line and both numbers:
{
"detail": {
"code": "ITEM_TOTAL_MISMATCH",
"field": "items[0].amount",
"message": "amount is 50.00, but quantity x unit_price is 150.00.",
"expected": "150.00",
"received": "50.00"
}
}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.
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.
{
"description": "Teclado",
"unit_price": "120.00",
"product": {
"quantity": 2,
"br": {
"ncm": "84716052",
"cfop": "5102",
"ibs_cbs": {
"cst": "000",
"classification": "000001",
"rate_state": 0.1,
"rate_city": 0.0,
"rate_federal": 0.9
}
}
}
}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.
List invoices
Lists the company's issuance history, paginated (see Pagination above). Filter with document_type and and (issued, rejected, cancelled).
Consult an invoice
Looks up a document's current status directly at the authorizer by its access key. Requires as a query param.
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.
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.
Correct invoice
Guide:NF-e correction letter (CC-e): what can be corrected, deadline, how-to
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. 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 answers 409.
Invalidate range
Guide:Invalidating NF-e numbers at the SEFAZ: when and how
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. A number that already reached the SEFAZ can't be invalidated: we check our own records first and answer 409 naming them, without a round trip — and the SEFAZ checks again for what we can't see from here. NF-e only, and no access key: there is no document to point at. Use GET /invoices/invalidations to list the ranges already reported.
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.
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.
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.
Manifestation
Guide:NF-e Recipient Manifestation: events, deadlines and XML download
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.