Sign inGet started
SDKs

SDKs

  • One class, six methods

    An Invoice client with issue(), consult(), cancel(), reissue(), correct() and invalidate() — 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.

Install
Copy
pip install stackin-python-sdk

One 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

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

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.

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.

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

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

Copy
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}")
HTTPErrorWhen
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).
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.
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.

Authorizer rejections

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

APIError's detail carries these fields when the authorizer rejects — use detail.invoice_id to look the attempt up later.

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

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

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

Copy
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")),
)
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.

The same call works for nfse — just change document_type.

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

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

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

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

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

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

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

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

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

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

Copy
from stackin import Manifestation

client.manifest(access_key, manifestation=Manifestation.CONFIRMACAO)

client.manifest(
    access_key,
    manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
    reason="Mercadoria nao recebida",
)
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