AE Compliance

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:

header
X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The 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:

EndpointPersists?Use it for
POST /api/v1/compliance/validateNoIntegration development — call it in a loop while you get your field mapping right
POST /api/v1/compliance/submitYesProduction 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.

bash
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"
}
}'

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.

json
{
"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_MANDATORYdocument

A 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_FORMATidentity

supplier_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_FORMATidentity

A Peppol endpoint ID isn't scheme-qualified as <scheme>:<identifier>. Format it as 0235:<10-digit TIN>, for example 0235:1000000000.

PEPPOL_SCHEME_INVALIDidentity

The 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_FORMATidentity

The 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_MISMATCHidentity

The 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_MATHmath

On 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_MATHmath

A 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_CURRENCY

tax_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_CONDITIONAL

A 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_UNKNOWNVAT

A 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_MISMATCHVAT

The 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_MISSINGVAT

The 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_FORMAT

The 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_INVALIDidentity

A 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:

CodeLabelAllowed rate(s)
SStandard rate5.00
ZZero rated0.00
EExempt from tax0.00 (requires an exemption reason)
OOutside scope of tax / not subject to tax0.00 (requires an exemption reason)
AEVAT reverse charge0.00 or 5.00
NStandard rate, additional VAT base5.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:

RECEIVED

Initial state before validation completes — transient.

VALIDATED

Passed validation — transient, used internally between validate and enqueue.

PENDING_TRANSMISSION

Validated and persisted; queued for the worker to submit to your ASP.

TRANSMITTED

The worker handed the invoice to the ASP; awaiting Peppol/FTA clearance.

CLEARED_BY_FTA

Terminal success — the FTA cleared the invoice via the Peppol network.

REJECTED

Terminal failure — the ASP or FTA rejected the invoice after transmission.

FAILED

Terminal failure — a permanent error occurred during ASP transmission, such as a 4xx from the ASP.

NEEDS_REVIEW

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 digest
  • X-Signature-Timestamp — unix timestamp the signature was generated at
  • X-Signature-Nonce — random per-delivery nonce

The signed message is the concatenation:

text
"{timestamp}.{nonce}.".encode() + body_bytes

keyed 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:

python
import hashlib
import hmac
import time
MAX_AGE_SECONDS = 300
def 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