Skip to main content

Integrate once. Issue everywhere.

Python PyPI License

API Reference · Python SDK guide


stackin

Python SDK for fiscal document issuance — a handful of business fields, nothing about certificates, XML, XSD, signing or SOAP. The API resolves all of that from the issuer's own configuration, identified by api_key.

One class, Invoice — issue()/consult()/cancel()/reissue()/correct()/invalidate()/pdf()/received()/manifest(), nothing else to instantiate. Each line item is a Product (stackin.br) — description plus either unit_price or amount are universal, everything else (ncm/cfop/cest/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.

What a line item is worth

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 that they agree.

from decimal import Decimal

# One unit
Product(description="Consultoria", quantity=Decimal("1"), unit_price=Decimal("1500.00"))

# More than one unit — amount is computed as 2 x 120.00 = 240.00
Product(description="Teclado", quantity=Decimal("2"), unit_price=Decimal("120.00"))

# A fractional quantity — 1.5 x 30.00 = 45.00
Product(description="Cabo", quantity=Decimal("1.5"), unit_price=Decimal("30.00"))

# Legacy: amount alone still means the line's gross total
Product(description="Servico", quantity=Decimal("3"), amount=Decimal("150.00"))

# Both, checked against each other before the document is transmitted
Product(
    description="Teclado",
    quantity=Decimal("2"),
    unit_price=Decimal("120.00"),
    amount=Decimal("240.00"),
)

# With charges: 5000.00 - 100.50 + 40.00 + 10.00 = 4949.50 on the note
Product(
    description="Servidor",
    quantity=Decimal("2"),
    unit_price=Decimal("2500.00"),
    discount=100.50,
    freight=40.00,
    insurance=10.00,
)

Amounts that do not add up are refused before the authorizer sees them, with a 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"
  }
}

Migrating

Before:  quantity = 3, amount = 150.00
After:   quantity = 3, unit_price = 50.00

Nothing has to migrate. amount keeps the meaning it always had and is not deprecated in this release; a line sent with amount alone is built exactly as it was before.

Install

pip install stackin-python-sdk

Usage

Get an api_key from the stackin dashboard — select the issuing company, then Settings → API key (context sdk). One key per issuing company, shown once at creation. The API resolves the issuer (CNPJ, state, address, certificate, environment) entirely from it; nothing about the issuer is ever passed on a call.

from decimal import Decimal

from stackin import Invoice, DocumentType, Address
from stackin.br import Product  # Brazil-specific line item — NCM/CFOP

client = Invoice(api_key="COMPANY_API_KEY")  # defaults to https://sdk.stackin.io

invoice = client.issue(
    document_type=DocumentType.NFSE,
    client_name="John Doe",
    tax_id="00000000000",
    items=[Product(description="Software development", unit_price=Decimal("5000.00"))],
)

status = client.consult("ACCESS_KEY...", document_type=DocumentType.NFSE)
client.cancel(
    "ACCESS_KEY...",
    document_type=DocumentType.NFSE,
    reason="Typo",
)
client.reissue(invoice["id"])  # retries a rejected/failed submission

# The authorizer's PDF, as raw bytes. NFS-e only; the XML stays the
# legally valid document, and a 502 here means the authorizer is down.
with open("nota.pdf", "wb") as file:
    file.write(client.pdf(access_key, document_type=DocumentType.NFSE))

# NFE requires ncm/cfop on every item, plus the buyer's full recipient_address:
client.issue(
    document_type=DocumentType.NFE,
    client_name="Buyer Company Ltd",
    tax_id="11111111111111",
    items=[
        Product(
            description="Teclado",
            quantity=Decimal("2"),
            unit_price=Decimal("120.00"),
            unit="UN",
            ncm="84716052",
            cfop="5102",
        )
    ],
    recipient_address=Address(
        street="Avenida Atlantica",
        number="500",
        neighborhood="Copacabana",
        city="Rio de Janeiro",
        state="RJ",
        zip_code="22010000",
        city_code="3304557",
    ),
)

recipient_address is an Address — the buyer's address, required for NFE and ignored for NFSE. Every field is required, city_code (the 7-digit IBGE municipality code) included: it becomes enderDest on the wire and the SEFAZ rejects a partial one. state is also what resolves idDest — a buyer in another state is emitted as an interstate operation automatically. A missing or incomplete address raises a ValueError locally, before the request goes out.

items is a list of Product (stackin.br) — description/amount apply to any document type; ncm/cfop (plus everything else on Product: cest, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good).

Retrying safely

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 number burned, and undoing it means cancelling, which has a deadline.

Pass idempotency_key to make the retry safe:

from decimal import Decimal

key = str(uuid.uuid4())

result = invoice.issue(
    document_type=DocumentType.NFSE,
    client_name="Maria Silva",
    tax_id="12345678909",
    items=[Product(description="Consultoria", unit_price=Decimal("1500.00"))],
    idempotency_key=key,
)

Retry with the same key and the same body and you get the first response back, replayed — no second document, no credit consumed. reissue() takes the same argument.

Situation What the API does
New key issues normally, records the response
Same key, same body replays the recorded response
Same key, different body APIError 422
Same key, first call still running APIError 409
Previous attempt failed key is released — the retry issues
Key older than 24 hours treated as new

Generate the key yourself and keep it for as long as you might retry — a uuid4 per business event, not per HTTP call. The SDK never generates one, because a key minted per call would protect nothing, and because two genuinely separate invoices for the same customer and amount on the same day are a normal thing to issue.

Correcting a document

Some mistakes don't need a cancellation. A wrong product name, wrong transport details, a typo in the extra information — a CC-e (carta de correção) fixes those, and it is free: no new credit, no burned series number, no reissue.

result = invoice.correct(
    "35240912345678000199550010000000011000000017",
    document_type=DocumentType.NFE,
    correction="Transportadora corrigida para Rapido Ltda",
)

The correction text is 15 to 1000 characters, checked locally before the call.

What a CC-e cannot fix: anything that changes the tax owed (base, rate, price, quantity, totals), the buyer or the seller, or the issue date. Those still mean cancelling and reissuing. The API sends the legally fixed wording that says exactly this, attached to every correction.

The original document does not change — the CC-e is an event attached to it, and the authorized XML stays as it was. A document accepts at most 20 of them, and they are numbered for you.

NF-e only. NFS-e has no correction letter, and asking for one returns a 409.

Invalidating unused numbers

NF-e numbering is sequential and the SEFAZ expects it to have no gaps. A number gets reserved the moment issuing starts, so a submission that fails afterwards — a rejection, a timeout — leaves a hole in the series. Reporting that range is how you close it.

result = invoice.invalidate(
    series="1",
    number_start=10,
    number_end=12,
    reason="Numeracao reservada e nao utilizada por falha no ERP",
)

The reason is 15 to 255 characters and the range is inclusive; both are checked locally, as is number_end not being below number_start.

A number that already reached the authorizer can't be invalidated. The API checks its own records first and answers 409 naming the offending numbers, without a round trip — and the authorizer checks again for what we can't see from here.

NF-e only, and it takes no access key: there is no document to point at.

Documents issued against you

Everything above serves the issuer. These two serve the recipient: what suppliers billed to this CNPJ, and the formal answer to it.

Reading the list never calls the SEFAZ. The authorizer caps how many times a CNPJ may ask for its distribution per day, so collecting runs on a schedule on the API side and a page refresh cannot spend that allowance.

from stackin import Manifestation

page = client.received(limit=20)
for document in page["data"]:
    print(document["access_key"], document["issuer_name"], document["amount"])

client.manifest(access_key, manifestation=Manifestation.CIENCIA)
client.manifest(
    access_key,
    manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
    reason="Mercadoria nunca chegou ao endereco",
)

Before you answer a document the SEFAZ sends only a summary (resNFe): access key, issuer, amount, date. The full document (nfeProc) arrives after a manifestation, and the schema field on each row says which one you hold.

The four answers are 210200 Confirmação da Operação, 210210 Ciência da Operação, 210220 Desconhecimento da Operação and 210240 Operação não Realizada. Only the last one takes a reason, and it requires one — both rules are checked locally, before the request goes out, because a round trip to be told a fixed rule is a round trip wasted.

Looking up a code, or who a CNPJ belongs to

Two more clients, for the tables an issuer reads while filling a document. Neither writes anything.

from stackin import FiscalReference, Taxpayer

ref = FiscalReference(api_key="...")          # country="BR" by default

ref.ncm.get("84716052")                       # one code
ref.ncm.search("teclado", limit=5)            # a page of matches
ref.cfop.get("5102")
ref.kinds()                                   # what this country has
ref.kind("ibs_cbs_class").get("000001")       # any kind, named or not
ref.search("teclado")                         # every kind at once

Taxpayer(api_key="...").get("00000000000191")

cfop, ncm, cest, cst, csosn, iss_service, icms_fuel and ibs_cbs_class have accessors. kind(name) reaches anything else, including a classification published after this release — ask kinds() rather than trusting this list.

metadata is passed through as the API sends it and differs per kind: utrib on an NCM, ncm_code on a CEST, tax_type on a CST, None on an ISS service.

Three things worth knowing before you loop:

  • These share the invoice read allowance — 600 calls a minute per key, the same bucket consult(), history(), received() and pdf() draw from. One search() page beats N get() calls.
  • Ordering is fixed (kind, then code, ascending). Unlike history(), these take no sort_by/order_by.
  • A 404 from Taxpayer does not mean the company does not exist. That registry reloads monthly, so a recently registered CNPJ is simply not in it yet. Do not build a validation rule on it.

Taxpayer has one method and keeps one: the registry holds the names and addresses of real people, so there is no search over it, by design.

Errors

  • stackin.APIError — the API responded with a non-2xx status (status_code, detail) — a 401 here means api_key is missing, wrong, or was rotated.
  • stackin.ConnectionFailedError — the API didn't respond (network/DNS/timeout).
  • ValueError — issue()'s items is empty, missing ncm/cfop on an item for NFE, or a missing/incomplete recipient_address on NFE.

Building the full fiscal document (issuer data, service code, tax groups, schema-accurate XML) is the API's job — configured once per company, not passed on every call.

Examples

Runnable end-to-end scripts in examples/nfe/ and examples/nfse/ — one file per field/variant, from the bare minimum to every field filled. examples/consult_invoice.py, examples/cancel_invoice.py, and examples/reissue_invoice.py cover the operations that act on an already-issued document. examples/lookup_fiscal_reference.py and examples/lookup_taxpayer.py cover the two read-only clients.

Metadata

Release files for stackin-python-sdk 0.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stackin-python-sdk 0.10.0
File Size Uploaded
stackin_python_sdk-0.10.0.tar.gz 24.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for stackin-python-sdk 0.10.0
File Interpreter ABI Platform
stackin_python_sdk-0.10.0-py3-none-any.whl Python 3 none any Details

Total release size: 46.2 kB

Release files / stackin_python_sdk-0.10.0.tar.gz

Download URL stackin_python_sdk-0.10.0.tar.gz
Size 24.1 kB
Tags Source
SHA-256 checksum
How to use checksums
ad0ad1dc73503618a9a7aa2d1525dc697a6f90f6b6724456a612a51af3c107c5
BLAKE2b-256 checksum
How to use checksums
0ce2803798979e3703b4d5901f81eada9740ccf6ea4747896e59af502197849e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / stackin_python_sdk-0.10.0-py3-none-any.whl

Download URL stackin_python_sdk-0.10.0-py3-none-any.whl
Size 22.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a63c0c7380c0fb31065470841f9cd78e0b6b8e601267787d0fcdae28db30ae70
BLAKE2b-256 checksum
How to use checksums
9a5a759330bf48a5dc08a48fe5dfa7c5baeda3b407f40219ea9489647261fb51
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.10.0 This release

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page