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.
Real-time validation feedback
/validate runs synchronously. There is no queue, no job ID to poll, and no delayed callback: the request and the response are the same round trip. The exact same normalize-then-validate pipeline that runs for a real submission runs inline, and every issue it finds comes back in that one response.
Nothing is persisted. You can call /validate as many times as you need while you fix your field mapping, with the same payload or a changed one, and it never creates a ledger row, never queues a transmission, and never shows up in your usage count. The response is the full ValidationIssue list for that submission, not just the first failure, so one call surfaces every field that needs fixing instead of discovering them one at a time.
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.
Document types the mandate recognizes
invoice_type_code is one of three values. Which one you send depends on what the document is, not on whether VAT applies to it:
380InvoiceThe standard document code, covering both a Tax Invoice (a VAT-taxable supply, with VAT category lines S, AE, or N) and a Commercial Invoice (an exempt, out-of-scope, or otherwise non-VAT supply, with VAT category lines E or O). The two share one type code; the distinction lives entirely in the VAT category composition of the lines, not in a separate code. Per the MoF Mandatory Fields glossary, a Commercial Invoice is simply an invoice that is not a Tax Invoice.
381Credit NoteRequires a preceding_invoice_reference linking back to the original document. The validator already enforces this: submit a credit note without it and you'll get MISSING_CONDITIONAL on that field.
383Debit NoteSame linkage rule as a Credit Note: preceding_invoice_reference is conditionally mandatory and is checked the same way.
Self-billingis a separate, orthogonal question: who is allowed to call the API for a given document, not which type code it carries. Ministerial Decision 243/2025, Article 9, permits the buyer to issue an Electronic Invoice or Electronic Credit Note on the seller's behalf, provided both parties are Registrants and the arrangement is contractually agreed. A self-billed invoice still uses 380, a self-billed credit note still uses 381; nothing about the payload shape changes, only who is authorized to submit it.
Mixed-category review flag: an invoice whose lines combine a taxable VAT category (S, AE, or N) with an exempt or out-of-scope category (E or O) is an unusual combination on a single document, and Kodowo flags it for a second look before you submit, rather than letting it pass silently.
Sandbox testing procedures
There's no separate sandbox infrastructure to stand up. A sandbox tenant runs against the exact same API as production, on the sandbox plan: the same endpoints, the same validation logic, the same issue codes. What makes it a sandbox is that /validate never persists (see above), so a sandbox tenant can call it freely without generating real ledger rows or usage.
Getting credentials: there's no self-serve signup yet. Request sandbox access through the early-access flow and a tenant administrator issues your X-API-Key out-of-band. Test data: use invented invoice numbers, amounts, and party names, not a real business's actual TRN or invoice data, since there is no live ASP connection behind a sandbox tenant and nothing you submit through /validate is ever transmitted anywhere.
First integration, step by step
- 1. Request sandbox access and receive your API key.
- 2. Confirm connectivity:
GET /healthz(no API key required). - 3. POST a sample invoice to
/validate. - 4. Read the returned issue-code list against the catalog above.
- 5. Fix the fields the messages point at and resubmit.
- 6. Repeat until the response comes back
is_valid: true. - 7. Switch the same payload to
/submitonce clean; the checks are identical, so nothing about the payload shape changes.
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. - Sandbox access: see Sandbox testing procedures above for how to request credentials and the first-integration walkthrough.
Ready to see it against your own payload?
Request sandbox access