Comprehensive guide for implementing PINT AE-compliant e-invoicing in the United Arab Emirates. Built on Peppol International standards with UAE-specific requirements for seamless FTA integration and compliance.
PINT AESpecifications
DCTCEModel
PeppolNetwork
PhasedRollout
Reporting Mandates
FTA Peppol eInvoicing
PINT AE Framework
Exchange Networks
Peppol Network
Accredited Service Providers
Supported Documents
Commercial Invoices (380)
Self-Billed Invoices (389)
Credit Notes (381, 81, 361)
Out of Scope Invoices (480)
Introduction
UAE e-invoicing runs on Peppol. You send a JSON document to Flick. One submission delivers the invoice to the buyer over Peppol and reports tax data to the Federal Tax Authority (FTA). Those two jobs succeed and fail independently.
The model is Decentralized Continuous Transaction Control and Exchange (DCTCE). There is no clearance step. The FTA is not in the delivery path, and an invoice is not held for FTA approval before it moves.
You do not build UBL, sign documents, run Schematron, or call the FTA. You send a payload and track two status values.
Scope
Documents
B2B and B2G invoices, credit notes, self-billed invoices and credit notes, and out-of-scope documents
Businesses
All UAE-operating entities, VAT-registered or not
Route
Peppol only. A PDF emailed to a customer does not meet the obligation
Reporting
Automatic. Both access points report a Tax Data Document. You never call the FTA
B2C is out of scope today. Phase dates are published by the Ministry of Finance.
Roles
Corner
Who
Does
C1
Supplier
Issues the invoice
C2
Supplier's access point
Validates, renders, transmits, reports the TDD
C3
Buyer's access point
Receives, validates, delivers, reports the TDD, returns an MLS
C4
Buyer
Receives the invoice
C5
FTA
Collects the Tax Data Document from both sides
Flick is C2 for what you send and C3 for what you receive. Both sides report the same transaction, so the FTA can reconcile two views.
Delivery and reporting
Reporting is your obligation to the FTA. Delivery is the exchange with your customer. They can fail independently.
exchange.status becomes delivered only when the recipient's Message Level Status arrives. Until then it stays processing, even after the document has left your access point.
status folds both tracks into one value for routing. When it reads failed, check reporting.settled and exchange.settled. Settled failure means the document was assessed and refused: correct and reissue. Unsettled failure means retries are still in progress: do nothing. Reissuing an unsettled failure creates a second document for one transaction.
What changes in your system
document_identifier must be unique per participant per financial year of issue_date.
Domestic invoices need the buyer's Peppol id, and usually a TRN or legal registration id.
Corrections are credit notes (381 plus reason and reference), not edits to the original.
Delivery is asynchronous. Plan for status that resolves after the API call returns.
Document types
Code
Document
380
Commercial invoice
480
Invoice out of scope of tax
381
Credit note
81, 83, 396, 532
Credit note variants
389
Self-billed invoice
261
Self-billed credit note
Use the type code. A 380 with is_selfbilled: true still renders as a plain invoice.
Transaction flags
Eight flags compose the PINT AE transaction type code. Send all nine flag keys (including is_selfbilled) on every document so the code is always built.
Flag
Code list name
Description (PINT AE)
Also requires
is_ftz
Free trade zone (position 1)
Supply of goods or services in Free trade zone
fz_beneficiary_id
is_deemed
Deemed supply (position 2)
Supply of goods or services without consideration
is_margin
Profit Margin Scheme (position 3)
Supply of goods under the profit margin scheme
Every line in the margin category
is_summary
Summary invoice (position 4)
Taxable person makes more than one supply of Goods or Services to the same person in the same calendar month
invoice_period
is_continuous
Continuous Supply (position 5)
Supply of goods and/or services made on recurrent basis
is_dab
Agent billing (position 6)
Agent who is a registrant makes a supply on behalf of the principal
Reverse charge and zero-rating are line VAT categories (AE, Z), not flags. Exempt uses E plus a reason code.
Business use cases
The specification covers sixteen business cases. Baseline cases map to document types. Conditional cases map to transaction flags and VAT categories. You do not declare a use case by name. It falls out of the type code, flags, and categories.
#
Scenario
How you express it
1
Tax invoice
document_type380
2
Credit note
document_type381
3
Commercial invoice
document_type380
4
Self-billed invoice
document_type389
5
Self-billed credit note
document_type261
6
Out of scope of tax
document_type480
7
Reverse charge
Line tax_categoryAE
8
Zero-rated
Line tax_categoryZ
9
Exempt
Line tax_categoryE plus a reason code
10
Profit Margin Scheme
flags.is_margin, category N on every line
11
Deemed supply
flags.is_deemed
12
Continuous Supply
flags.is_continuous
13
Agent billing
flags.is_dab plus principal_id
14
Summary invoice
flags.is_summary plus invoice_period
15
Free trade zone
flags.is_ftz plus fz_beneficiary_id
16
Supply through E-commerce / Exports
flags.is_ecommerce or flags.is_export, plus a delivery address
What you build vs what Flick does
You
Flick
Map your invoice model to the Regional API payload
UBL generation, Schematron, XSD
Handle 400 responses during development
Peppol routing, SMP lookup, AS4 transport
Track reporting and exchange status
FTA reporting and retries
Consume the events feed, or poll
Artifact storage (UBL, SBD, MLS, TDD)
Regional API: UAE only. Endpoints under /v1/participants/{participant_id}/. The body is the document.
Unified API: Same document body plus a country discriminator, for multi-market use.
You send an invoice as JSON. Flick turns it into compliant UBL XML and does two jobs with it: it reports the tax data to the Federal Tax Authority (FTA), and it delivers the invoice to your buyer over the Peppol network. Both jobs finish after the API call returns, so the core skill on this API is submitting a document and then tracking what happens to it.
This page walks the whole journey: submit, track, receive, acknowledge.
The big picture
You POST the invoice JSON.
Flick validates it. If anything is wrong, you get a 400 with a list of issues and nothing is stored.
A valid invoice gets a 202 response with a document_id. Save that id.
Flick then works on two tracks at the same time: reporting tax data to the FTA, and delivering the invoice to the buyer.
Your app finds out how both tracks ended by polling or by reading the events feed.
The one rule to remember: 202 means accepted and queued, never delivered. Delivery and reporting finish later, on their own schedule.
Step 1: Authenticate and submit
Every request carries one auth header. There are two kinds of credential, told apart by prefix:
The standard Authorization: Bearer header works too, with the same credentials. If you were given a client_id and client_secret instead of an API key, exchange them for a token first:
Save data.document_id. It is how you track this invoice from now on.
Two things to know about the payload:
Amounts are decimal strings: "total_amount": "1200.00", never a bare JSON number.
A 400 means the document was refused and nothing was filed. Fix the payload and submit again. See Examples for complete payloads and Validation & Errors for how to read a rejection.
Step 2: The three status fields
Every document carries three status fields: two detailed tracks, plus one coarse summary that folds them together.
Each event carries the three status fields, so most of the time you do not need to fetch the document at all.
There are two feeds: one for what you send, and one for what arrives for you. direction is required and has no default, and it accepts either vocabulary: sent and outgoing name the same feed, as do received and incoming. Paging and acknowledgement work the same on both, and are explained below.
Receiving invoices
When a supplier sends you an invoice over Peppol, it simply appears on your account with direction: incoming. Nothing pushes it to you; your app has to look. Again, there are two ways.
Option A: Poll the document list
HTTP
GET /v1/participants/{participant_id}/documents?direction=incoming&since_id={newest_id_you_have_seen}
since_id returns only documents newer than an id you already know, so you do not reread the same rows forever. The catch: you own the bookkeeping. Your app must store the newest id it has processed, and if that record is lost or wrong, you will miss invoices or process them twice.
Option B: Read the events feed (recommended)
Every arriving invoice raises a document.received event on the received feed:
HTTP
GET /v1/participants/{participant_id}/events?direction=received
The loop for handling incoming invoices:
Fetch a page of events.
For each document.received event, fetch the full invoice with GET /documents/{document_id} and process it in your system.
Acknowledge the page. The server now remembers you are done with those events.
That third step is why the events feed wins for incoming invoices. The server keeps your position for you, so "which invoices have I already handled" survives crashes and restarts. The document list cannot do that.
One naming detail. The document list takes only direction=incoming and outgoing; the events feed takes those as well as received and sent. Whichever you send, the events response echoes the incoming and outgoing pair back in data.direction, so match on those when you read it rather than on what you asked for.
Two more things about the feed that are easy to get wrong. GET /events/position for a feed you have never acknowledged answers 200 with acked: null, not 404, so branch on acked rather than on the status code. And the cursor in a POST /events/ack body must be the opaque string from an event id; a number is reported back as a missing parameter.
How acknowledgement works
Acknowledging is like marking mail as read, except the marker is stored on the server.
Every page of events comes with a next_cursor. When you tell the API "I am done through this cursor", that becomes your stored position, and the feed continues from there for you, even after a crash or redeploy.
Three tools drive it:
Tool
What it does
GET /events?...&ack_cursor={cursor}
Acknowledges everything through the cursor, then returns the next page. The normal loop needs only this
POST /events/ack with {direction, cursor}
Acknowledges without fetching a page. For when you are done and idle
GET /events/position?direction=...
Reads your stored position without changing anything. Use it on startup
The crash-safe loop, step by step:
On startup, call GET /events/position. If it returns a cursor, continue from it with ?cursor={acked}. If not, start with no cursor at all; you begin at the oldest event.
Fetch a page and process every event in it.
Fetch the next page with ack_cursor set to the next_cursor of the page you just finished. This one call commits your progress and gets more work.
Repeat from step 2. An empty page means you are caught up; keep its next_cursor and poll again after a pause.
Shutting down, or idle after an empty page? Commit the last cursor with POST /events/ack.
Two rules make the loop safe:
Acknowledge only after your side of the work is done (saved to your database), never before. If you crash mid-page, the unacknowledged events are simply served again.
Because events can be served again, they can repeat. Keep a record of processed event ids and skip the ones you have seen. event.id is the deduplication key.
Cursors only move forward. An acknowledgement behind your stored position is ignored, so two workers polling the same feed cannot rewind each other.
Endpoint quick reference
Endpoint
Use it to
POST /v1/oauth/token
Exchange client credentials for an access token
POST /v1/participants/{participant_id}/documents
Submit an invoice
GET /v1/participants/{participant_id}/documents/{document_id}
Read one document with full track detail
GET /v1/participants/{participant_id}/documents
List and filter documents, both directions
GET /v1/participants/{participant_id}/events
Read the status feed, with cursor or ack_cursor
POST /v1/participants/{participant_id}/events/ack
Commit a position without fetching
GET /v1/participants/{participant_id}/events/position
Read your stored position
Full request and response schemas for every endpoint, including bulk submission, document XML and artifacts, are in the UAE API Reference.
Invoice Datapoints
Every field the Regional API accepts on a UAE document, with its requirement, allowed values and the PINT AE rules that touch it. Fields are grouped in payload order. Unless a field is tagged otherwise, it exists on all four document types.
190 fields
Document header
Identity, dates and currency. Everything here sits at the root of the payload.
Why the credit note is being issued. Mandatory on a 381.
Reason for issuing the credit note. Confirm the exact key name against the Flick credit-note schema.
DL8.61.1.AThe supply was cancelled
DL8.61.1.BTax treatment changed with the nature of the supply
DL8.61.1.CThe agreed consideration was altered
DL8.61.1.DGoods or services returned, consideration returned
DL8.61.1.ETax was charged or applied in error
VDVolume discount: the one code that discharges ibr-055-ae
{ "credit_note_reason_code": "DL8.61.1.D"}
Flags
Mandatory block: the API rejects a document without it (ibr-154-ae), so send at least flags: {}. Every key inside is optional and defaults to false. The eight transaction-type flags are read in code-list order to compose the PINT AE transaction type code (BTAE-02).
Optional block, prefilled from the entity profile you onboarded with Flick. peppol_id and legal_name always come from your registration and values sent for them are ignored. On a self-billed document the issuer is the buyer, so this block carries the buyer's details.
Trading name the seller operates under, when it differs from the legal name.
The whole issuing_party block is OPTIONAL: anything you omit is prefilled from the entity profile onboarded in Flick. Send values only to override the profile.
Mandatory. peppol_id is what routes the document, and a submission without it is rejected before validation. On a self-billed document this block carries the supplier's details.
Buyer tax identification number. The service currently rejects this identifier for the buyer under ibr-133-ae, whether or not another identifier is present.
Legal name of the tax representative acting for the seller.
The whole tax_representative block is OPTIONAL - send it only when a tax/fiscal representative acts for the seller. If sent, the name, the postal address (at least the country) and the VAT identifier become mandatory [ibr-018, ibr-019, ibr-020, ibr-056].
Tax representative's country. Mandatory once the block is sent.
Required only when the tax_representative block is sent (fields inside the block are 1..1 within it; the block itself is optional). The address must at least state the country [ibr-020].
{ "tax_representative": { "country_code": "AE" }}
Example AE
Delivery
Required when the e-commerce or export flag is raised.
How and when payment is made. A payment means with its type_code is mandatory except on credit notes and deemed supplies (ibr-191-ae). Common type_code values: 10 cash, 30 credit transfer, 48 card, 49 direct debit.
Unit of measure, from UN/ECE Recommendation 20. The value is passed through without being checked against the code list, so a wrong code reaches the invoice.
Populated by Flick if omitted (= unit_price + price_discount; equals unit_price when there is no discount). If you send it, gross_price - price_discount must equal unit_price.
Mandatory on every line. Only exception: out-of-scope documents (type 81 / Out of scope of VAT), where the line VAT block is not required [ibr-123-ae].
SStandard rate: carries a rate of exactly 5.00
ZZero rated: carries a rate of exactly 0
EExempt: no rate, needs an exemption reason code
OOutside scope of tax: no rate
AEReverse charge: keeps its rate, needs a goods type
VAT rate for this line. Mandatory for S, Z, O and AE categories; an E line validates without it.
Conditional by category: S must state exactly 5.00, Z must state exactly 0, AE must state a rate. For E and O lines a rate is not needed - you may still pass one (e.g. 0) and Flick drops it when generating the UBL, since PINT forbids a rate on those lines.
Optional: Flick computes it from the lines when omitted. Inside this block the rate field is tax_rate, not tax_percentage; the wrong name is silently dropped. A breakdown you send is rendered as sent and is not reconciled for you.
VAT rate for this breakdown row. Derived from the lines when the whole block is omitted; mandatory inside a row you send. The key is tax_rate here, not tax_percentage.
Net amount taxed at this category and rate. Derived when the whole block is omitted; mandatory inside a row you send, and reconciled against the lines.
Optional and fill-missing-only: state a value and yours is used, omit it and it derives from the lines. The two AED conversion totals are never derived and are required on a non-AED document.
Every payload here was validated, submitted and read back on pre-production. The third tab is the
real response from GET /v1/participants/{participant_id}/documents/{document_id}/xml, reformatted
for reading, with the seller's registration details swapped for presentable ones.
Each sample is a complete document. Copy one, change the document_identifier, and it files.
Regional API and Unified API differ only in the envelope. Regional takes the document at
the root or under a document key, unified adds country beside it. The document itself is
identical.
Unknown keys are dropped in silence. A misspelt key is not an error, so it surfaces later as
an arithmetic or Schematron complaint about something else. Suspect a key name first.
Validating consumes the document number. A document_identifier that has been through
/validate cannot be submitted afterwards, and the second call answers 500 with "The operation
was blocked by a retention rule." Use throwaway numbers while you develop.
Invoices
Minimal invoice
The floor. Remove anything here and the document is rejected: nine root keys, one buyer, one line.
No issuing_party: your name, address, VAT number and Peppol endpoint come from registration.
No tax_totals or invoice_totals: both compute from the line.
No uuid or customization_id: the platform stamps them.
flags is mandatory (ibr-154-ae) because the nine booleans compose the transaction-type code.
Every key inside defaults to false, so "flags": {} is enough. The nine are spelled out in each
sample to show which one the scenario raises.
Derived here: cbc:CustomizationID, cbc:UUID, cac:AccountingSupplierParty, cac:TaxTotal,
cac:LegalMonetaryTotal, cac:ItemPriceExtension. Net 1000.00, VAT 50.00, payable 1050.00,
none of it sent.
The same invoice with the breakdown and totals declared rather than derived, plus the optional
header fields most integrations end up sending: issue_time, note, buyer_reference,
order_reference and payment_terms.
The rate key is tax_rate inside tax_totals and tax_percentage everywhere else, including on
lines and on allowances and charges.
tax_percentage inside tax_totals is dropped without an error. The breakdown renders with no
category rate and you get four arithmetic complaints instead: aligned-ibrp-048, ibr-119-ae,
aligned-ibrp-s-08 and aligned-ibrp-s-09.
Omit invoice_totals and everything derives. Send it and every value is reconciled: a wrong
payable_amount fails ibr-co-16, a wrong line_extension_amount fails ibr-co-10 and
ibr-co-13, a wrong taxable_amount fails aligned-ibrp-s-08.
Partial is allowed. {"payable_amount": 1050.00} alone validates, but only because it is right.
The four keys in a tax_totals row are a set. Drop tax_category, tax_rate, taxable_amount
or tax_amount and it fails on invalid_type or invalid_union before Schematron runs.
A discount against the whole invoice rather than against a line.
charge_indicator: false makes the entry an allowance, true makes it a charge. Both live in
the same array.
The key is plural. Singular allowance_charge is dropped in silence, so the discount never
reaches the invoice and the totals you sent stop adding up.
charge_indicator is mandatory in every entry, not optional, as is amount. Either one missing
fails with a field-level Required.
Renders as a document-level cac:AllowanceCharge with its own cac:TaxCategory, and
cbc:AllowanceTotalAmount picks up the 100.00.
State invoice_totals.allowance_total_amount and it must equal the sum of the entries
(ibr-co-11), with the tax-exclusive amount absorbing it (ibr-co-13).
A discount belonging to one line. It reduces that line's net amount, so the document total follows
without you touching invoice_totals.
Thinner than the document-level entry: no tax_category and no tax_percentage, because the
line already carries both.
charge_indicator is still mandatory.
The line's cbc:LineExtensionAmount comes back as 950.00 rather than 1000.00.
The allowance appears as a cac:AllowanceCharge inside cac:InvoiceLine. That is a different
element from the cac:AllowanceCharge always present inside cac:Price, which carries the price
discount and is 0.00 here.
You raise the invoice on your supplier's behalf, so the parties swap. The supplier is the
receiving_party and you are the seller merged in from registration.
Use the type code: 389 is the self-billed invoice, 261 its credit note.
is_selfbilled: true on a 380 does not produce a self-billed document, it produces
ibr-cl-01, because that combination is not a valid UNTDID 1001 pairing.
The flag is not what makes it self-billed. A 389 with is_selfbilled: false validates and
files identically. The flag contributes a bit to the transaction-type code, the type code decides
what the document is.
is_ftz: true sets the free-trade-zone bit in the transaction-type code, and that bit makes
fz_beneficiary_id mandatory (ibr-007-ae).
Raising the flag without the beneficiary is the most common conditional-flag failure, and the
error names the flag rather than the missing field, so it reads as though the flag were wrong.
The flag surfaces as cbc:ProfileExecutionID, the eight-digit transaction-type code. All nine
flags false gives 00000000, this document gives 10000000. The beneficiary lands in
cac:AdditionalDocumentReference.
is_export: true asks for the same four-part delivery address as e-commerce, this time under
ibr-152-ae.
emirates_code is still mandatory even though the address is in another country. The rule wants
a country subdivision and does not special-case a foreign one.
The destination half of the rule text is not enforced. is_export: true with an AE delivery
country validates, so do not rely on the service to catch a wrongly flagged export.
The line is category Z rather than S, the usual pairing for an export, though nothing forces
it.
Both AED conversion totals become mandatory (ibr-053, ibr-084, ibr-175-ae):
total_tax_amount_in_aed and total_amount_with_tax_in_aed. Neither is derived from the rate,
so omitting either one is a rejection.
Line-level line_amount_aed and tax_line_amount_aed are accepted but never required, on any
document.
No AED amount is checked against the rate. A document declaring 1.00 AED of VAT against a 50.00
USD VAT amount validates, so the AED figures are yours to get right.
Renders cbc:TaxCurrencyCode, a cac:PaymentExchangeRate block carrying source and target
currencies, and a second cac:TaxTotal denominated in AED beside the USD one. Every other amount
stays in USD with currencyID="USD".
S and Z need a tax_percentage: exactly 5 for standard (ibr-190-ae), exactly 0 for zero
rated.
E needs tax_exemption_reason_code from the official list (ibr-167-ae), and is the one
category that validates with no tax_percentage at all.
A 380 or 381 cannot be made up only of E and O lines (ibr-151-ae). The fix is document
type 480, not a different category. Mixing one exempt line into an otherwise standard-rated
invoice, as here, is fine.
The derived breakdown carries one cac:TaxSubtotal per category, three here. The exempt row
carries cbc:TaxExemptionReasonCode and cbc:TaxExemptionReason, the standard row carries
cbc:Percent, and a row mixing the two is rejected by aligned-ibrp-s-10.
A domestic reverse-charge supply, where the buyer accounts for the VAT rather than you.
Category AE keeps its rate, unlike Z, E and O.
The line needs a standard_item_id with standard_item_id_scheme set to 0160 (ibr-174-ae,
ibr-064).
The line needs a reverse_charge_item_type from the reverse-charge goods list (ibr-166-ae).
The buyer's VAT number becomes mandatory (ibr-103-ae), the one rule here that sits on the buyer
rather than on the line.
That last one is worth remembering, because the buyer's VAT number is otherwise entirely
optional: a standard-rated invoice to a buyer with no identifiers block validates fine.
commodity_code is the switch. G makes hs_code mandatory (ibr-184-ae), S makes
sac_code mandatory (ibr-185-ae).
Leaving commodity_code out means neither is required, and an hs_code sent on its own is
accepted.
This is why hs_code and sac_code are conditional rather than optional. Nothing about the item
requires them, but declaring what kind of item it is does.
The document type for supplies that carry no VAT at all.
480 is the answer to ibr-151-ae, the rule that rejects a 380 or 381 made up entirely of
exempt and out-of-scope lines. A document whose only line is category E has to be a 480.
The reverse also holds: a 480 carrying an ordinary standard-rated line is rejected by
ibr-122-ae.
Three ways to attach something, all in the same array.
A plain reference is an identifier plus document_type_code and scheme_id, pointing at
something the recipient already has.
A URI reference adds attachment_uri.
An embedded document carries the bytes: mime_code, filename and base64 content, where the
first two are mandatory once embedded_document exists.
An entry with no document_identifier is accepted rather than rejected, and dropped quietly, so
a mistyped key here costs you the attachment without an error.
Read back, an embedded attachment is described rather than inlined:
That index is what /documents/{document_id}/attachments/{index} takes. The 20 MiB ceiling is
checked against the recorded size_bytes before any bytes are read.
A credit transfer with bank details, settled in two instalments.
payment_means[].type_code is where the conditionals sit. 30, credit transfer, makes
payment_account_id mandatory (ibr-192-ae).
The common codes are 10 cash, 30 credit transfer, 48 card payment, 49 direct debit, 54
credit card and 68 online payment service.
Card codes do not make card.primary_account_number mandatory, despite what the pairing
suggests. 48 with no card block validates.
payment_means itself is required on a 380 (ibr-191-ae), and not on a credit note or a
deemed supply.
Instalments are repeated payment_terms entries, each with its own amount and
installment_due_date. Nothing reconciles them against payable_amount, so two halves that do
not add up will file.
Everything optional at the document level on one invoice, so you can see where each block goes and
what it renders as.
other_references collects the six reference types with nowhere else to live: despatch, receipt,
tender or lot, contract, customs and project.
delivery carries a location and a party name as well as the address, none of it required
without an e-commerce or export flag.
payee_party names a third party to be paid. Once it exists name is mandatory (ibr-017),
though an entirely empty payee_party: {} is accepted and dropped.
contract_value is a string, not a number, and reads like "AED 250000". Sending it as a number
fails with a field-level Expected string, received number.
A document passes two checks before it is filed. Field-level validation runs against the payload and reports dotted field paths. PINT AE Schematron runs against the rendered UBL and reports rule codes. Both come back as 400, both use the same issue shape, and the second is where most real integration time goes.
Diagram
flowchart LR P["Payload"] --> S["Unknown keys<br/>SILENTLY STRIPPED"] S --> F["Field validation"] F -->|"fails"| FE["400 · path names a field<br/>receiving_party.peppol_id"] F -->|"passes"| U["Render PINT AE UBL"] U --> SC["Schematron + XSD"] SC -->|"fails"| SE["400 · path is 'Document'<br/>code names a rule, ibr-172-ae"] SC -->|"passes"| OK["202 Accepted"]
Every response quoted on this page was captured from the pre-production service.
Telling the two phases apart
Read message before you read errors. It names the phase, and the phase decides how you debug.
message
Phase
path looks like
What to do
Document does not satisfy the regional schema.
Field validation
receiving_party.peppol_id
The named key is missing, the wrong type, or carries a value outside its enum. Fix that key.
Document is invalid
Schematron
Document
Read code, not path. Every issue reports the same useless path.
The two phases never mix in one response. Field validation aborts before the UBL is rendered, so a payload with both a bad enum and a broken total reports only the enum.
Field validation errors
Four codes cover everything this phase reports.
custom: a mandatory field is missing
The most helpful error on the surface. These carry hand-written messages that tell you what the field is for, not just that it is absent.
JSON
{ "status": "bad_request", "message": "Document does not satisfy the regional schema.", "errors": [ { "path": "receiving_party.peppol_id", "code": "custom", "message": "receiving_party.peppol_id is required — the recipient's routing identity is not derived, state it explicitly (e.g. \"0235:1234567890\").", "severity": "error" } ]}
invalid_type: wrong JSON type, or absent
invalid_type with the message Required is how the plainer mandatory fields report themselves. An empty body {} returns one of these per missing field: document_identifier, issue_date, document_type, document_currency, receiving_party.legal_name, receiving_party.street_address and the rest.
The same code covers a value of the wrong type:
JSON
{ "status": "bad_request", "message": "Document does not satisfy the regional schema.", "errors": [ { "path": "other_references.contract_document_reference.contract_value", "code": "invalid_type", "message": "Expected string, received number", "severity": "error" } ]}
contract_value is the field this catches most often, because it reads like an amount and is a string.
invalid_enum_value: outside the allowed set
The most useful error of the four, because the message lists the whole accepted set:
Raised when an entry in a repeated block is missing something the block needs. Sending a tax_totals row with only its category and rate reports the two amounts it still wants, with an array index in the path and no message worth reading:
payment_terms[] is the exception: an entry of {} is accepted.
Schematron errors
These arrive after the UBL is rendered, with message: "Document is invalid" and path: "Document" on every issue. The code is the only part that identifies anything.
Conditional requirements, grouped by what triggers them
Most first rejections are here. Each row is a thing you sent that made something else mandatory.
You sent
Which requires
Code
flags.is_ftz: true
fz_beneficiary_id
ibr-007-ae
flags.is_dab: true
principal_id, and it must differ from your own VAT number
ibr-137-ae, ibr-176-ae
flags.is_ecommerce: true
delivery.address with street, city, emirates and country
ibr-142-ae
flags.is_export: true
delivery.address with street, city, emirates and country
ibr-152-ae
flags.is_summary: true
invoice_period with both bounds
ibr-138-ae
document_currency other than AED
currency_exchange_rate
ibr-153-ae, ibr-159-ae
document_currency other than AED
invoice_totals.total_tax_amount_in_aed and total_amount_with_tax_in_aed
ibr-053, ibr-084, ibr-175-ae
document_type: "381"
credit_note_reason_code
ibr-158-ae
document_type: "381" with any reason but VD
document_references
ibr-055-ae
tax_category: "AE" on a line
standard_item_id with scheme 0160
ibr-174-ae, ibr-064
tax_category: "AE" on a line
reverse_charge_item_type
ibr-166-ae
tax_category: "AE" on a line
the buyer's VAT number
ibr-103-ae
tax_category: "E" on a line
tax_exemption_reason_code
ibr-167-ae
commodity_code: "G" on a line
hs_code
ibr-184-ae
commodity_code: "S" on a line
sac_code
ibr-185-ae
payment_means[].type_code: "30"
payment_account_id
ibr-192-ae
any AE:TL identifier
its authority_name
ibr-172-ae seller, ibr-101-ae buyer
a tax_representative block
name, vat_number, emirates_code, country_code
ibr-018, ibr-056, ibr-128-ae, ibr-020
a non-empty payee_party
name
ibr-017
document_type: "380"
payment_means, unless the supply is deemed
ibr-191-ae
a due amount above zero
due_date
ibr-127-ae
Two of these surprise people. ibr-103-ae is the only rule that makes the buyer's VAT number mandatory; without a reverse-charge line the buyer needs no identifiers at all. And ibr-152-ae still wants emirates_code on an export delivery address even when the country is not AE, because it asks for a country subdivision and does not special-case a foreign one.
Value constraints
Code
Rule
ibr-190-ae
A standard-rated line carries a rate of exactly 5
ibr-120-ae
A zero-rated breakdown row states its rate as exactly 0
ibr-121-ae
An exempt breakdown row states no rate at all
aligned-ibrp-s-10
A standard-rated breakdown row must not carry an exemption reason
ibr-141-ae
tax_point_date, when present, falls before issue_date
ibr-029
invoice_period.end_date is not before start_date
aligned-ibrp-057
percentage and base_amount on an allowance or charge are both present or both absent
ibr-133-ae
A party tax identifier must sit in the VAT scheme
ibr-104
The buyer carries at most one tax identifier
Document composition
Code
Rule
ibr-016
An invoice has at least one line
ibr-025
Line item name required, despite the schema calling it optional
ibr-125-ae
Line item description required, likewise
ibr-154-ae
Transaction-type code required, which is why flags is mandatory
ibr-cl-01
The document type code must be a valid UNTDID 1001 pairing, which is why is_selfbilled: true on a 380 fails
ibr-151-ae
A 380 or 381 cannot be made up only of E and O lines; use 480
ibr-122-ae
A 480 or 81 cannot carry an ordinary standard-rated line
ibr-116-ae
A margin-scheme document needs the margin tax category
ibr-001-ae
The credit note reason code comes from the code list
ibr-150-ae
Seller legal registration id required when the scheme is 0235
ibr-010-ae
AE:PAS requires a passport issuing country
Arithmetic
These fire only on values you sent. Omit tax_totals and invoice_totals and none of them can trigger.
Code
Equation
ibr-co-10
line_extension_amount = sum of line net amounts
ibr-co-11
allowance_total_amount = sum of document allowances
ibr-co-12
charge_total_amount = sum of document charges
ibr-co-13
tax_exclusive_amount = line net total, less allowances, plus charges
Each standard-rated breakdown row's taxable amount matches its lines
aligned-ibrp-s-09
Each standard-rated breakdown row's tax = taxable amount x rate
ibr-147-ae
Line net amount = quantity x (unit price / base quantity)
Stating a total is a commitment, not a hint. A single wrong figure is enough:
JSON
{ "status": "bad_request", "message": "Document is invalid", "errors": [ { "path": "Document", "code": "ibr-co-16", "message": "[ibr-co-16]-Amount due for payment (ibt-115) = Invoice total amount with Tax (ibt-112) - Paid amount (ibt-113) + Rounding amount (ibt-114).", "severity": "error" } ]}
Partial totals are fine, so {"payable_amount": 1050.00} alone validates. It validates because the number is right, not because it was the only one.
Silently stripped keys
Every payload mistake found while verifying this API was a correctly spelled but wrong key, and none of them produced an error naming that key. Unknown keys never reach the renderer, so the failure surfaces several steps later as an arithmetic or Schematron complaint about something else entirely.
Diagram
flowchart TD A["You send tax_percentage<br/>inside tax_totals"] --> B["Key is not recognised<br/>silently stripped"] B --> C["Breakdown renders with<br/>no VAT category rate"] C --> D["aligned-ibrp-048 · ibr-119-ae<br/>aligned-ibrp-s-08 · aligned-ibrp-s-09"] D --> E["Four errors about<br/>totals arithmetic"] E --> F["Real cause: one wrong key name"]
ibr-co-11, ibr-co-13, if you stated matching totals
flags.is_self_billed instead of is_selfbilled
The transaction-type bit is never set
Nothing. 200, and a document with the wrong transaction type
An unknown key at the root, or on a line
Removed
Nothing. 200
additional_document_references: [{}]
The entry is dropped
Nothing. 200, and no attachment
payee_party: {}
The block is dropped
Nothing. 200, and no payee
The tax_rate case is the cruellest, because tax_percentageis correct on lines and on allowances and charges. It is wrong only inside tax_totals.
When an error message does not match anything you can see in your payload, check key spelling before anything else.
What is not validated
Just as important as what is checked. Everything here is accepted without complaint, so it is yours to get right.
Field
What is not checked
invoice_lines[].uom
Not checked against UN/ECE Recommendation 20. "XYZ" is accepted and reaches the invoice
invoice_totals.total_tax_amount_in_aed
Required on a non-AED document, but never reconciled against currency_exchange_rate
invoice_totals.total_amount_with_tax_in_aed
Same: required, never reconciled
invoice_lines[].line_amount_aed
Accepted on any document, never required, never reconciled
invoice_lines[].tax_line_amount_aed
Same
payment_terms[].amount
Instalments are not summed against payable_amount
delivery.address.country_code under is_export
The rule text calls for a destination outside the UAE, but an AE country validates
Authentication and authorization
Three distinct failures, and the status code does not separate them the way you would expect. A missing credential is 401; both a bad credential and a credential pointed at someone else's participant are 403. All three use the bare { error, message } shape rather than the { status, message, errors } envelope.
No credential at all, 401:
JSON
{ "error": "Authentication Required", "message": "Missing credential — send X-Flick-Auth-Key or an Authorization: Bearer token."}
Unknown, revoked or expired key, 403:
JSON
{ "error": "Authentication Failed", "message": "Invalid or revoked API Key."}
A valid key, aimed at a participant it does not cover, 403. The message names the participant, which makes this the easiest of the three to diagnose:
JSON
{ "error": "Authorization Failed", "message": "Credential is not scoped to participant '019fc2bd-0000-0000-0000-000000000000'."}
The token exchange is the one endpoint that does not follow either shape. It answers 401 in OAuth's own vocabulary:
JSON
{ "error": "invalid_client", "error_description": "Invalid or expired client credentials."}
Transport and framework errors
A request that never reaches the application is answered by the web framework, not by the API. These come back as text/html with no JSON at all, so check Content-Type before you parse an error body.
Request
Status
Body
Unknown path
404
HTML, Cannot GET /v1/participants/…/no-such-thing
Malformed JSON body
400
HTML, Bad Request
Wrong method on a known path
500
HTML, Internal Server Error, not the 405 you would expect
A document_identifier where a document_id belongs
500
HTML, not the 404 you would expect
Application-level 404s do return JSON, and are deliberately indistinguishable between "no such document" and "not yours":
JSON
{ "status": "not_found", "message": "Document not found"}
An artifact that exists as a type but not yet for this document is also a 404, with detail:
JSON
{ "status": "not_found", "message": "Artifact not available", "errors": { "message": "DOCUMENT_SBD_SIGNED artifact not available for this document" }}
That means not yet rather than never. DOCUMENT_XML is there as soon as the document is accepted; DOCUMENT_SBD_SIGNED, DOCUMENT_MLS_RECVD and DOCUMENT_TDD appear once the exchange reaches delivered. Poll the event feed rather than retrying blindly.
Status codes
Status
status
Meaning
400
bad_request
Validation failure, or a bad parameter
401
absent
No credential. Bare { error, message }
403
absent
Bad credential, or outside participant scope. Bare { error, message }
404
not_found
No such document, or not yours, deliberately indistinguishable
409
conflict
DUPLICATE_DOCUMENT. Not yet what you get; see the defect below
429
absent
Rate limited; carries Retry-After and the X-RateLimit-* headers
500
server_error
Anything else; errors is omitted
503
absent
Rate limiter unavailable, so the request is refused rather than served unchecked
Error shape
The key is errors, not error, and it is omitted entirely when there is no detail to report. status here is an error kind, not the document status vocabulary.
errors is an array for validation failures:
JSON
{ "status": "bad_request", "message": "Document is invalid", "errors": [ { "path": "Document", "code": "ibr-007-ae", "severity": "error", "message": "[ibr-007-ae]-When Invoice Transaction-type code (BTAE-02) has value 1XXXXXXX (Free trade zone), then Beneficiary ID (BTAE-01) MUST be provided." } ]}
A validation issue carries path, code, message and severity. path is a dotted field path for field validation and the literal "Document" for a Schematron rule. Only severity: "error" aborts the submission.
Schematron messages repeat the code inside the text, in the form [ibr-007-ae]-…, so strip the prefix before showing a message to a user.
Known defects
Each of these was reproduced against the live service.
Duplicates return 500, not 409. Resubmitting a document_identifier already used in the same calendar year answers:
JSON
{ "status": "server_error", "message": "The operation was blocked by a retention rule."}
A client following normal retry conventions will retry something that can never succeed. Until this is fixed, treat that exact message as "already filed, do not retry".
validate consumes the document number. The same 500 answers a POST /documents for a number that has been through /validate, and a second /validate of it. Validation is not a dry run as far as the identifier is concerned. Use throwaway numbers while you are developing.
The rate-limit headers disagree.X-RateLimit-Limit reports 2000 while X-RateLimit-Remaining reports 3999, and the remaining count does not decrement across requests. Do not drive client-side throttling off them yet.
events/position returns 200, not 404, for a feed with no stored position. The body carries acked: null and behind: null. Branch on acked rather than on the status code.
events/ack reports a numeric cursor as missing. The cursor must be the opaque string from an event id. Sending {"direction": "outgoing", "cursor": 1} answers Both 'direction' and 'cursor' are required., which points at the wrong problem.
The margin scheme cannot be filed.flags.is_margin: true fails ibr-116-ae for every tax category, and category N fails ibr-108-ae or ibr-111-ae whether or not the flag is set. There is currently no accepted combination.
A buyer AE:TIN is always rejected.receiving_party.identifiers carrying AE:TIN fails ibr-133-ae whether or not another identifier is present, because the rule requires the buyer's tax identifier to sit in the VAT scheme. Use AE:VAT for the buyer.
A wrong ?issue_date on a by-id read gives 404, not a slower lookup. The parameter narrows the search rather than filtering the result.
Peppol lookups query the production SML, so pre-production participants always report is_registered: false. Correct behaviour, not a fault.
Debugging a rejection
Diagram
flowchart TD E["400"] --> M{"message"} M -->|"'does not satisfy<br/>the regional schema'"| F["Field validation.<br/>Read path. Fix that key."] M -->|"'Document is invalid'"| S["Schematron.<br/>Read code. Ignore path."] S --> K{"Does the message describe<br/>something you did send?"} K -->|"yes"| FIX["Fix the value."] K -->|"no"| SPELL["Check key spelling.<br/>A stripped key is the usual cause."] FIX --> V["Re-validate under a<br/>THROWAWAY number"] SPELL --> V