Skip to content

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

RequirementDescription
Portuguese entityAn entity with country_code: "PT", its legal name, NIF and address
ATCUD validation codesOne 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

SupportedNot 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 codesCash-accounting VAT
Foreign-currency documents (reported in EUR)Drafts, editing issued documents, document email and e-invoicing
Payments on invoices and advance invoicesDirect payments on credit notes, and applying an advance invoice to a final invoice
SAF-T PT export and PDF archivesRecurring invoices, the native iOS and Android apps, and importing Portuguese businesses through legacy migration
Manual recovery of paper documentsCorrecting 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_formAlso provide
sole_traderNothing else
limited_liability_company, public_limited_company, partnership_limited_by_sharesregistration_office, company_number and starting_capital
other_companyregistration_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.

Create Portugal entitytypescript
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.

Create sandbox ATCUD seriestypescript
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:

Create issued invoicetypescript
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 like 2026-09-10; don’t derive them with toISOString().slice(0, 10), which uses UTC and can pick the wrong day around midnight.
  • Service dates go in date_service and date_service_to, and payment dates in payments[].date, each for when that event happened.
  • Lines take one tax each. Item names are limited to 200 characters; unit is optional and exports as UN.
  • Discounts go in each line’s discounts array, for example { "type": "percent", "value": 50 } for 50% (not 0.5).
  • Card payments use type: "credit_card" or type: "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-1 on 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:

CodeWording added
M07Isento artigo 9.º do CIVA
M15Regime da margem de lucro-Objetos de coleção e antiguidades
M21IVA - não confere direito à dedução
M35, M40, M42, M43IVA - 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.

  1. Call GET /invoices/{id}/credit-options (SDK: invoices.getCreditOptions(id, options)) to get each line’s remaining_quantity.
  2. Create the credit note with the original line details and those quantities, the invoice in linked_documents, and a short pt.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_code and its number in pt.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).

Export SAF-Ttypescript
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 XMLPDF archive
StartGET /fiscalization/pt/saft/reportPOST /documents/export/pdf
PeriodQuery: from, toJSON: date_from, date_to
DocumentsEverything for the selected modetypes, for example ["invoice", "credit_note"]
ResultXML file in the responseA job; poll GET /documents/export/pdf/{id}
DownloadSave the response bytesWhen 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.

Run the sandbox launch flowtypescript
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

ProblemFix
422 on a /custom endpointUse the standard create endpoints.
No active seriesCreate or reactivate an ATCUD series for that document family.
Registry office and company number too longShorten the registry name to fit 50 characters with the number.
Issue date isn’t todaySend today’s date in Europe/Lisbon.
M19 without a reasonAdd pt_exemption_reason on the tax or the line.
Credit exceeds the originalUse remaining_quantity from credit-options.

See the PT ATCUD Series API Reference, PT SAF-T API Reference, and Invoices API Reference.