Sign inGet started
API Reference

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.

Base URL
https://api.stackin.io/api/v1Copy

All API endpoints are relative to this base URL.

Download the Postman collection

Generated 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

  1. 1
    Sign up and log in at .
  2. 2
    Create a company — this is required before a key.
  3. 3
    In the dashboard, open the company and go to Company → API keys. The context starts as sdk — switch it to api.
  4. 4
    Name the key and choose its environment — homologation or production. Both are required at creation, and the environment is fixed for the key’s whole lifetime.
  5. 5
    Copy 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
Copy
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.

HomologationTest environment

Free and unlimited — it's the real SEFAZ/ADN test environment, not a mock — so integrate and test fully before switching to production.

ProductionLive environment

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
Copy
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.

Response headers
Copy
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1788556800
429
Copy
{
  "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
Copy
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": []}'
What arrives at your endpoint
Copy
{
  "event": "invoice.authorized",
  "data": {
    "invoice_id": "9f2c1e3a-4b5d-6e7f-8a9b-0c1d2e3f4a5b",
    "document_type": "nfe",
    "access_key": "35240912345678000199550010000000011000000017",
    "status": "issued",
    "amount": 1500.0
  }
}
Checking the signature
Copy
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.

Headers once the version is deprecated
Copy
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.

Example response
{
  "detail": "This company has no state set — required for NFe. Set it via PATCH /companies/{id}."
}
HTTPErrorWhenLearn more
400Invalid configurationThe issuer's configuration (certificate, address, fiscal fields) is missing or invalid.
401UnauthorizedThe 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
402Quota reachedThe company's plan quota for production invoices was reached and the plan has no overage price (trial).
404Not foundNo document with that access key or invoice_id belongs to the company behind your key.
409Operation conflictThe requested operation doesn't apply to that document_type (e.g. an nfse-only operation on an nfe).
422Validation errorThe request body failed validation — a required field is missing or malformed.
429Rate limitedToo many calls for this key in the current window. The Retry-After header says how many seconds to wait.See details
501Not implementedSigning for that document type/country isn't implemented on the server yet.
502Authorizer rejectedAuthorizer rejectionThe 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

Authorizer rejections

When the SEFAZ or ADN rejects an operation, the response includes extra information.

Use detail.invoice_id to look the attempt's status up at /invoices/{{id}}.

Copy
{
  "detail": {
    "message": "IE do emitente inválida",
    "invoice_id": "inv_8f3c9b2e6a7d"
  }
}
detail.message
The message returned by the authorizer.
detail.invoice_id
Identifies the issuance attempt.

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.

Copy
# 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:

Copy
{
  "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.

Copy
{
  "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
      }
    }
  }
}
Endpoints

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

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

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

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.

Reliable

99.9%+ uptime

Secure

Encrypted data, LGPD-ready

Fast

Scalable, low-latency infrastructure

AI-first

Official MCP server — issue from an agent

© 2026 stackin. All rights reserved.Built with code, purpose, and coffee.Privacy PolicyTerms of UseContact