We build invoice automation for businesses that bill in more than one country, and the two regimes we are asked about most are India's GST e-invoicing and Saudi Arabia's ZATCA e-invoicing in its second, integration phase. On a slide they look alike. In a pipeline they behave differently enough that treating them as one integration with two endpoints causes real problems.
The shape of each flow
Under GST e-invoicing, the supplier's system creates the invoice and submits it as JSON in the notified schema to an Invoice Registration Portal (IRP). The IRP validates it, generates an Invoice Reference Number (IRN) and returns the invoice digitally signed, together with a signed QR code. The IRN is a hash derived from the supplier's GSTIN, the financial year, the document type and the document number. E-invoicing applies above an aggregate turnover threshold that has been lowered several times since it was introduced in 2020.
Under ZATCA Phase 2, invoices are UBL 2.1 XML, and the supplier's own invoicing solution does more of the work. Each invoice is hashed and cryptographically stamped on the supplier's side, carries the hash of the previous invoice, a counter and a UUID, and includes a QR code in a tag-length-value format. What happens next depends on the invoice type. Standard tax invoices, broadly business to business, must be cleared: sent to ZATCA's platform and validated before they are shared with the buyer. Simplified tax invoices, broadly business to consumer, go to the buyer at the point of sale and must be reported to ZATCA within 24 hours.
So the first difference is ordering. In India the government platform issues the identifier. In Saudi Arabia the supplier's solution creates and stamps the invoice, and the platform then clears or receives it.
Checks that both need
Some validation is common to both and should run before anything is submitted, because a rejection from a government platform is slower and noisier than a rejection from your own code.
- Identity: a GSTIN is 15 characters, starts with a two-digit state code, contains the holder's PAN and ends in a check character. A Saudi VAT registration number is 15 digits, beginning and ending with 3. Validate the format, and the checksum where there is one, for both supplier and buyer.
- Arithmetic: line amounts, discounts, tax per line and invoice totals must agree. Decide the rounding rule, apply it consistently per line or per invoice, and test the cases where the two approaches give different totals.
- Tax classification: in India, the place of supply decides whether a line carries IGST or CGST plus SGST, and each line needs an HSN or SAC code at the length the supplier's turnover requires. In Saudi Arabia, each line needs a VAT category, and zero-rated, exempt and out-of-scope lines need a reason code.
- Fields for the invoice type: Saudi standard invoices need fuller buyer details than simplified ones; Indian e-invoices need the fields the schema marks mandatory for the supply type, such as B2B or export.
- Dates and windows: invoice dates the regime will not accept, and submission deadlines measured from the invoice date.
Checks specific to GST e-invoicing
- Uniqueness before submission: the IRN is derived from the GSTIN, financial year, document type and number, so a reused document number will be rejected. Check locally first; it is faster and cleaner.
- The cancellation window: an IRN can be cancelled on the IRP only within 24 hours of generation, and the document number cannot then be reused. After that, a correction is a credit or debit note. Build the user flow around that window rather than discovering it in production.
- Reporting time limits: taxpayers above a turnover threshold must report invoices to the IRP within a fixed number of days of the invoice date. Hold both the threshold and the number of days in configuration.
- Keep what comes back: store the signed invoice, the IRN, the acknowledgement number and date and the signed QR code exactly as returned. They are what the printed invoice has to carry.
Checks specific to ZATCA Phase 2
- Onboarding state: each invoicing unit is onboarded with a cryptographic stamp identifier, issued through a compliance step before a production one. The pipeline must know which identifier and private key belong to which unit, and keep the keys out of application logs and source control.
- The hash chain: every invoice carries the hash of the previous one from the same unit, along with an incrementing counter, which makes generation sequential per unit. Two workers stamping invoices for the same unit at once will break the chain, so stamping needs a lock or a single queue per unit.
- Clearance before sharing: a standard invoice must not reach the buyer until clearance has succeeded, and what is shared must be the cleared version the platform returns.
- The 24-hour reporting window: simplified invoices need a durable queue that retries, alerts well before the deadline and survives a restart.
- Rejections and the chain: decide, against ZATCA's current developer guidance, how a rejected invoice affects the counter and the previous-invoice hash, and test that path in the sandbox before go-live.
Retries without duplicates
Both platforms are network calls that can time out after the request has succeeded on their side. A naive retry then submits the same invoice twice. Every submission in our pipelines carries an idempotency key we generate, and a timeout is followed by a status lookup rather than an immediate resubmission. For GST, that means looking the document up by its details; for ZATCA, it means checking what the platform recorded for that invoice before stamping anything new.
async function submit(invoice: Invoice) {
await validateLocally(invoice); // identity, arithmetic, tax, fields
const key = idempotencyKey(invoice);
try {
return await platform.send(invoice, { key });
} catch (err) {
if (!isTimeout(err)) throw err;
const existing = await platform.lookup(invoice);
if (existing) return existing; // it went through after all
return platform.send(invoice, { key }); // nothing was recorded
}
}One model, two adapters
The structure that has held up for us is a single internal invoice model holding the business facts (parties, lines, amounts, tax treatment) and one adapter per regime that maps it to the required format, applies the regime's own rules and talks to its platform. Common validation runs against the internal model. Regime-specific validation lives in the adapter. Raw platform responses are kept for audit.
The government platform is the last validator, not the first. Every rejection it returns is one your own checks could have caught earlier and more cheaply.
What changes most often is not the code but the rules: thresholds, deadlines, schema versions and validation lists. Keeping those in versioned configuration, with the date each version applies from, is what lets the pipeline follow a new notification without a rewrite.

