Developers
The integration surface, published openly.
This product is an API. Send arbitrary invoice JSON from your POS, ERP, or billing system; we normalize it to the PINT-AE shape, validate it against the official MoF mandatory field set, write it to a tenant-isolated ledger, and route it to your accredited ASP. Everything below is the real integration surface — the same endpoints, fields, and codes the service runs today, published in the open rather than behind a sales call.
Authentication
Every request except GET /healthz carries an X-API-Key header:
X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe key identifies your tenant and is checked as a constant-time HMAC-SHA256 digest comparison — the raw key is never stored on our side, only its hash. It is shown once at creation and cannot be retrieved again, only rotated, so treat it like a password from the moment you receive it.
There is no self-serve signup. Keys are provisioned for you, out-of-band, by a tenant administrator during design-partner onboarding — that is the current, honest state of this service, not a temporary rough edge. Request sandbox access through the early-access channel and we'll issue tenant credentials directly.
Integration path — start here
One integration path exists today: the direct REST API below. SDKs aren't published yet, and we don't promise a timeline for them here — build against the raw endpoints, which are stable and fully documented on this page.
Two endpoints share the exact same request body and the exact same normalize-then-validate pipeline:
| Endpoint | Persists? | Use it for |
|---|---|---|
POST /api/v1/compliance/validate | No | Integration development — call it in a loop while you get your field mapping right |
POST /api/v1/compliance/submit | Yes | Production traffic — persists to the ledger and enqueues for ASP transmission |
A first session against this API looks the same for every integrator: import the Postman collection, POST a sample invoice to /validate, read the returned issue list, fix the fields the messages point at, and resubmit. Repeat until the response comes back is_valid: true. Once /validate is clean, switch the same payload to /submit — the checks are identical, so nothing about your payload shape changes between the two.
Working examples
All three examples submit the same invoice: a single AED 1,000.00 line item at 5% standard VAT, net plus VAT reconciling to a gross total of AED 1,050.00. Field names are matched to the normalizer's alias table — you're not restricted to these exact keys, but these are guaranteed to map.
curl -X POST http://localhost:8000/api/v1/compliance/submit \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source_reference": "INV-2026-0001", "payload": { "invoiceNumber": "INV-2026-0001", "issue_date": "2026-07-10", "invoice_type_code": "380", "currency": "AED", "tax_currency": "AED", "specification_identifier": "urn:peppol:pint:billing-1@ae-1", "purchase_order": "PO-2026-4471", "business_process_type": "P1", "transaction_type_code": "00000000", "seller_name": "Acme Trading LLC", "seller_trn": "100000000000003", "supplier_peppol_id": "0235:1000000000", "supplier_scheme": "0235", "supplier_street": "1 Sheikh Zayed Road", "supplier_city": "Dubai", "supplier_emirate": "DXB", "supplier_country": "AE", "supplier_trade_license": "DED-123456", "supplier_legal_registration_identifier_type": "TL", "supplier_tax_scheme": "VAT", "buyer_name": "Beta Retail FZE", "buyer_trn": "100000000100102", "buyer_peppol_id": "0235:1000000001", "buyer_scheme": "0235", "buyer_street": "2 Al Maktoum Road", "buyer_city": "Abu Dhabi", "buyer_emirate": "AUH", "buyer_country": "AE", "buyer_legal_registration_identifier": "DED-654321", "buyer_legal_registration_identifier_type": "TL", "buyer_tax_scheme": "VAT", "payment_method": "30", "terms": "Net 30", "due_date": "2026-08-09", "tax_date": "2026-07-10", "amount_due": "1050.00", "taxable_amount": "1000.00", "vat_breakdown_amount": "50.00", "vat_category_summary": "S", "vat_summary_rate": "5.00", "lines": [ { "line_id": "1", "item_name": "Consulting services", "quantity": "1", "unit_code": "C62", "unit_price": "1000.00", "base_quantity": "1", "gross_price": "1000.00", "net_amount": "1000.00", "vat_category": "S", "vat_rate": "5.00", "vat_amount": "50.00", "gross_amount": "1050.00", "vat_amount_aed": "50.00", "line_amount_aed": "1050.00" } ], "subtotal": "1000.00", "vat_total": "50.00", "grand_total": "1050.00" } }'import httpxAPI_KEY = "ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"BASE_URL = "http://localhost:8000"payload = { "invoiceNumber": "INV-2026-0001", "issue_date": "2026-07-10", "invoice_type_code": "380", "currency": "AED", "tax_currency": "AED", "specification_identifier": "urn:peppol:pint:billing-1@ae-1", "purchase_order": "PO-2026-4471", "business_process_type": "P1", "transaction_type_code": "00000000", "seller_name": "Acme Trading LLC", "seller_trn": "100000000000003", "supplier_peppol_id": "0235:1000000000", "supplier_scheme": "0235", "supplier_street": "1 Sheikh Zayed Road", "supplier_city": "Dubai", "supplier_emirate": "DXB", "supplier_country": "AE", "supplier_trade_license": "DED-123456", "supplier_legal_registration_identifier_type": "TL", "supplier_tax_scheme": "VAT", "buyer_name": "Beta Retail FZE", "buyer_trn": "100000000100102", "buyer_peppol_id": "0235:1000000001", "buyer_scheme": "0235", "buyer_street": "2 Al Maktoum Road", "buyer_city": "Abu Dhabi", "buyer_emirate": "AUH", "buyer_country": "AE", "buyer_legal_registration_identifier": "DED-654321", "buyer_legal_registration_identifier_type": "TL", "buyer_tax_scheme": "VAT", "payment_method": "30", "terms": "Net 30", "due_date": "2026-08-09", "tax_date": "2026-07-10", "amount_due": "1050.00", "taxable_amount": "1000.00", "vat_breakdown_amount": "50.00", "vat_category_summary": "S", "vat_summary_rate": "5.00", "lines": [ { "line_id": "1", "item_name": "Consulting services", "quantity": "1", "unit_code": "C62", "unit_price": "1000.00", "base_quantity": "1", "gross_price": "1000.00", "net_amount": "1000.00", "vat_category": "S", "vat_rate": "5.00", "vat_amount": "50.00", "gross_amount": "1050.00", "vat_amount_aed": "50.00", "line_amount_aed": "1050.00", } ], "subtotal": "1000.00", "vat_total": "50.00", "grand_total": "1050.00",}with httpx.Client(base_url=BASE_URL, headers={"X-API-Key": API_KEY}) as client: response = client.post( "/api/v1/compliance/submit", json={"source_reference": "INV-2026-0001", "payload": payload}, ) response.raise_for_status() result = response.json() print(result["status"], result["ledger_id"], result["duplicate"])const API_KEY = "ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";const BASE_URL = "http://localhost:8000";const payload = { invoiceNumber: "INV-2026-0001", issue_date: "2026-07-10", invoice_type_code: "380", currency: "AED", tax_currency: "AED", specification_identifier: "urn:peppol:pint:billing-1@ae-1", purchase_order: "PO-2026-4471", business_process_type: "P1", transaction_type_code: "00000000", seller_name: "Acme Trading LLC", seller_trn: "100000000000003", supplier_peppol_id: "0235:1000000000", supplier_scheme: "0235", supplier_street: "1 Sheikh Zayed Road", supplier_city: "Dubai", supplier_emirate: "DXB", supplier_country: "AE", supplier_trade_license: "DED-123456", supplier_legal_registration_identifier_type: "TL", supplier_tax_scheme: "VAT", buyer_name: "Beta Retail FZE", buyer_trn: "100000000100102", buyer_peppol_id: "0235:1000000001", buyer_scheme: "0235", buyer_street: "2 Al Maktoum Road", buyer_city: "Abu Dhabi", buyer_emirate: "AUH", buyer_country: "AE", buyer_legal_registration_identifier: "DED-654321", buyer_legal_registration_identifier_type: "TL", buyer_tax_scheme: "VAT", payment_method: "30", terms: "Net 30", due_date: "2026-08-09", tax_date: "2026-07-10", amount_due: "1050.00", taxable_amount: "1000.00", vat_breakdown_amount: "50.00", vat_category_summary: "S", vat_summary_rate: "5.00", lines: [ { line_id: "1", item_name: "Consulting services", quantity: "1", unit_code: "C62", unit_price: "1000.00", base_quantity: "1", gross_price: "1000.00", net_amount: "1000.00", vat_category: "S", vat_rate: "5.00", vat_amount: "50.00", gross_amount: "1050.00", vat_amount_aed: "50.00", line_amount_aed: "1050.00", }, ], subtotal: "1000.00", vat_total: "50.00", grand_total: "1050.00",};const response = await fetch(`${BASE_URL}/api/v1/compliance/submit`, { method: "POST", headers: { "X-API-Key": API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ source_reference: "INV-2026-0001", payload }),});if (!response.ok) { throw new Error(`Submit failed: ${response.status} ${await response.text()}`);}const result = await response.json();console.log(result.status, result.ledger_id, result.duplicate);Known, documented gap: this example passes every identity, math, currency, and VAT check cleanly. It still comes back with MISSING_MANDATORY on a handful of PINT-AE fields — party address lines, the specification identifier, payment terms and due date, the tax point date, and the VAT breakdown fields — because the current alias table has no mapping for those field names yet. That's a gap in the normalizer, not a mistake in this example, and it closes as the alias tables are extended.
Idempotency
/submit is idempotent on (tenant, source_reference). Internally, the idempotency key is a SHA-256 digest of the tenant ID and your source reference, enforced by a unique database index on the pair — proven safe under concurrent load with a dedicated chaos-test harness.
Resubmit an invoice with a source_reference you've already sent, and you get back the existing ledger row with duplicate: true — no new processing happens. Safe to retry on timeouts or redeliver from your own outbox pattern without double-submitting to the FTA.
{ "ledger_id": "b3f1c2a0-...", "status": "TRANSMITTED", "idempotency_key": "e3b0c4...", "duplicate": true, "issues": []}Validation issue-code catalog
Every failed check produces a ValidationIssue: field, code, message, severity. error-severity issues block submission and leave the ledger status at NEEDS_REVIEW; warning-severity issues don't. This is the exhaustive set of codes the validator emits.
MISSING_MANDATORYdocumentA field PINT-AE requires unconditionally — at document or line level — couldn't be mapped from your payload. Check the field name against the normalizer's alias table, or send it directly under its canonical PINT-AE name.
TRN_FORMATidentitysupplier_trn or buyer_trn is neither a 15-digit TRN nor a 10-digit TIN. Send the full 15-digit FTA Tax Registration Number, digits only — or, for a party without VAT registration, their standalone 10-digit TIN.
PEPPOL_ID_FORMATidentityA Peppol endpoint ID isn't scheme-qualified as <scheme>:<identifier>. Format it as 0235:<10-digit TIN>, for example 0235:1000000000.
PEPPOL_SCHEME_INVALIDidentityThe scheme prefix before the colon isn't a recognized UAE Peppol scheme. Use scheme 0235 — the UAE TIN-based scheme, fixed by the MoF spec.
TIN_FORMATidentityThe identifier after the scheme prefix isn't exactly 10 digits. The TIN is the first 10 digits of the party's 15-digit TRN, or its standalone 10-digit TIN when it has no TRN.
TIN_TRN_MISMATCHidentityThe Peppol TIN doesn't match the corresponding TRN field. When that field holds a 15-digit TRN, the TIN must equal its first 10 digits; when it holds a standalone TIN, the Peppol identifier must equal it exactly.
LINE_MATHmathOn a line item, net_amount + vat_amount doesn't equal gross_amount at 2dp, ROUND_HALF_UP. Recompute the line's gross amount or check for a rounding mismatch.
TOTAL_MATHmathA document total doesn't reconcile against the sum of line amounts, or net + vat doesn't equal gross at document level. Recompute the invoice's net, VAT, and gross totals from the line items.
TAX_CURRENCYtax_currency_code is present and isn't AED. UAE VAT amounts must always be expressed in AED, even when the document currency is foreign.
MISSING_CONDITIONALA conditionally-mandatory field is missing given the rest of the document — for example an exchange rate when the document currency isn't AED, or a preceding-invoice reference on a credit or debit note. The message names the exact field and condition.
VAT_CATEGORY_UNKNOWNVATA vat_category_code, at line or document-breakdown level, isn't one of the six recognized codes. Use S, Z, E, O, AE, or N — see the VAT category codelist below. Margin-scheme, designated-zone, and export semantics belong in the transaction-type flags, not the VAT category.
VAT_RATE_MISMATCHVATThe vat_rate on a line, or in the document VAT breakdown, isn't one of the rates allowed for that category. S and N allow only 5.00; Z, E, and O allow only 0.00; AE allows 0.00 or 5.00.
VAT_EXEMPTION_REASON_MISSINGVATThe line's VAT category requires an exemption reason (E or O) and none was supplied. Add a short text reason to the line.
TRANSACTION_TYPE_FORMATThe transaction-type code is present but isn't an 8-character string of 0/1 flags. Send the 8-flag bitmask — Free Trade Zone, Deemed Supply, Margin Scheme, Summary Invoice, Continuous Supply, Disclosed Agent Billing, Supply through e-commerce, Exports — for example 00000000 when none apply.
LEGAL_REG_TYPE_INVALIDidentityA party's legal-registration-identifier type isn't one of the recognized types. Use TL (Trade License), EID (Emirates ID), PAS (Passport), or CD (Commercial Registration/other).
VAT category codelist
The official Peppol PINT-AE tax-category codelist has exactly six codes — the letter code is canonical:
| Code | Label | Allowed rate(s) |
|---|---|---|
S | Standard rate | 5.00 |
Z | Zero rated | 0.00 |
E | Exempt from tax | 0.00 (requires an exemption reason) |
O | Outside scope of tax / not subject to tax | 0.00 (requires an exemption reason) |
AE | VAT reverse charge | 0.00 or 5.00 |
N | Standard rate, additional VAT base | 5.00 |
For backward compatibility, the legacy provisional aliases AE-VAT-1 through AE-VAT-4 and AE-VAT-6 are still accepted as input and map to S, Z, E, O, and AE respectively. The other provisional codes that used to exist were never part of the official spec and now fail as VAT_CATEGORY_UNKNOWN — the concepts they stood in for belong in the transaction-type flags, not the VAT category.
Ledger status reference
status on a /submit response, and on the persisted ledger row, is always one of eight values:
Initial state before validation completes — transient.
Passed validation — transient, used internally between validate and enqueue.
Validated and persisted; queued for the worker to submit to your ASP.
The worker handed the invoice to the ASP; awaiting Peppol/FTA clearance.
Terminal success — the FTA cleared the invoice via the Peppol network.
Terminal failure — the ASP or FTA rejected the invoice after transmission.
Terminal failure — a permanent error occurred during ASP transmission, such as a 4xx from the ASP.
Failed pre-submission validation; never transmitted. Check the issues array in the response, or failure_detail on the ledger row.
An invoice is never silently dropped: every dead end — FAILED, NEEDS_REVIEW, REJECTED — is a persisted status plus a failure webhook to your registered endpoint.
Webhook signature verification
When an invoice lands in NEEDS_REVIEW or FAILED, this service POSTs a signed webhook to your tenant's registered failure-webhook URL, carrying three headers:
X-Signature— hex HMAC-SHA256 digestX-Signature-Timestamp— unix timestamp the signature was generated atX-Signature-Nonce— random per-delivery nonce
The signed message is the concatenation:
"{timestamp}.{nonce}.".encode() + body_byteskeyed with your tenant's webhook secret. Verify with a constant-time comparison, reject stale timestamps (the default freshness window is 300 seconds), and track seen nonces — for example in Redis with a TTL slightly longer than the freshness window — to reject replays:
import hashlibimport hmacimport timeMAX_AGE_SECONDS = 300def verify_webhook(secret: bytes, body: bytes, signature: str, timestamp: str, nonce: str) -> bool: try: age = abs(time.time() - int(timestamp)) except (TypeError, ValueError): return False if age > MAX_AGE_SECONDS: return False message = f"{timestamp}.{nonce}.".encode() + body expected = hmac.new(secret, message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)# In your webhook handler:# body = raw request bytes (do not use a re-serialized/parsed copy)# ok = verify_webhook(# secret=YOUR_WEBHOOK_SECRET,# body=body,# signature=request.headers["X-Signature"],# timestamp=request.headers["X-Signature-Timestamp"],# nonce=request.headers["X-Signature-Nonce"],# )The inbound ASP-callback endpoint — the one your chosen ASP calls back on clearance or rejection — is verified with this exact same scheme today. None of the supported ASPs have published their own UAE-specific callback signature scheme yet, so rather than leave that endpoint unauthenticated in the meantime, it's guarded by a shared secret you configure alongside your ASP credentials; a given adapter moves to its vendor-native scheme automatically once one is published.
Resources
- Postman collection — every request on this page, importable directly. Download the collection.
- Interactive API reference — a running service exposes a Swagger UI at
GET /docs. There's no public hosted sandbox to link to yet; it's available once you have tenant credentials and a service instance. - Design-partner contact— request sandbox access below and we'll issue tenant credentials and walk through your field mapping directly.
Ready to see it against your own payload?
Request sandbox access