Skip to content

Italy (FatturaPA / SdI)

Italy support is a country-specific compliance overlay for entities with country_code = "IT". It does not change the base invoice API for other countries.

When Italy validation applies

For Italian entities, final invoices and credit notes require FatturaPA-ready customer and routing data. Drafts can still be saved without completing every compliance field.

The API enforces these checks server-side. Web and embedded forms explain the supported buyer, seller and line-treatment requirements for Italian entities. The API remains authoritative for calculated totals, XML validation and issuance.

Configure the seller’s settings.tax_rules.italy.fiscal_regime explicitly as RF01 (ordinary regime) or RF19 (flat-rate regime, regime forfettario). The issuer snapshot captures that setting at issuance, including when finalizing a draft. Seller and buyer need complete legal names, street addresses, cities and country codes; Italian addresses also require a five-digit postal code.

VAT IDs and fiscal codes are independent. Use tax_number for VAT and settings.tax_rules.italy.fiscal_code for the seller’s optional codice fiscale. Use customer.sdi.fiscal_code for the buyer’s codice fiscale; domestic consumer customer.tax_number remains a supported legacy fiscal-code input. A company number is never promoted to a VAT ID.

RF19 flat-rate regime

Configure settings.tax_rules.italy.fiscal_regime as RF19 for a seller operating under Italy’s regime forfettario. RF19 sellers cannot charge VAT: every line must use a 0% tax, and a VAT-bearing line is refused with a 422 error.

For a domestic buyer, a zero-VAT RF19 line that does not supply its own items[].e_invoicing.italy treatment is automatically assigned tax nature N2.2 with the statutory legal reference “Operazione senza applicazione dell’IVA ai sensi dell’art. 1, commi da 54 a 89, Legge n. 190/2014”. Supply an explicit nature: "N2.2" with a different legal_reference on a line to override that statutory wording; any other nature on a domestic RF19 line is rejected.

For a foreign buyer, RF19 follows the same rule as any other seller: a flat-rate seller invoicing abroad still needs the actual applicable treatment (N2.1, N3.1 or N3.2, see Line tax treatment) with an explicit legal_reference. There is no automatic default, and N2.2 is refused with a 422 error for a foreign buyer.

N2.2 is otherwise scoped to a domestic RF19 buyer: an RF01 seller, or an RF19 seller invoicing a foreign buyer, supplying nature: "N2.2" is refused with a 422 error.

When the combined net total of a document’s N2.1 and N2.2 lines exceeds EUR 77.47, the generated XML declares stamp duty (DatiBollo, with BolloVirtuale: SI and ImportoBollo: 2.00). That EUR 2 is declared in the file only; it is not added to the invoice’s payable total, and the seller remains responsible for paying it through the ordinary imposta di bollo channel.

Line tax treatment

For eligible foreign zero-VAT sales, supply a tax nature and legal reference on each affected line. The supported values are:

Tax natureTreatment
N2.1Operations outside the territorial scope of Italian VAT
N3.1Exports
N3.2Intra-community supplies

Choose the treatment that applies to the actual transaction and enter its legal reference, such as the applicable statutory provision. The reference must contain 1–100 characters after trimming. A foreign customer or a zero VAT rate alone does not determine the correct legal treatment.

A fourth nature, N2.2 (flat-rate operations), applies only to an RF19 seller invoicing a domestic buyer and is assigned automatically; an RF19 seller invoicing a foreign buyer must instead use one of the natures above, like any other seller. See RF19 flat-rate regime above.

Saved catalog items can carry this data as defaults. Review it for each invoice or credit note: changing the buyer or VAT treatment can make a previously selected nature incompatible. Taxable lines must not carry a zero-VAT nature, and domestic zero-VAT treatments are outside the supported scope.

The same API fields apply to invoices and credit notes:

{
"e_invoicing": {
"italy": {
"nature": "N3.1",
"legal_reference": "Art. 8 DPR 633/72"
}
}
}

This is a line-level fragment, to be included in the relevant items[] entry. It illustrates an export treatment; it is not a default for all foreign buyers. The currency and stamp-duty limits in Supported scope still apply.

Routing matrix

FlowRequired routing
Italian seller to Italian businessProvide a recipient code or PEC when available; otherwise the API uses 0000000. Provide a courtesy copy and tell the buyer that the invoice is available through Agenzia delle Entrate.
Italian seller to Italian consumerNo SdI recipient code is required; the API uses consumer routing, but the customer fiscal code is required.
Italian seller to foreign businessUse the cross-border FatturaPA path. The buyer must have a VAT/tax ID in customer.tax_number; a company registration number is not a VAT ID.
Italian seller to foreign consumerKeep the consumer flow simple. Italian codice fiscale is not required.

customer.sdi is buyer routing input and is copied into the document customer snapshot. Root sdi on invoice and credit note responses is server-managed submission lifecycle state for SdI transmission and is not accepted in create requests.

With customer_id alone, the API copies the saved customer’s routing data. To override routing for one document, send the complete customer snapshot, including its name, country and tax identity, with customer.save_customer: false and the desired customer.sdi values. An explicit customer object is authoritative; omitted identity fields are not filled from the saved record.

Web and embedded forms

The invoice and credit-note forms share the Italy-specific controls:

  1. In Settings > Tax, select the seller’s actual supported fiscal regime and supply an independent fiscal code when applicable. Complete the legal address in company settings.
  2. Enter the buyer’s legal identity and address. Select end-consumer status for B2C, and enter the buyer’s fiscal code and any available recipient code or PEC in the SdI fields. This SdI block is only shown for an Italian buyer; a foreign buyer always routes to XXXXXXX (see Routing matrix), so the fields are hidden.
  3. A new line for a foreign buyer defaults to the entity’s 0% tax with VAT nature and Legal reference pre-filled: N2.1 with “Art. 7-ter DPR 633/72” for an intra-EU business buyer, N3.1 with “Art. 8 DPR 633/72” for a non-EU buyer. An intra-EU consumer keeps ordinary Italian VAT for an RF01 seller (a flat-rate seller still defaults to N2.1, see RF19 flat-rate regime). This default only ever applies to a line you have not edited; typed input is never overwritten, and switching the buyer back to Italy reverts an untouched line to its domestic tax.
  4. Choosing a VAT nature manually fills its standard Legal reference unless the field already holds text you wrote: N2.1 → “Art. 7-ter DPR 633/72”, N3.1 → “Art. 8 DPR 633/72”, N3.2 → “Art. 41 D.L. 331/93”, N2.2 → the Legge 190/2014 wording from RF19 flat-rate regime. Saved item forms expose the same treatment as a reusable default.
  5. A line with no tax shows a plain explanation and a one-click Use 0% action; choosing a nature from that state assigns the 0% tax at the same time.
  6. Review retained treatments after changing the buyer or line taxes. The form preserves your input and provides an explicit removal action for an incompatible treatment.
  7. Save a draft while completing issuance details, or issue after resolving the validation messages. A supplied line-treatment block must contain both fields, even in a draft.

For reverse-charge sales, the Italy forms keep the tax selector available: choose the applicable tax record and the transaction’s legal treatment. Reverse charge does not replace the explicit line tax needed to produce a tax summary. Saved tax rates are checked against the document date.

Standard document calculation uses the EU automation settings, which are opt-in and default to off. A foreign buyer alone does not remove the selected VAT. settings.tax_rules.eu.auto_reverse_charge applies reverse charge to qualifying intra-EU B2B sales; an explicitly selected reverse-charge tax also retains its treatment when automation is off. settings.tax_rules.eu.auto_remove_tax_export removes all taxes on exports outside the EU. That setting conflicts with this FatturaPA MVP’s requirement for an explicit tax on every line, so the forms explain the conflict and block final issuance while it applies. Review Settings > Tax and select taxes and legal treatments that reflect the actual sale. Custom documents preserve their supplied tax calculations and bypass these automatic transformations. If a copied custom document is changed enough to use standard creation, the standard automation rules apply again.

Validation errors appear inline at their own field. Only entity-level problems — a missing company VAT number or fiscal regime — appear in a summary that points to Settings, since resolving them happens outside the document form.

If a buyer’s EU VAT number cannot be verified in VIES, a notice next to the tax number explains that the sale is treated as B2C with standard tax. This is distinct from VIES itself being unreachable, which keeps the reverse-charge treatment and warns that validation was unavailable.

The stamp-duty notice appears only when the document’s combined N2.1 and N2.2 net total exceeds EUR 77.47; it does not calculate a stamp-duty amount or certify eligibility, and it states that the EUR 2 is declared in the SdI file rather than added to the document total. The API checks the absolute net-line total when issuing. Invoice preview and a successful save are not evidence of SdI acceptance; consult the document’s submission status.

Document lists show an SdI status badge. A document voided before dispatch is stored as failed with last_error.code: "SDI_CANCELLED"; the badge presents that specific case as cancelled rather than failed, since there is nothing for the user to act on. This is distinct from a rejected submission, which is a separate SdI outcome (see SdI lifecycle).

The courtesy PDF labels the seller’s and buyer’s VAT numbers “P.IVA” and prints a buyer’s codice fiscale as a separate line when one is on file. A zero-VAT line prints its legal reference, and the stamp-duty notice prints on the PDF when the same EUR 77.47 threshold is met.

Issued Italian invoices and credit notes cannot be edited, including while their SdI submission is pending or rejected. Their stored accounting data must continue to match the captured XML. Use the supported void or correction flow, which checks the submission outcome. Drafts remain editable; item edits use the same rate-level VAT rounding as creation and preview. If a draft changes during an edit or finalization, reload it before retrying.

Integration examples

These examples use an authenticated JavaScript SDK client and an existing Italian entity. Initialize the client with your access token as described in the JavaScript SDK guide. Every document call supplies entity_id; use the actual seller entity, not your platform’s entity. The identifiers and fiscal details below are illustrative and must be replaced with the parties’ real data.

Use a stable request_id for each create operation and reuse it when retrying that operation. Use a different ID for a different invoice or credit note. Do not run the examples automatically on page load.

Configure the seller

The entity must already have country_code = "IT". Configure RF01 or RF19 only if it is the seller’s actual regime. The example configures an ordinary VAT-registered company; it does not make a different regime eligible. Add the seller’s independent settings.tax_rules.italy.fiscal_code only when applicable.

Configure the Italian sellertypescript
await sdk.entities.update(entity.id, {
  name: "Example Seller SRL",
  address: "Via Milano 10",
  city: "Milano",
  post_code: "20100",
  tax_number: "IT12345678903",
  currency_code: "EUR",
  is_tax_subject: true,
  settings: {
    tax_rules: { italy: { fiscal_regime: "RF01" } },
  },
});

Domestic B2B

A domestic business can provide its VAT number and recipient code:

Domestic business with recipient codetypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Cliente Business SRL",
    "country_code": "IT",
    "address": "Via Roma 1",
    "city": "Roma",
    "post_code": "00100",
    "tax_number": "IT10987654321",
    "is_end_consumer": false,
    "sdi": {
      "recipient_code": "ABC1234"
    }
  },
  "items": [
    {
      "name": "Subscription",
      "quantity": 1,
      "price": 100,
      "taxes": [
        {
          "rate": 22
        }
      ]
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

For PEC routing, replace customer.sdi with { pec: "cliente@example.pec.it" }. If neither routing detail is available, omit it:

Domestic business without routing detailstypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Cliente Business SRL",
    "country_code": "IT",
    "address": "Via Roma 1",
    "city": "Roma",
    "post_code": "00100",
    "tax_number": "IT10987654321",
    "is_end_consumer": false
  },
  "items": [
    {
      "name": "Subscription",
      "quantity": 1,
      "price": 100,
      "taxes": [
        {
          "rate": 22
        }
      ]
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

The second example uses 0000000 routing. Provide a courtesy copy and tell the buyer that the invoice is available through Agenzia delle Entrate. A valid domestic business fiscal code can be supplied in customer.sdi.fiscal_code when the buyer has no VAT number.

Domestic B2C

Mark the buyer as an end consumer and supply their fiscal code explicitly. This example uses ordinary VAT; B2C does not automatically mean zero VAT.

Domestic consumer with fiscal codetypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Mario Rossi",
    "country_code": "IT",
    "address": "Via Roma 1",
    "city": "Roma",
    "post_code": "00100",
    "is_end_consumer": true,
    "sdi": {
      "fiscal_code": "RSSMRA80A01H501U"
    }
  },
  "items": [
    {
      "name": "In-app purchase",
      "quantity": 1,
      "price": 9.99,
      "taxes": [
        {
          "rate": 22
        }
      ]
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

RF19 flat-rate invoice

This example is for an RF19 seller invoicing a domestic Italian business. Both lines use a 0% tax and omit e_invoicing.italy, so the API assigns N2.2 and the statutory legal reference automatically. Their combined net total (85.00) exceeds the EUR 77.47 stamp-duty threshold, so the generated XML also declares DatiBollo; that EUR 2 is not added to the invoice total.

RF19 flat-rate invoice above the stamp-duty thresholdtypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Cliente Business SRL",
    "country_code": "IT",
    "address": "Via Roma 1",
    "city": "Roma",
    "post_code": "00100",
    "tax_number": "IT10987654321",
    "is_end_consumer": false,
    "sdi": {
      "recipient_code": "ABC1234"
    }
  },
  "items": [
    {
      "name": "Consulting session",
      "quantity": 1,
      "price": 45,
      "taxes": [
        {
          "rate": 0
        }
      ]
    },
    {
      "name": "Follow-up review",
      "quantity": 1,
      "price": 40,
      "taxes": [
        {
          "rate": 0
        }
      ]
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

Foreign B2B

This is an example of a service that qualifies for territorial exclusion under N2.1, with a net amount below the current stamp-duty limit. A foreign business requires its actual VAT/tax ID. Country and business status alone do not establish eligibility for this treatment.

Foreign business: territorial exclusion exampletypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Berlin Buyer GmbH",
    "country_code": "DE",
    "address": "Hauptstrasse 10",
    "city": "Berlin",
    "tax_number": "DE123456789",
    "is_end_consumer": false
  },
  "items": [
    {
      "name": "Subscription",
      "quantity": 1,
      "price": 50,
      "taxes": [
        {
          "rate": 0
        }
      ],
      "e_invoicing": {
        "italy": {
          "nature": "N2.1",
          "legal_reference": "Art. 7-ter DPR 633/72"
        }
      }
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

Foreign B2C

This example concerns eligible exported goods (N3.1), not a generic foreign-consumer or digital-service default. Italian fiscal code is not required for the foreign consumer. The API uses foreign routing (XXXXXXX).

Foreign consumer: exported goods exampletypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Jane Consumer",
    "country_code": "US",
    "address": "100 Main Street",
    "city": "Boston",
    "is_end_consumer": true
  },
  "items": [
    {
      "name": "Exported goods",
      "quantity": 1,
      "price": 19.99,
      "taxes": [
        {
          "rate": 0
        }
      ],
      "e_invoicing": {
        "italy": {
          "nature": "N3.1",
          "legal_reference": "Art. 8 DPR 633/72"
        }
      }
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

Intra-community goods

For a supply of goods that qualifies for intra-community treatment, use N3.2 with the appropriate legal reference. This is distinct from the service example above.

Eligible intra-community supply of goodstypescript
const _invoice = await sdk.invoices.create({
  "customer": {
    "name": "Berlin Buyer GmbH",
    "country_code": "DE",
    "address": "Hauptstrasse 10",
    "city": "Berlin",
    "tax_number": "DE123456789",
    "is_end_consumer": false
  },
  "items": [
    {
      "name": "Goods supplied within the EU",
      "quantity": 1,
      "price": 100,
      "taxes": [
        {
          "rate": 0
        }
      ],
      "e_invoicing": {
        "italy": {
          "nature": "N3.2",
          "legal_reference": "Art. 41 DL 331/93"
        }
      }
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-order-operation-id",
});

Always select an explicit 0% tax for zero-VAT examples. Omitting taxes is not equivalent to producing the required zero-VAT tax summary.

Credit notes

Credit notes use the same buyer identity and line-treatment fields. Send positive quantities and amounts; the API applies the credit-note sign. This example is a standalone credit note. For linking credit notes to invoices, see the other documents guide.

Create an Italian credit notetypescript
const _creditNote = await sdk.creditNotes.create({
  "customer": {
    "name": "Cliente Business SRL",
    "country_code": "IT",
    "address": "Via Roma 1",
    "city": "Roma",
    "post_code": "00100",
    "tax_number": "IT10987654321",
    "is_end_consumer": false,
    "sdi": {
      "recipient_code": "ABC1234"
    }
  },
  "items": [
    {
      "name": "Subscription credit",
      "quantity": 1,
      "price": 25,
      "taxes": [
        {
          "rate": 22
        }
      ]
    }
  ],
  "currency_code": "EUR"
}, {
  entity_id: entity.id,
  request_id: "your-stable-credit-operation-id",
});

Drafts and finalization

A draft may omit issuance-only details such as the buyer’s completed address or seller fiscal setup. When a line treatment object is supplied, its nature and legal reference must still satisfy the request schema; a half-filled object is not a valid draft payload. Complete the buyer and line details before finalizing. Finalization assigns a number, captures the issuer settings, and runs the API’s Italy checks.

Save a draft and issue it after reviewtypescript
const draft = await sdk.invoices.create({
  is_draft: true,
  currency_code: "EUR",
  items: [{ name: "Subscription", quantity: 1, price: 100, taxes: [{ rate: 22 }] }],
}, { entity_id: entity.id, request_id: "your-stable-draft-operation-id" });

// After completing and reviewing the buyer, issue the saved draft.
await sdk.invoices.update(draft.id, {
  customer: {
  "name": "Cliente Business SRL",
  "country_code": "IT",
  "address": "Via Roma 1",
  "city": "Roma",
  "post_code": "00100",
  "tax_number": "IT10987654321",
  "is_end_consumer": false,
  "sdi": {
    "recipient_code": "ABC1234"
  }
},
}, { entity_id: entity.id });
await sdk.documents.finalizeDocument(
  draft.id, {}, { type: "invoice" }, { entity_id: entity.id },
);
const issued = await sdk.invoices.get(draft.id, undefined, { entity_id: entity.id });
console.log(issued.sdi?.status);

Saved item defaults

Store the nature and legal reference on a catalog item when they are appropriate defaults. The following example creates an explicit zero-rate tax and attaches it to an export item. Review the treatment for each sale; using the item for a domestic or taxable sale can make its defaults incompatible.

Save an item with an explicit export treatmenttypescript
const zeroVat = await sdk.taxes.create({
  name: "Export VAT 0%",
  tax_rates: [{ rate: 0 }],
}, { entity_id: entity.id });
const _item = await sdk.items.create({
  name: "Exported goods",
  price: 19.99,
  tax_ids: [zeroVat.id],
  e_invoicing: {
    italy: { nature: "N3.1", legal_reference: "Art. 8 DPR 633/72" },
  },
}, { entity_id: entity.id });

Observe the submission outcome

Read status from the invoice or credit-note response. Do not send root sdi in a create request, and do not create a replacement invoice merely because transmission is still pending or submitted.

Read persisted SdI statustypescript
const current = await sdk.invoices.get(invoice.id, undefined, { entity_id: entity.id });
console.log(current.sdi?.status);
console.log(current.sdi?.latest_submission_id);
// Creation or a __PROTECTED_0__ status does not establish SdI acceptance.
// Read the persisted receipt outcome before treating transmission as complete.

SdI lifecycle

When a final Italian invoice or credit note is eligible for FatturaPA, the API creates a pending SdI submission and mirrors the current state on the document root sdi property. Transmission is asynchronous: document creation does not depend on the live SdI SOAP endpoint being available.

Generated XML is checked for structure, supported fiscal inputs, line arithmetic and VAT reconciliation at issuance and before dispatch. Private FPR12 invoices use the officially permitted unsigned XML path; the channel certificate authenticates transport and is not treated as a qualified signing certificate. The sender sends a WSDL-aligned fileSdIAccoglienza SOAP body to RiceviFile with SOAPAction http://www.fatturapa.it/SdIRiceviFile/RiceviFile.

SdI answers a successful submission as a SOAP-with-Attachments (MTOM/XOP) reply: multipart/related, with the SOAP envelope carried as the XOP root part. The API extracts that root part and validates the resulting rispostaSdIRiceviFile envelope; a reply that cannot be decoded as valid multipart or valid XML is treated as unparseable either way, so the transmission’s outcome stays ambiguous rather than being assumed successful.

The response sdi object is server-managed and can include:

  • status, such as pending, submitted, accepted, delivered, rejected, or non_deliverable.
  • latest_submission_id, file_name, and sdi_identifier.
  • Safe timestamps and rejection details.
  • Redacted diagnostics such as endpoint and certificate fingerprint/expiry.

Full XML payloads, private keys, mounted certificate contents, and raw callback bodies are never exposed through document responses.

Dispatch and recovery

Ordinary sandbox invoices remain pending and do not contact SdI. Production dispatch is also disabled until the operator sets ITALY_SDI_DISPATCH_ENABLED=true. Real accredited test delivery has its own opt-in, ITALY_SDI_TEST_DISPATCH_ENABLED=true, and dedicated FATTURA_PA_TEST_* credentials. Neither switch enables the other environment.

Before dispatch, configure the actual accredited transmitter using FATTURA_PA_TRANSMITTER_COUNTRY_CODE and FATTURA_PA_TRANSMITTER_TAX_CODE (or their FATTURA_PA_TEST_ counterparts). For an Italian transmitter, the latter is its codice fiscale. The seller VAT number is a separate fact and is never used as an inferred transmitter fiscal code. Pending private outbox XML has an unconfigured transport envelope; dispatch binds the configured identity and a reserved progressive before contacting SdI. The fiscal invoice snapshot stays unchanged.

Filenames and ProgressivoInvio share a durable counter for the actual transmitter, across sellers and environments. When moving an existing accredited channel to this sender, set FATTURA_PA_FILE_SEQUENCE_START above the verified last-used base-36 filename sequence. The counter can only advance; deleting a seller or submission does not reset it.

Local configuration or signing failures retry at most eight times, with backoff from five minutes to one hour. Near-expiry certificates remain usable and produce sdi.diagnostics.certificate_warnings; expired certificates block dispatch. The API exposes preparation_retry_count and next_retry_at.

A RiceviFile transport failure is classified before it is recorded, based on the actual TCP/TLS request lifecycle rather than message matching:

  • An explicit SdI <Errore> reply (codes EI01-EI04) or a SOAP Fault is a definitive answer: SdI did not accept the file. EI02 (“service unavailable”) retries through the same eight-attempt backoff as local preparation failures, sharing its counter and cap. EI01, EI03, EI04, and a SOAP fault fail immediately with the SdI code preserved in sdi.last_error; these need a human fix to the file, credentials, or configuration, not a retry loop.
  • A connection failure before the request was delivered — DNS resolution, a refused connection, or a TLS handshake failure — also retries through that same mechanism, except an actual certificate problem (expired or untrusted client certificate), which fails immediately with a configuration error instead of retrying blindly.
  • Anything else — a timeout or reset after the request was already fully written, or a reply that cannot be parsed — stays ambiguous: it may have reached SdI. That submission sets requires_reconciliation=true and is never automatically resent, exactly as before.

A submission that ends retry_scheduled or failed from one of the non-ambiguous outcomes above is not treated as an outstanding dispatch: it is voidable/correctable the same way as any other undispatched or rejected submission (see below). Only the genuinely ambiguous case requires operator reconciliation.

Private operator commands run with the intended deployment’s database configuration. They do not transmit invoices:

Terminal window
cd apps/api
bun run src/modules/e-invoicing/fatturapa/sdi-operator.ts inspect ENTITY_ID SUBMISSION_ID
bun run src/modules/e-invoicing/fatturapa/sdi-operator.ts requeue-preparation ENTITY_ID SUBMISSION_ID "Channel configuration repaired and verified"
bun run src/modules/e-invoicing/fatturapa/sdi-operator.ts apply-verified-receipt ENTITY_ID SUBMISSION_ID /secure/receipt.xml "Receipt verified against the accredited channel"

Requeue only applies to unlocked, undispatched configuration/signing failures after repair. It preserves operation identity and records the reason. For uncertain delivery, obtain the authoritative receipt through the accredited channel, verify it, and apply it using the existing identifier-matched receipt path. Arbitrary status changes and resending uncertain or rejected bytes are unsupported.

An issued historical invoice without an SdI submission record requires evidence recovery before voiding. An operator must obtain the original unsigned invoice XML and terminal receipt from the verified archive, and confirm that no correction already exists outside Space Invoices:

Terminal window
cd apps/api
bun run src/modules/e-invoicing/fatturapa/sdi-operator.ts import-verified-history ENTITY_ID INVOICE_ID /secure/original.xml /secure/receipt.xml --confirm-no-external-correction "Verified channel archive and checked for prior external corrections"

This private command records evidence without transmitting or changing the stored invoice. It checks the parties, routing, lines and totals against the invoice, matches receipt identifiers and any supplied hash, rejects existing credit relations, and reserves the historical filename sequence. Repeating the same import is safe; conflicting evidence is rejected. Archive provenance and receipt authenticity must be verified by the operator; the command does not authenticate signatures.

Recovery supports the current MVP XML structure with a five-character base-36 filename sequence and a rejected, delivered, or non_deliverable receipt. Signed invoice XML, extra payment structures, unsupported historical profiles, and missing receipts require manual reconciliation. Each XML file must be at most 1 MiB. Missing history never means that an invoice was not transmitted. After recovery, the ordinary void rules below apply: a rejection permits local closure, while delivery evidence requires the supported linked credit-note flow.

Voiding an undispatched or rejected invoice closes it locally without sending a credit note; rejection evidence is retained. Issue a new corrected invoice after a rejection. Accepted, delivered or non-deliverable invoices are offset by a linked TD04 credit note, with the original invoice number and date in DatiFattureCollegate. An outstanding or ambiguous transmission must be reconciled before voiding. Credit creation, its outbox, settlement and the original invoice void commit together. If an integration refund fails after that commit while completing downstream lifecycle effects, its persisted credit remains available; the existing order recovery policy requires manual reconciliation when the successful-void checkpoint is missing. Do not recreate the credit or infer completion from voided_at alone.

Voiding an Italian credit note follows the same SdI outcome check as invoices, applied to the credit note’s own submission. An undispatched or rejected credit note voids locally, with rejection evidence retained. An accepted, delivered or non-deliverable credit note cannot be voided: this MVP has no automated reversal for a credit note SdI already accepted, so issue a new invoice instead. An issued Italian credit note without an SdI submission record also cannot be voided; the verified history import described above supports invoices only. An outstanding or ambiguous transmission must be reconciled before voiding. This also applies to the system-created TD04 credit note from an invoice void: once SdI accepts that TD04, it is locked, and the original invoice it offset stays voided.

Italy calculates VAT on each rate’s combined taxable base and distributes VAT cents across the stored lines. For gross-price entry, a small difference is disclosed as document payable rounding, preserving the entered gross total; the normal EUR 1 rounding bound still applies. This does not loosen the SdI VAT arithmetic check.

Transmission credentials

SdI Web Service transmission uses accredited channel credentials mounted as deployment secrets. The API reads file paths from:

  • FATTURA_PA_CREDENTIAL_BUNDLE_DIR, defaulting to /var/run/secrets/space-invoices/italy-sdi

The mounted directory must contain these fixed filenames:

  • ca.crt
  • client.crt
  • client.key
  • server.crt
  • server.key

Certificates in the bundle must be PEM-encoded. If the accreditation portal downloads a DER .cer file, convert it before mounting:

Terminal window
openssl x509 -in CER_CLIENT_SI42531357.cer -out CER_CLIENT_SI42531357.pem
openssl x509 -in CER_SERVER_SI42531357.cer -out CER_SERVER_SI42531357.pem

client.crt and client.key authenticate the outgoing SOAP connection. server.crt and server.key serve the callback edge. Keep private keys in deployment secrets only; no XML signing certificate is required.

The API validates that files exist, are readable, contain parseable certificates/keys, are not expired, and that the private key matches the configured certificate material. TLS trust is configured on the SdI client; process-wide TLS verification is not disabled.

Run redacted production diagnostics after mounting the secret and before enabling transmission:

Terminal window
cd apps/api
bun run scripts/sdi-cert-diagnostics.ts

To verify DNS, TCP, TLS trust, and mTLS client-certificate wiring without submitting a FatturaPA file, run a connection probe:

Terminal window
cd apps/api
bun run scripts/sdi-probe-connection.ts -- --production

The probe sends a non-SOAP GET ?wsdl request to the SdI endpoint. Any HTTP response means the mutual TLS connection reached SdI; it does not prove that a RiceviFile invoice submission would be accepted.

To prepare a Kubernetes secret manifest without applying it, use:

Terminal window
cd apps/api
bun run scripts/sdi-k8s-secret-command.ts -- \
--ca /path/ca.crt \
--client-cert /path/CER_CLIENT_SI42531357.pem \
--client-key /path/client.key \
--server-cert /path/CER_SERVER_SI42531357.pem \
--server-key /path/server.key

The helper prints a kubectl create secret --dry-run=client -o yaml command for review or sealing; it does not deploy anything. The same script has dedicated TEST (--test-bundle / --callback-token-file) and PRODUCTION edge (--prod-edge-bundle / --callback-token-file) modes for the callback edge and outbound TEST dispatch secrets — see “Deployment prerequisites (operator)” and “Production go-live” under SdI callbacks below.

Official portal testing

Use the Sistema di Accreditamento TEST transmission environment. The portal’s Flows (Flussi) shows submitted files and their receipts; Progress (Avanzamento) records interoperability outcomes. Simulator sending tests invoice reception by your channel. It does not submit an outgoing invoice from Space Invoices. Incoming invoice reception and public-administration outcomes are outside this FPR12 implementation.

The official SDICoop test plan defines these transmission cases:

CaseInvoice inputExpected persisted receipt
WST01Otherwise valid FPR12 invoice dated seven days in the futurerejected, with a scarto receipt
WST02Valid invoice addressed to a fictitious recipient code shown in the portaldelivered
WST03A separate valid invoice addressed to that codedelivered; the additional B2G outcome distinction does not apply to FPR12
WST04Valid invoice addressed to XS00001, the nonexistent B2B/B2C channelnon_deliverable

WST01 intentionally exercises an SdI control. It does not disable normal API or XML validation. Use Italian seller and buyer identities accepted for your test channel. Syntactically plausible fixture VAT numbers do not establish eligibility or guarantee acceptance.

Prepare the credentials offline

The subscriber client and server certificates need their original matching private keys. A CSR or an issued certificate cannot recreate a private key. Confirm that the selected credentials are authorized for TEST; issuer names alone do not establish this.

The downloaded kit has two distinct trust directions: the outbound testservizi.fatturapa.it server chain and the inbound SdI TEST client chain. Keep them separate. This command accepts PEM, DER and bare base64-DER certificates, checks validity and key matches, and creates a new owner-only directory. It does not contact SdI or change deployment configuration:

Terminal window
bun run apps/api/scripts/sdi-prepare-test-credentials.ts \
--output /secure/italy-sdi-test \
--outbound-ca /verified-kit/caentrate.cer \
--outbound-server-cert /verified-kit/testservizi.fatturapa.it.cer \
--client-cert /verified-channel/client.cer \
--client-key /private-keys/client.key \
--server-cert /verified-channel/server.cer \
--server-key /private-keys/server.key \
--callback-ca /verified-kit/CAEntratetest.cer \
--callback-peer-cert /verified-kit/SistemaInterscambioFatturaPATest.cer \
--transmitter-country-code IT \
--transmitter-tax-code YOUR_AUTHORIZED_TRANSMITTER_ID

The output contains outbound-ca.crt, client.crt, client.key, server.crt, server.key, callback-ca.crt, callback-peer.crt, allowed-peers.conf and test.env. Its server/callback path variables are preparation handoff labels; mount the corresponding files through the deployment secrets. The sender consumes the three FATTURA_PA_TEST_* outbound credential paths. The private FPR12 invoice remains unsigned; channel TLS certificates are not qualified XML signing certificates.

After deploying the reviewed TEST receiver, register its stable HTTPS address ending in /v1/italy/sdi/webhooks under Manage channel → WS Operation → Manage ENDPOINTS → TEST Transmission. Verify the selected environment before saving. Confirm hostname/IP compatibility with the issued server certificate. The existing production endpoint is a separate configuration. A fictitious recipient code pointing at an old reception endpoint may need the authority’s test routing configuration checked before WST02/03 can deliver.

Run local diagnostics, then the explicit TEST TLS probe when network testing is authorized:

Terminal window
cd apps/api
bun run scripts/sdi-test-cert-diagnostics.ts
bun run scripts/sdi-probe-connection.ts -- --test

A successful TLS probe proves connectivity, not invoice acceptance or callback delivery.

Run cases with retained evidence

Use a dedicated persistent TEST database shared by the test API, callback receiver and callback worker. Apply the normal migrations before starting the receiver. Keep automatic test dispatch disabled while preparing individual cases. In the sandbox UI or API, create four distinct invoices with the inputs above, ordinary VAT, complete Italian addresses and explicit RF01. Record each invoice’s sdi.latest_submission_id. Use a new invoice number for each case.

Configure FATTURA_PA_TEST_SELLER_TAX_NUMBER, FATTURA_PA_TEST_BUYER_TAX_NUMBER, FATTURA_PA_TEST_RECIPIENT_CODE, FATTURA_PA_TEST_TRANSMITTER_COUNTRY_CODE and FATTURA_PA_TEST_TRANSMITTER_TAX_CODE, together with the dedicated outbound credential paths. The endpoint defaults to https://testservizi.fatturapa.it/ricevi_file; production targets are rejected.

The private operator command checks entity ownership, the TEST submission environment, parties, routing, invoice date and normal XML preflight before one explicit send. It reuses the normal durable sender and never truncates the database:

Terminal window
cd apps/api
bun run src/modules/e-invoicing/fatturapa/sdi-test-operator.ts inspect ENTITY_ID SUBMISSION_ID WST02
TEST_FATTURA_PA_LIVE=true bun run src/modules/e-invoicing/fatturapa/sdi-test-operator.ts send ENTITY_ID SUBMISSION_ID WST02 --confirm-test-transmission

Repeat with the appropriate case and its distinct submission. The send command opts in only the selected TEST submission; it does not enable the scheduled dispatcher. Allow the callback worker to run. For a dedicated test deployment without the regular cron, process one bounded batch explicitly:

Terminal window
bun run src/modules/e-invoicing/fatturapa/sdi-test-operator.ts process-test-callbacks
bun run src/modules/e-invoicing/fatturapa/sdi-test-operator.ts inspect ENTITY_ID SUBMISSION_ID WST02

Compare the actual transmitted file_name and sdi_identifier from this report with portal Flows, then check Progress. The prepared filename may change when the sender binds the accredited transmitter. A case passes only when the expected authority receipt is persisted and the portal confirms the corresponding outcome. A SOAP upload acknowledgement alone is insufficient. Preserve the case-to-invoice mapping, redacted operator reports and portal results. Reconcile ambiguous sends in Flows before any further action; do not resend automatically.

Optional disposable automated live suite

sdi-live-e2e.integration.test.ts exercises the same four cases with an explicit live opt-in. It uses the repository’s disposable Vitest worker database, which is recreated by the test runner. The authenticated callback receiver must target that exact worker database during the run. Never aim this runner at the persistent portal-testing database or use it to retain accreditation evidence.

Terminal window
TEST_FATTURA_PA_LIVE=true bun run test:api -- src/modules/e-invoicing/fatturapa/sdi-live-e2e.integration.test.ts

The suite is skipped by default. Missing dedicated credentials or identities fail an enabled run. FATTURA_PA_TEST_RECEIPT_TIMEOUT defaults to 600000 ms and is capped at 3600000 ms. The persistent operator workflow above is preferable when a session may stop while waiting for SdI.

SdI callbacks

There are two fully isolated pairs, both in namespace spaceinvoices: italy-sdi-test-callback (edge) + italy-sdi-test-receiver (TEST), and italy-sdi-callback (edge) + italy-sdi-receiver (production). Neither pair has a dedicated database — both receivers read DATABASE_URL from the same api-runtime-database secret the api Deployment uses. TEST vs production is separated entirely by the trusted ITALY_SDI_CALLBACK_ENVIRONMENT filter, sandbox-only entity data, and environment-scoped secrets/tokens — never by a separate database, storage, secret names or backups.

The real, CI-applied manifests are k8s/api/italy-sdi-test-callback-edge.yaml + k8s/api/italy-sdi-test-receiver.yaml (TEST) and k8s/api/italy-sdi-callback-edge.yaml + k8s/api/italy-sdi-receiver.yaml (production) — see “Production go-live” below for production specifics. .github/workflows/deploy-hke.yaml applies each environment’s pair, and the four sealed secrets it depends on, only once all four of that environment’s sealed-secret files are committed to this repo (checked at deploy time with a plain file-existence test, every run — not inferred from what changed in a given push). Until then, merging changes to these manifests has no effect on the cluster: no Deployment, Service/LoadBalancer, NetworkPolicy or PDB is created for that environment. The apply itself runs in its own step at the very end of the deploy job, isolated with continue-on-error: true, so a stuck or failing SdI rollout can never fail or delay the api/renderer/cron/ops deploys, which have already finished by the time it runs.

SdI TEST/PRODUCTION → mTLS edge → its own receiver → durable callback inbox
↓
callback worker → invoice lifecycle

Each edge verifies the inbound client certificate chain against its own environment’s callback CA and an explicit peer-leaf allowlist, then forwards only POST /v1/italy/sdi/webhooks to its own receiver, injecting the internal token header. CA trust alone is insufficient because the authority can issue other subscribers’ certificates. Pin entries use "SUCCESS:<SHA1 fingerprint without colons>" 1;. Rotate only with verified replacement peer material. Each receiver’s NetworkPolicy admits only its own edge’s pods; each edge exposes its public LoadBalancer 443 listener separately from its private health port.

The TEST receiver requires ITALY_SDI_CALLBACK_ENVIRONMENT=test and ITALY_SDI_TEST_CALLBACK_TOKEN; the production receiver requires ITALY_SDI_CALLBACK_ENVIRONMENT=production and ITALY_SDI_CALLBACK_TOKEN. Each receiver reads only its own environment’s token — never the other’s. Missing configuration fails closed with 503; tokens never fall back between environments. Each edge overwrites X-Space-Invoices-Sdi-Callback-Token with its own internal token. Use a random high-entropy hexadecimal token suitable for nginx interpolation, distinct per environment.

Deployment prerequisites (operator)

Sealing the four TEST secrets and registering the endpoint in the accreditation portal remain explicit operator actions; applying the manifests themselves is CI’s job, gated on all four secrets being committed:

  1. Create and seal the TEST secrets. Prepare the credential bundle with sdi-prepare-test-credentials.ts (above), then generate the reviewable kubectl create secret --dry-run=client commands for exactly the four TEST secrets — italy-sdi-test-server-credentials, italy-sdi-test-callback-trust, italy-sdi-test-callback-token (edge/receiver) and italy-sdi-test-credentials (the outbound TEST dispatch bundle mounted on the api Deployment) — with:

    Terminal window
    cd apps/api
    bun run scripts/sdi-k8s-secret-command.ts \
    --test-bundle /secure/italy-sdi-test \
    --callback-token-file /secure/italy-sdi-test-callback-token

    This prints no certificate, key or token content — only --from-file paths. Pipe each printed command through kubeseal --format yaml and commit the result under the FINAL name (a starting k8s/api/italy-sdi-test-*.sealed-secret.example.yaml placeholder for each already exists as a reference for the expected keys — commit the real sealed file under the same name minus the .example suffix). Do not kubectl apply these yourself; CI does, the next time deploy-hke.yaml runs after all four are committed.

  2. Reserve a stable TEST IP. hcloud’s cloud-controller-manager assigns the si-test-italy-sdi LoadBalancer its own stable IP automatically once CI applies the Service — no operator action needed beyond waiting for the first successful apply.

  3. Register the endpoint in the portal. Once CI has applied the TEST pair (confirm with kubectl get pods -n spaceinvoices -l app=italy-sdi-test-callback or app=italy-sdi-test-receiver), follow the existing “official portal testing” registration steps above, pointing at the new stable TEST address.

No step here claims live SdI TEST acceptance; it only brings the infrastructure up so an authorized operator can proceed.

The accredited endpoint accepts SOAP 1.1 text/xml requests with the WSDL’s operation namespace and, when present, a matching SOAPAction; a missing or empty SOAPAction is tolerated (authentication is provided by edge mTLS pinning plus the environment callback token), but a present SOAPAction that conflicts with the body operation is rejected. Real SdI callbacks may carry a vendor SOAP Header; arbitrary header entries are accepted, but an entry marked mustUnderstand="1" that the receiver does not understand is rejected with a soap:MustUnderstand fault. The base64 File contains the receipt XML. Raw receipts remain available through the private verified-receipt operator workflow. Actual envelope bytes are limited to 5,000,000 and uploads have a 15-second deadline.

Callback wrapperLifecycle status
notificaScartorejected
ricevutaConsegnadelivered
notificaMancataConsegnanon_deliverable
attestazioneTrasmissioneFatturanon_deliverable
notificaEsitoaccepted
notificaDecorrenzaTerminiaccepted

All six WSDL operations are one-way: successful durable capture returns HTTP 202 with an empty body. Duplicates are identified by environment and payload hash. Invalid tokens return 401; malformed SOAP produces a SOAP fault; storage failure returns 503 so SdI can retry. The receiver does not acknowledge an unstored receipt.

italy-sdi-callbacks runs every minute through the authenticated cron endpoint. It claims up to 25 receipts by default, one at a time, with a two-minute recovery lease and a cooperative 50-second batch budget. Receipt application uses the existing transactional lifecycle owner. Test receipts match only test submissions. Duplicate processing cannot repeat lifecycle activities or regress terminal outcomes.

Unmatched and transiently failing receipts receive bounded backoff and up to eight processing attempts, then remain quarantined for investigation. The cron heartbeat fails on processing errors, quarantined receipts or unresolved receipts older than 15 minutes. Monitor that heartbeat independently from receiver liveness/readiness. Processed raw payloads are scrubbed after 30 days in bounded batches; receipt identities, hashes and outcomes remain. Unresolved raw payloads remain available for investigation.

Inspect or replay a retained receipt after repairing the cause:

Terminal window
cd apps/api
bun run src/modules/e-invoicing/fatturapa/sdi-operator.ts inspect-callback test CALLBACK_ID
bun run src/modules/e-invoicing/fatturapa/sdi-operator.ts requeue-callback test CALLBACK_ID "Verified missing submission restored from channel evidence"

Replay records the operator reason and preserves the original payload, hash and environment. It cannot requeue a processed receipt or take over an active lease. Raw XML and secrets are excluded from receipt logs and operator reports.

Production go-live

The real, CI-applied PRODUCTION manifests are k8s/api/italy-sdi-callback-edge.yaml (mTLS edge, dedicated Hetzner LoadBalancer si-production-italy-sdi) and k8s/api/italy-sdi-receiver.yaml (2-replica receiver sharing the production api image and database). Neither TEST nor production has a dedicated database — both receivers read DATABASE_URL from the same api-runtime-database secret the api Deployment uses. Outbound dispatch credentials are mounted read-only on the api Deployment itself: the production bundle from italy-sdi-credentials at FATTURA_PA_CREDENTIAL_BUNDLE_DIR (/var/run/secrets/space-invoices/italy-sdi), and the TEST outbound bundle from italy-sdi-test-credentials at /var/run/secrets/space-invoices/italy-sdi-test. Both volumes are optional: true — the manifest wiring and the SealedSecret each references can land in separate PRs/deploys without one stalling the api rollout waiting on the other, and a missing or incomplete bundle is a reported-invalid configuration (errorCode: "SDI_CONFIG", submission retried/failed) rather than a boot crash: see validateSdiCertificateConfig in apps/api/src/modules/e-invoicing/fatturapa/sdi.config.ts (missing files are pushed onto errors, never thrown), and the isSdiDispatchEnabled gate in apps/api/src/modules/e-invoicing/fatturapa/sdi-dispatch-policy.ts, which the submission service checks before any credential path is read — dispatch stays off until ITALY_SDI_DISPATCH_ENABLED / ITALY_SDI_TEST_DISPATCH_ENABLED is "true".

None of the eight Italy SdI SealedSecrets (four production, four TEST) are committed to this repo yet. Only clearly-named, non-functional placeholders are (k8s/api/italy-sdi-*.sealed-secret.example.yaml) — their encryptedData is the literal string REPLACE_WITH_KUBESEAL_OUTPUT, never real ciphertext. .github/workflows/deploy-hke.yaml looks for each environment’s four secrets under their FINAL names (no .example suffix, e.g. k8s/api/italy-sdi-server-credentials.sealed-secret.yaml) and only applies anything for that environment — the four secrets, the receiver, and the edge — once all four of that environment’s files exist; a partial set (e.g. only the outbound bundle) leaves that whole environment untouched. This apply runs in its own step at the very end of the deploy job, isolated with continue-on-error: true, so it can never fail or delay the api/renderer/cron/ops deploys.

Bringing this live is staged and explicit:

  1. DNS. Point sdi.spaceinvoices.com at the italy-sdi-callback Service’s LoadBalancer IP (load-balancer.hetzner.cloud/name: si-production-italy-sdi) with a Cloudflare DNS-only record — never proxied, since the edge terminates its own mTLS on 8443 and Cloudflare’s TLS/HTTP proxying would break both the client-certificate handshake and the fixed peer pin.

  2. Portal registration. Set the accreditation portal’s production Transmission endpoint to https://sdi.spaceinvoices.com/v1/italy/sdi/webhooks.

  3. Seal and commit all four production secrets together. italy-sdi-server-credentials, italy-sdi-callback-trust, italy-sdi-callback-token (edge/receiver) and italy-sdi-credentials (outbound, mounted on api) — CI applies nothing production-side until every one of the four is committed, so prepare and commit them in one PR. Generate the reviewable commands with the production edge mode, matching each command’s secret name to its placeholder file above and committing the sealed result under the FINAL name (drop .example):

    Terminal window
    cd apps/api
    bun run scripts/sdi-k8s-secret-command.ts \
    --prod-edge-bundle /secure/italy-sdi-production \
    --callback-token-file /secure/italy-sdi-production-callback-token

    As with TEST mode, this prints only --from-file paths — never certificate, key or token content. Once all four are committed, the next deploy-hke.yaml run applies them, waits for the sealed-secrets controller to report each fully reconciled, then applies the receiver and edge.

    Because the outbound mount is optional, applying the api Deployment before italy-sdi-credentials exists does not fail the rollout — it just means the bundle is silently absent until this step runs. Confirm the mounted bundle is actually present and complete before relying on it:

    Terminal window
    kubectl exec -n spaceinvoices deployment/api -- \
    env FATTURA_PA_CREDENTIAL_BUNDLE_DIR=/var/run/secrets/space-invoices/italy-sdi \
    bun run scripts/sdi-cert-diagnostics.ts

    A valid: false result here (missing/unreadable files, expired certs) is expected and harmless while ITALY_SDI_DISPATCH_ENABLED is still "false" — dispatch never reads these files in that state (see above) — but it must read valid: true before step 5 flips dispatch on.

  4. Verify intake and outbound separately before enabling dispatch. These are two different directions and neither substitutes for the other:

    • Outbound (this codebase calling servizi.fatturapa.it with the subscriber client certificate) is what the read-only production probe checks:

      Terminal window
      cd apps/api
      bun run scripts/sdi-probe-connection.ts -- --production

      A successful run proves the outbound mTLS handshake and trust chain, not that the edge accepts inbound callbacks — it never connects to sdi.spaceinvoices.com.

    • Inbound intake (SdI calling us at the edge) has no equivalent bundled script; verify it externally, the same way as the TEST edge:

      Terminal window
      openssl s_client -connect sdi.spaceinvoices.com:443 -servername sdi.spaceinvoices.com </dev/null

      The handshake must present our subscriber server certificate and then reject the connection once the server requests a client certificate we don’t supply — confirming ssl_verify_client on is enforced, not just configured. We hold only the certificate (not the private key) for the pinned “Sistema Interscambio Fattura PA” leaf — the authority keeps that key — so the accepted-client half of the handshake cannot be self-tested this way; it is confirmed instead by a real or portal-driven test callback being durably captured (see the TEST workflow above for the equivalent pattern). Check kubectl logs deployment/italy-sdi-callback -n spaceinvoices for the JSON access log fields the edge’s nginx config emits per request to confirm the rejected probe above — and any subsequent real callback — is actually recorded, not silently dropped before nginx.

    Only after both directions are verified end-to-end should ITALY_SDI_DISPATCH_ENABLED move from its committed "false" default to "true" in k8s/api/configmap.yaml, and only with explicit operator sign-off — this is a production configuration change, not something CI flips on its own. ITALY_SDI_TEST_DISPATCH_ENABLED follows the equivalent TEST verification, independently.

  5. Confirm the transmitter identity. k8s/api/configmap.yaml sets FATTURA_PA_TRANSMITTER_COUNTRY_CODE=SI / FATTURA_PA_TRANSMITTER_TAX_CODE=42531357 and the equivalent FATTURA_PA_TEST_* pair: the accredited channel owner (Studio 404, VAT SI42531357) transmits on behalf of Italian sellers, so every file carries IdTrasmittente SI/42531357 and filenames start with SI42531357_ — legacy per-seller-VAT filenames are unaffected, since no FATTURA_PA_FILE_SEQUENCE_START floor is configured.

  6. Cron is already wired. italy-sdi-callbacks and italy-sdi-transmit (apps/cron/src/jobs.ts) are already scheduled — no cron change is needed for go-live. The transmit cron route itself (/cron/e-invoicing/italy-sdi/transmit) is served by the main api Deployment, not a separate worker; there is no separate cron/worker mount to wire up.

Monitoring. Watch, at minimum: edge 403/404/5xx rates (peer-pin rejections and malformed requests should be rare and explainable), receiver readiness (a 503 from /health/ready means the receiver cannot reach the database), and the italy-sdi-callbacks cron heartbeat (see “SdI callbacks” above). A stalled heartbeat with a healthy receiver usually means the portal stopped delivering callbacks, not a receiver failure.

Rotation. allowed-peers.conf pins the SHA-1 fingerprint (40 hex characters) of the callback peer leaf, because that is what nginx’s $ssl_client_fingerprint variable — which the edge’s map matches this file against — actually is; SHA-256 (64 hex characters) here would silently never match, rejecting every real SdI callback with 403. Regenerate with formatAllowedPeerLine (apps/api/src/modules/e-invoicing/fatturapa/sdi-test-credentials.ts) rather than hand-formatting a new pin; the --prod-edge-bundle mode of sdi-k8s-secret-command.ts rejects a non-SHA-1 (e.g. 64-hex) fingerprint in allowed-peers.conf before it can be sealed. Track certificate/peer-pin expiry dates against the accreditation portal’s own records rather than a value recorded here, which can go stale.

Rollback. Deleting the edge/receiver only stops capture — the italy-sdi-callbacks cron processor and, once enabled, outbound dispatch are separate and keep running. Pick the scope that matches the actual problem:

  • Stop outbound (a bad transmission, wrong transmitter identity, or outbound credential issue): set ITALY_SDI_DISPATCH_ENABLED="false" (or ITALY_SDI_TEST_DISPATCH_ENABLED) in k8s/api/configmap.yaml and roll the api Deployment (kubectl rollout restart deployment/api -n spaceinvoices). This is the fastest and least disruptive rollback — intake, capture, and receipt processing are unaffected, and no LoadBalancer or DNS state changes.
  • Stop intake (the edge or receiver itself is misbehaving — e.g. rejecting valid peers, or the receiver can’t reach the database): scale or delete the two Deployments for the affected environment (kubectl delete deployment italy-sdi-callback italy-sdi-receiver -n spaceinvoices for production, or the italy-sdi-test-* pair for TEST; kubectl scale --replicas=0 also works). Keep the callback edge’s Service (LoadBalancer) in place — deleting it releases the Hetzner LB and its IP, which would break the DNS record and portal registration on the next go-live attempt. SdI retries undelivered callbacks for a bounded time on its side; once the edge is back, italy-sdi-callbacks keeps draining the durable inbox as normal, and unmatched or expired receipts fall into the existing quarantine/recovery workflow (see “SdI callbacks” above) rather than being lost. Re-apply the Deployments to resume; the Service and its stable IP are unaffected by the whole cycle.
  • The callbacks cron itself does not need to be paused in either case — it is harmless to keep running against an empty or temporarily stalled inbox.

Validation errors

Italy-specific validation errors are returned as 422 responses with messages scoped to FatturaPA. Common failures include:

  • Missing Italian entity VAT number.
  • Missing customer details or complete legal addresses.
  • Missing explicit RF01/RF19 seller configuration or unsupported tax treatment.
  • Domestic B2C without buyer fiscal code.
  • Foreign B2B without a buyer VAT/tax ID.
  • Malformed 7-character SdI recipient code.

Supported scope

The implementation supports EUR-denominated private FPR12 invoices and credit notes under explicitly configured RF01 (ordinary regime) or RF19 (flat-rate regime, regime forfettario), with ordinary VAT and reconciled line discounts. It preserves stored VAT summaries and emits unit prices with up to eight decimal places.

Foreign zero-VAT lines require items[].e_invoicing.italy with an explicit supported nature (N2.1, N3.1 or N3.2) and a legal_reference of up to 100 characters. Choose the treatment only when legally applicable. This applies to RF19 sellers too when the buyer is foreign — a flat-rate seller invoicing abroad is not exempt from choosing the actual treatment. A domestic RF19 zero-VAT line instead defaults automatically to N2.2, with an editable statutory legal reference; see RF19 flat-rate regime. Special fiscal regimes other than RF01/RF19, domestic zero-VAT treatments outside a domestic RF19 line’s automatic N2.2, N2.2 for a foreign buyer, multiple taxes on one line and other unsupported treatments fail with 422; the API does not silently choose a fiscal regime, and it only assigns a zero-VAT nature automatically for an untreated domestic RF19 line. Drafts may retain incomplete inputs.

Withholding, pension contributions, other special VAT procedures, public-administration invoices and fiscal currency conversion are unsupported. Do not use this path for operations requiring those fields. Stamp duty (imposta di bollo) is implemented: the generated XML declares DatiBollo (BolloVirtuale: SI, ImportoBollo: 2.00) once the combined net total of a document’s N2.1 and N2.2 lines exceeds EUR 77.47; N3.x lines are exempt from that threshold. The declared EUR 2 is not added to the invoice’s payable total — the seller remains responsible for paying it separately. The threshold follows the official stamp-duty guide.

These checks do not establish live SdI acceptance or cover every FatturaPA control. The implementation follows the private-invoice unsigned option and arithmetic controls in the official technical specifications version 1.9.1.

This guide covers FatturaPA/SdI invoice data for Italian entities. POS/RT linkage, PEM/PEL fiscal receipts, and OSS/IOSS return filing are separate compliance surfaces.