Portugal (ATCUD and SAF-T)
Portuguese invoices carry an ATCUD code from a series registered with AT, the Portuguese tax authority, and must be exportable as SAF-T PT. Once you set up the entity and its ATCUD series, Space Invoices numbers each document, prints the ATCUD and QR code on the PDF, and produces the SAF-T file.
Space Invoices doesn’t decide whether a transaction qualifies for a VAT exemption; that stays with the issuer and their accountant.
Prerequisites
| Requirement | Description |
|---|---|
| Portuguese entity | An entity with country_code: "PT", its legal name, NIF and address |
| ATCUD validation codes | One per document series, returned by AT when you register the series on the Portal das Finanças |
Sandbox entities accept sandbox validation codes (starting with AA); live entities need the real codes from AT.
Scope
| Supported | Not supported |
|---|---|
| Invoices and advance invoices (FT), credit notes (NC) and estimates (OR) | Delivery notes, FS simplified invoices, self-billing and third-party invoicing |
| Exempt and zero-rate lines with AT exemption codes | Cash-accounting VAT |
| Foreign-currency documents (reported in EUR) | Drafts, editing issued documents, document email and e-invoicing |
| Payments on invoices and advance invoices | Direct payments on credit notes, and applying an advance invoice to a final invoice |
| SAF-T PT export and PDF archives | Recurring invoices, the native iOS and Android apps, and importing Portuguese businesses through legacy migration |
| Manual recovery of paper documents | Correcting advance invoices |
Each entity’s country_rules.capabilities lists what’s available, so you can decide which actions to offer. The API also checks when an action runs.
Existing recurring schedules for a Portugal entity are paused on their next run, with last_error set and a recurring_invoice.generation_failed webhook sent. To switch off previously saved e-invoicing settings, send enabled: false or auto_send: false; both are cleared together.
Setup
1. Create the entity
Set settings.legal_details.legal_form. The company details you need depend on it:
legal_form | Also provide |
|---|---|
sole_trader | Nothing else |
limited_liability_company, public_limited_company, partnership_limited_by_shares | registration_office, company_number and starting_capital |
other_company | registration_office and company_number; starting_capital is optional |
Add paid_up_capital, equity and in_liquidation when they apply.
The registry office, a space and the company number must fit in 50 characters together (the SAF-T CompanyID limit). Use a recognizable abbreviation of the correct registry, such as C.R.C. Lisboa, and keep the number unchanged.
const _entity = await sdk.entities.create({
name: "João Silva",
country: "Portugal",
country_code: "PT",
tax_number: "200000004",
currency_code: "EUR",
locale: "pt-PT",
address: "Avenida da Liberdade 10",
city: "Lisboa",
post_code: "1000-001",
settings: {
legal_details: {
legal_form: "sole_trader",
},
},
});2. Create the ATCUD series
Create an active series for each document family you issue. Invoices and advance invoices share the FT series. Use the validation code AT returned: in production, at least eight uppercase consonants or digits 2–9 (for example BCDF2345); in sandbox, a code starting with AA. Space Invoices checks the format only, not that AT registered the code.
const _series = await sdk.ptAtcudSeries.create({
document_type: "invoice",
series_code: "FT2026A",
validation_code: "AABC2345",
first_number: "FT FT2026A/00001",
start_date: "2026-01-01",
is_manual: false,
initial_sequence: 1,
});A series keeps numbering across years, and series codes are case-insensitive within a family. When you stop using one, retire it in Portugal settings or with POST /fiscalization/pt/atcud-series/{id}/retire. Retired series can’t issue documents or be reopened. Retiring doesn’t notify AT, so finalize the series with AT separately.
Issuing invoices
Create invoices with the standard endpoints (not /custom). Space Invoices uses the active series for the document family, or the one you set in pt.series_id:
const _invoice = await sdk.invoices.create({
customer: {
name: "Porto Payloads Lda",
country: "Portugal",
country_code: "PT",
tax_number: "516403125",
address: "Rua de Santa Catarina 100",
city: "Porto",
post_code: "4000-442",
},
items: [
{
name: "Ground operations service",
quantity: 1,
price: 100,
classification: "service",
unit: "UN",
taxes: [{ rate: 23, classification: "standard" }],
},
{
name: "Exempt training service",
quantity: 1,
price: 50,
classification: "service",
unit: "UN",
taxes: [
{
rate: 0,
pt_exemption_code: "M07",
pt_exemption_reason: "Isento de IVA ao abrigo do artigo 9.º do CIVA",
},
],
},
],
pt: {
series_id: "atc_000000000000000000000001",
},
});- Issue date is today in Portugal (
Europe/Lisbon). Send date-only values like2026-09-10; don’t derive them withtoISOString().slice(0, 10), which uses UTC and can pick the wrong day around midnight. - Service dates go in
date_serviceanddate_service_to, and payment dates inpayments[].date, each for when that event happened. - Lines take one tax each. Item names are limited to 200 characters;
unitis optional and exports asUN. - Discounts go in each line’s
discountsarray, for example{ "type": "percent", "value": 50 }for 50% (not0.5). - Card payments use
type: "credit_card"ortype: "debit_card". - Final consumers are supported on ordinary FT invoices; this doesn’t make them FS simplified invoices.
- Retries should reuse the original body and request ID; see idempotency.
Identifying the operator
pt.operator_id is an optional operator identifier of up to 30 characters, stored as SAF-T SourceID.
- If you omit it, Space Invoices uses the authenticated user or API key.
- If several staff members share one API key, send a stable staff identifier such as
reception-1on each document. - Don’t use a person’s name or NIF.
The old operator_first_name, operator_last_name, operator_tax_number and account_* fields are ignored; remove them from your integration.
VAT exemptions
A zero-rate line needs an AT exemption code. For standard codes, send just the code and Space Invoices adds the legal wording:
{ "rate": 0, "pt_exemption_code": "M07"}Every supported code except M19 has standard wording. For example:
| Code | Wording added |
|---|---|
M07 | Isento artigo 9.º do CIVA |
M15 | Regime da margem de lucro-Objetos de coleção e antiguidades |
M21 | IVA - não confere direito à dedução |
M35, M40, M42, M43 | IVA - autoliquidação |
M35, M40, M42 and M43 share a mention but have different legal grounds, so pick the right code. M35 is for qualifying construction or rehabilitation under Decreto-Lei n.º 97/2026, on invoices issued after 1 July 2026; other construction reverse-charge cases still use M31.
To use your own wording, add pt_exemption_reason (up to 60 characters). M19 (other exemptions) has no standard wording, so it always needs a reason:
{ "rate": 0, "pt_exemption_code": "M19", "pt_exemption_reason": "<applicable legal provision>"}You can save the code and reason on a reusable tax and reference it from lines. A reason sent on the line takes precedence over the saved tax. If the line uses a different code from the saved tax, the saved reason isn’t carried over and the new code’s standard wording is used, so M19 then needs a reason on the line. Issued documents keep their wording even if the tax changes later.
Credit notes
A credit note must reference an issued invoice (inv_) from the same entity. It keeps the original customer and currency. Several partial credit notes are allowed, up to the original amounts.
- Call
GET /invoices/{id}/credit-options(SDK:invoices.getCreditOptions(id, options)) to get each line’sremaining_quantity. - Create the credit note with the original line details and those quantities, the invoice in
linked_documents, and a shortpt.correction_reason(up to 50 characters):
{ "linked_documents": ["inv_6595a27b5d35015c3ef0c3fd"], "pt": { "series_id": "pt_credit_note_series_00000000000001", "correction_reason": "Returned goods" }}Reasons that fit the limit include Anulação por erro de faturação, Devolução de mercadoria and Anulação de pack reembolsado.
Partial credits can be a cent off a manual recalculation because of rounding. If you send expected_total_with_tax, take it from the calculation preview.
A credit note doesn’t refund money; process refunds separately. If you reduce VAT after a correction, attach evidence that the customer was informed or reimbursed to the document. Voiding a credit note releases the balance it used. Cancelling an invoice voids it in place, but only while no active credit note references it; void those credit notes first.
Manual recovery
If you had to issue paper documents while your system was unavailable, record them later with pt.manual: true and an active manual series:
- Put the paper series in
pt.manual_series_codeand its number inpt.manual_sequential_number. Use a string such as"00001"to keep leading zeros. - Keep each paper document’s actual issue date, and recover documents in date order within each series. To recover an older document after newer ones, use a separate recovery series.
- A paper original can’t be recovered again while an earlier recovery of it is active, even in another series. Cancel the earlier recovery to record a corrected one.
Manual recovery isn’t a way to backdate ordinary invoices or import invoices from another system.
Exports
SAF-T PT
The default monthly export covers from to to within one calendar month, ending no later than today. audit covers a full year (up to today for the current year).
const _xml = await sdk.ptSafT.generateReport({
from: "2026-01-01",
to: "2026-01-31",
});
const _auditXml = await sdk.ptSafT.generateReport({
mode: "audit",
from: "2026-01-01",
});SAF-T is Windows-1252 XML. Save the response bytes as they are, without converting to UTF-8. The JavaScript SDK returns a Blob.
PDF archive
| SAF-T XML | PDF archive | |
|---|---|---|
| Start | GET /fiscalization/pt/saft/report | POST /documents/export/pdf |
| Period | Query: from, to | JSON: date_from, date_to |
| Documents | Everything for the selected mode | types, for example ["invoice", "credit_note"] |
| Result | XML file in the response | A job; poll GET /documents/export/pdf/{id} |
| Download | Save the response bytes | When status is completed, use file_url |
Poll until status is completed or failed, not until progress reaches a value. If polling times out, keep polling the same job instead of starting another. Send your API key only to your API origin, never to a storage host in file_url.
Sandbox walkthrough
This example runs the whole flow in a fresh sandbox company: two invoices (€40 and €20), a €40 debit-card payment, a linked €40 credit note, and both exports. It needs a sandbox API key in SI_SANDBOX_KEY and writes portugal-launch.xml and portugal-launch.zip.
import SpaceInvoices from "@spaceinvoices/js-sdk/sdk";
import * as fs from "node:fs";
const key = process.env.SI_SANDBOX_KEY;
if (!key?.startsWith("sk_sandbox_")) throw new Error("Set a sandbox account API key in SI_SANDBOX_KEY");
const sdk = new SpaceInvoices(key);
function portugalDate(instant = new Date()) {
const parts = new Intl.DateTimeFormat("en", {
timeZone: "Europe/Lisbon", year: "numeric", month: "2-digit", day: "2-digit",
}).formatToParts(instant);
const part = (type: string) => parts.find((value) => value.type === type)!.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
const today = portugalDate();
// Example identity for this sandbox walkthrough only.
const entity = await sdk.entities.create({
name: "Clinica Demo, Lda.", country: "Portugal", country_code: "PT",
tax_number: "501964843", company_number: "501964843", starting_capital: 5000,
address: "Rua de Teste 10", city: "Lisboa", post_code: "1000-001",
currency_code: "EUR", locale: "pt-PT",
settings: { legal_details: {
legal_form: "limited_liability_company", registration_office: "C.R.C. Lisboa",
} },
});
if (entity.environment !== "sandbox") throw new Error("This walkthrough requires a sandbox entity");
const context = { entity_id: entity.id };
console.log("Save this entity ID:", entity.id);
const ft = await sdk.ptAtcudSeries.create({
document_type: "invoice", series_code: "DEMO", validation_code: "AABC2345",
first_number: "FT DEMO/00001", start_date: today, initial_sequence: 1,
}, context);
const nc = await sdk.ptAtcudSeries.create({
document_type: "credit_note", series_code: "DEMO", validation_code: "AABC2346",
first_number: "NC DEMO/00001", start_date: today, initial_sequence: 1,
}, context);
const customer = { name: "Consumidor final", is_end_consumer: true, country: "Portugal", country_code: "PT" };
// Use M07 only after establishing that the actual supply qualifies.
const line = {
name: "Eligible exempt service", classification: "service" as const,
quantity: 1, unit: "UN", price: 40,
taxes: [{ rate: 0,
pt_exemption_code: "M07" }],
};
const first = await sdk.invoices.create({
date: portugalDate(), date_service: today, customer, items: [line],
pt: { series_id: ft.id, operator_id: "reception-1" },
payments: [{ type: "debit_card", amount: 40, date: today }],
}, { ...context, request_id: `${entity.id}:launch:invoice-1` });
const discounted = await sdk.invoices.create({
date: portugalDate(), date_service: today, customer,
items: [{ ...line, discounts: [{ type: "percent", value: 50 }] }],
pt: { series_id: ft.id, operator_id: "reception-1" },
}, { ...context, request_id: `${entity.id}:launch:invoice-2` });
const credit = await sdk.creditNotes.create({
date: portugalDate(), customer, linked_documents: [first.id], items: [line],
pt: { series_id: nc.id, operator_id: "reception-1", correction_reason: "Anulação por erro de faturação" },
}, { ...context, request_id: `${entity.id}:launch:credit-1` });
if (first.total_with_tax !== 40 || discounted.total_with_tax !== 20 || credit.total_with_tax !== 40) {
throw new Error("Unexpected document totals; inspect the responses before continuing");
}
console.log(first.number, discounted.number, credit.number);
// This run's dates must be in one month for a single monthly SAF-T export.
const through = portugalDate();
if (today.slice(0, 7) !== through.slice(0, 7)) {
throw new Error("The run crossed a month boundary; export the two months separately");
}
const from = `${today.slice(0, 7)}-01`;
const xml = await sdk.ptSafT.generateReport({ from, to: through }, context);
fs.writeFileSync("portugal-launch.xml", new Uint8Array(await xml.arrayBuffer()));
const job = await sdk.exports.startPdfExport({
types: ["invoice", "credit_note"], date_from: from, date_to: through,
}, context);
console.log("Save this export job ID:", job.id);
let downloadUrl: string | null = null;
for (let attempt = 0; attempt < 60; attempt++) {
const status = await sdk.exports.getPdfStatus(job.id, context);
if (status.status === "failed") throw new Error(status.error ?? "PDF export failed");
if (status.status === "completed") {
if (!status.file_url) throw new Error("Archive unavailable; inspect the export job");
downloadUrl = status.file_url;
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!downloadUrl) throw new Error(`Export still running. Resume polling job ${job.id}; do not start another.`);
// Signed storage URLs need no API credential; API download routes do.
const apiOrigin = "https://eu.spaceinvoices.com";
const url = new URL(downloadUrl, apiOrigin);
const headers = url.origin === apiOrigin
? { Authorization: `Bearer ${key}`, "x-entity-id": entity.id }
: undefined;
const archive = await fetch(url, { headers });
if (!archive.ok) throw new Error(`Archive download failed: ${archive.status}`);
fs.writeFileSync("portugal-launch.zip", new Uint8Array(await archive.arrayBuffer()));Check the PDFs for the ATCUD, QR code, exemption wording and credit-note reference, and the SAF-T file for all three documents (FT DEMO/00001, FT DEMO/00002, NC DEMO/00001). The example’s M07 exemption is illustrative, not a default.
For production, repeat the setup with the issuer’s real details and AT’s validation codes. Sandbox series don’t carry over.
Common errors
| Problem | Fix |
|---|---|
422 on a /custom endpoint | Use the standard create endpoints. |
| No active series | Create or reactivate an ATCUD series for that document family. |
| Registry office and company number too long | Shorten the registry name to fit 50 characters with the number. |
| Issue date isn’t today | Send today’s date in Europe/Lisbon. |
M19 without a reason | Add pt_exemption_reason on the tax or the line. |
| Credit exceeds the original | Use remaining_quantity from credit-options. |
Related API
See the PT ATCUD Series API Reference, PT SAF-T API Reference, and Invoices API Reference.