IRS filing season is open. File your IRS for free (early access) →
descodify
Invoicing

Descodify API Quickstart: Authenticate, Create a Customer, Issue an Invoice

How to authenticate against the Descodify API, create a customer, issue an invoice, record the payment, and pull the SAF-T export: idempotency, scopes, rate limits, and the error envelope explained with real request/response examples.

About 5 minutes.

Last updated:

Say you're running your own storefront, or a back-office spreadsheet-replacement, and every sale needs a certified Portuguese invoice on the other end. You don't want to open the Descodify app and re-type each one. This is the path from an API key to your first issued invoice.

Step 1: Create a key

In Settings → Developers, the account owner creates an API key. Pick the scopes it needs: customers:read, customers:write, the matching pair for products and invoices, and saft:read plus saft:write for the fiscal export. A write scope always includes read on the same resource, so invoices:write is enough to both create and fetch invoices.

saft stands apart. Nothing outside it reaches either of its scopes, because one export call returns your whole fiscal record for the period rather than a row, so invoices:write deliberately does not get there. saft:write is narrower again: it is what produces a month's export and keeps the file, while saft:read only reads what is already there. A month can be exported as many times as you need, and every file is kept, so the split is about which calls write, not about a quota.

The secret is shown once, in the form dsc_live_<hex>. Copy it somewhere safe. There's no way to see it again, only to revoke the key and create a new one.

Every request sends it as a bearer token:

Authorization: Bearer dsc_live_1a2b3c4d...

Get this wrong (missing header, expired key, revoked key) and you get a 401 before anything else runs:

{
  "error": {
    "type": "invalid_api_key",
    "message": "The API key is invalid, revoked, or expired."
  }
}

Step 2: Create a customer

A minimal customer only needs three fields: customerType, name, and country. Everything else, email, address, VAT number, defaults to null if you leave it out.

curl https://descodify.pt/api/v1/customers \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -H "Content-Type: application/json" \
  -d '{
    "customerType": "business",
    "name": "Acme Lda",
    "country": "PT",
    "vatNumber": "PT513445377"
  }'

The response is the created customer, with an id you'll use as customerId on the invoice.

Step 3: Issue the invoice

The headline path for an e-commerce or back-office integration is create-and-issue in a single call: send "action": "issue" on the create request, and Descodify creates the draft, then runs it through the exact same certified path the app itself uses (series validation, ATCUD, digital signature) before handing back the issued invoice.

This call requires an Idempotency-Key header. Generate a fresh UUID per logical invoice, not per HTTP attempt, so a network retry replays the stored response instead of minting a second certified document.

curl https://descodify.pt/api/v1/invoices \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f2c6b6a-2f0e-4b0a-9c0a-000000000001" \
  -d '{
    "invoiceType": "invoice",
    "customerId": "cust_9f8e7d6c",
    "action": "issue",
    "items": [
      {
        "description": "Consulting, June 2026",
        "quantity": 1,
        "unitPrice": "500.00",
        "vatRate": 23,
        "itemType": "services"
      }
    ]
  }'

unitPrice is the unit price net of tax, as a decimal string: "500.00", or "1.789" if you price per litre or per kilo. Up to six decimals. It is in the invoice's currency, which currencyCode states once at document level and which defaults to EUR. On a USD invoice, "500.00" is 500.00 USD, and we derive the euro figures the document is signed and reported in. The API stores and returns that value as unitPriceMicros (millionths, 500000000 = 500.00), the precision Portuguese SAF-T needs once a line discount is apportioned; you can send unitPriceMicros directly instead if you prefer integer arithmetic, but never both. vatRate is a whole percent (23 = 23%). invoiceType picks the document type: invoice (FT), invoice_receipt (FR), simplified (FS), credit_note (NC), debit_note (ND), receipt (RG), or receipt_vat_cash (RC).

A successful response is a 201 with the issued invoice: sequential invoiceNumber, atcud, qrCodeData, a certification hash, and atCommStatus: "pending". AT communication runs asynchronously, the same as when you issue from the app, so poll GET /api/v1/invoices/{id} (or check back later) to see it move to "accepted" once AT has confirmed it.

If the draft can't be issued

Validation problems (a missing required field, a rule the invoice fails) come back as a 422 with the draft preserved, so you don't have to start over:

{
  "error": {
    "type": "invoice_not_issuable",
    "message": "...",
    "details": { "draft_id": "inv_1a2b3c4d" }
  }
}

Fix whatever the message points at (often reconnecting AT, or a missing customer field the invoice type requires), then call POST /api/v1/invoices/{draft_id}/issue with the same Idempotency-Key to retry the exact same logical invoice.

Two-step alternative: draft, then issue separately

If your integration wants a review step between creating and issuing, drop "action": "issue" from the create call. That returns a plain draft (status: "draft", no invoiceNumber yet). When you're ready, issue it with:

curl -X POST https://descodify.pt/api/v1/invoices/inv_1a2b3c4d/issue \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -H "Idempotency-Key: 6f2c6b6a-2f0e-4b0a-9c0a-000000000002"

Same Idempotency-Key requirement, same certified path, same response shape.

Step 4: Record the payment

When the money arrives, record it against the issued invoice. That is not a status flip: it issues a receipt (recibo, RG), numbered in its own series and signed like any other document. So this call carries the same obligations as issuing, and needs its own fresh Idempotency-Key.

curl https://descodify.pt/api/v1/invoices/inv_1a2b3c4d/payments \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f2c6b6a-2f0e-4b0a-9c0a-000000000003" \
  -d '{
    "paymentMethod": "bank_transfer",
    "amount": "615.00",
    "paymentDate": "2026-07-04"
  }'

amount is a decimal string in the invoice's currency, the same convention as unitPrice. Partial payments are fine: send what arrived, and each one gets its own receipt. paymentMethod is one of card, cash, bank_transfer, mbway, spin, multibanco, or other.

If the client paid in another currency, say what actually landed and the rate is resolved at the payment date, not the invoice date, since that gap is the exchange difference:

{
  "paymentMethod": "bank_transfer",
  "amount": "615.00",
  "paymentDate": "2026-07-04",
  "received": { "currencyCode": "USD", "amount": "660.40" }
}

Pay more than the invoice has outstanding and you get a 422. The response is the receipt, with its own invoiceNumber, atcud, and hash.

Step 5: Pull the fiscal export (SAF-T)

The standard audit file. A month is the unit the Portal das Finanças takes, so a month is the unit that gets recorded: ask for one and you get the file, and the file is kept.

There is a second door for any other window, covered at the end of this step.

curl -X PUT "https://descodify.pt/api/v1/saft/export/2026-02" \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -o SAFT_2026-02.xml

The first call produces the export, stores it, and hands it back. Call it again and the same bytes return without anything being produced, so retrying after a timeout is safe. If you issued an invoice into February after exporting it, the next call notices and produces a fresh file, because the stored one no longer covers the month. Uploading a month to AT more than once creates no duplicates: AT takes the documents out of the file and keeps only what is new.

PUT needs the saft:write scope and a Completo plan. Reading a month you have already exported needs neither:

curl "https://descodify.pt/api/v1/saft/export/2026-02" \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -o SAFT_2026-02.xml

GET returns the stored bytes exactly as they were produced, never regenerated, so the file stays the record of what you handed over on the day you handed it over. GET /api/v1/saft/exports lists every month you have exported. Both responses are application/xml, not JSON, with a Content-Disposition filename, so -o or --remote-header-name is the right way to take them.

Both are capped at 12 calls an hour per key. The cap is about how much one call hands over, not how long it takes to answer: GET only streams bytes that already exist, and it is still capped, because what it streams is a whole period rather than a row.

Any other window

A month is what you file. For anything else, an accountant asking for a quarter or for last year, there is a second door that takes dates:

curl "https://descodify.pt/api/v1/saft?year=2026&start=2026-01-01&end=2026-03-31" \
  -H "Authorization: Bearer dsc_live_1a2b3c4d..." \
  -o SAFT_Q1_2026.xml

Leave start and end off and you get the whole fiscal year. This one needs saft:read and a Completo plan, and it stores nothing: it answers a question, where the monthly door keeps a record. It shares the same 12 calls an hour.

Rate limits and fair usage

Every call is rate-limited per key under a fair-usage policy. Go over it and you get a 429:

{
  "error": {
    "type": "rate_limited",
    "message": "Rate limit exceeded. Retry after the indicated interval."
  }
}

with a Retry-After header (in seconds) telling you how long to back off. Honor it: pause for that many seconds, then retry. There's no remaining-quota header, so a well-behaved client backs off on the 429 and paces itself rather than hammering.

Issuing a certified document, an invoice or a receipt, also has a daily quota (creating drafts and reading data don't count against it). If you hit it, that specific call returns a 429 with a distinct type:

{
  "error": {
    "type": "daily_quota_exceeded",
    "message": "You've reached today's certified-invoice limit. Apply for a higher threshold: mailto:hello@descodify.pt."
  }
}

The Retry-After on that response tells you when the daily window resets. If your volume needs a higher issuance threshold, email hello@descodify.pt and we'll raise it.

The fiscal export has its own hourly budget, 12 calls per key. Over it, that call returns a 429 with type bulk_export_rate_limited and a Retry-After in seconds. Cache a closed period's file rather than re-pulling it on a schedule.

The error envelope

Every non-2xx response has the same shape:

{
  "error": {
    "type": "insufficient_scope",
    "message": "This key lacks the required scope \"invoices:write\".",
    "details": "optional, present on validation errors"
  }
}

Common type values: invalid_api_key (401), insufficient_scope (403), not_found (404), validation_error (422, details carries the field-level issues), idempotency_key_required (400, missing header on a call that mints a document), idempotency_key_reused (422, same key with a different body), idempotency_in_progress (409, a call with that key is still running), invoice_not_issuable (422, draft preserved), invoice_immutable (409, tried to edit or re-issue an already-issued document), rate_limited (429), daily_quota_exceeded (429, the issuance quota), and bulk_export_rate_limited (429, the export budget).

Common mistakes

  • Forgetting Idempotency-Key on a call that mints a document. POST /invoices with "action": "issue", POST /invoices/{id}/issue, and POST /invoices/{id}/payments all reject with idempotency_key_required (400) if it's missing. It's not required on a plain draft create or on customer/product writes.
  • Treating a payment as a status update. Recording a payment issues a receipt, so it consumes issuance quota and needs a fresh key per payment, not per invoice. Two partial payments are two receipts and two keys.
  • Reaching for the export with invoices:read. Reading the export needs saft:read and producing a month needs saft:write, and no scope outside the saft resource reaches either. A key without the right one gets insufficient_scope (403).
  • Reusing an Idempotency-Key for a different logical request. The API treats the same key with a different body as idempotency_key_reused (422), not as a new attempt. Generate one key per invoice you intend to issue, and keep sending that same key if you retry it.
  • Assuming a scope covers more than it does. customers:write doesn't touch products or invoices, and invoices:read can't issue anything. Scopes are per-resource, and you can change them on an existing key in Settings → Developers without rotating it.
  • Editing or re-issuing an already-issued invoice. Issued documents are immutable (invoice_immutable, 409). To correct one, create a credit note (invoiceType: "credit_note", originalInvoiceId set to the invoice you're correcting) instead.

What's not here yet

There's no sandbox or test mode: every key issues real, certified documents against your organization, so test against a throwaway org rather than production. Webhooks aren't available either, poll GET /invoices/{id} for the atCommStatus transition. Both are worth knowing before you build around gaps that don't exist yet.

Full reference

The complete OpenAPI 3.1 document, every field, every response shape, lives at /api/v1/openapi.json, and the browsable version is on the API reference page. For the positioning, scopes, and rate limits at a glance, see the Developers page.

Related terms