E-Invoice Integration API — Integration Guide
This service sits between your application (ERP, POS, billing system) and Marmin, the UAE e-invoicing provider (Accredited Service Provider). You send JSON invoices to this service. It validates them, signs in to Marmin, submits them over Peppol to the buyer and the FTA, and tells you when they are cleared.
Your app ──X-API-Key──▶ E-Invoice Integration API ──HMAC+JWT──▶ Marmin ──Peppol──▶ Buyer + FTA
▲ │ validates, retries, signs in │
└──── forwarded webhook ◀─┴──── stores webhook events ◀──────────┘ (status updates)
This guide covers how to use this service. The document fields (what goes inside an invoice) are exactly Marmin's, so for those follow the official Marmin documentation linked in §6.
Marmin API version: 2026-05-07. Interactive reference: Swagger UI at /docs.
Contents
- One-time setup
- Environments
- Calling this API
- Endpoint catalogue
- Document lifecycle and status
- Document fields (Marmin documentation)
- Webhooks
- Errors and retries
- Open points to confirm with Marmin
1. One-time setup
| # | Step | Where |
|---|---|---|
| 1 | Get a Marmin account (email support@marmin.ai). Sandbox: https://sandbox.ae.marmin.ai. Production: https://app.ae.marmin.ai. |
Marmin |
| 2 | Ask Marmin support to enable API Integration, then copy the Client ID and Client Secret from Developer Dashboard → Settings → API Configuration. | Marmin web app |
| 3 | In EmaraTax, select Marmin as your ASP for your TIN. | emaratax.tax.gov.ae |
| 4 | Claim your business profile: POST /api/v1/business-profiles/claim/request-otp, then POST /api/v1/business-profiles/claim. Keep the returned profile_id (MBP-...). |
This API |
| 5 | Complete the profile (address, trade licence) with PUT /api/v1/business-profiles/{profile_id} or in the Marmin web app. |
This API / Marmin |
| 6 | Register https://<your-host>/api/v1/webhooks/marmin in Developer Dashboard → Webhooks and reveal the Webhook Signing Secret. |
Marmin web app |
| 7 | Configure the service for your environment (§2), start it, and call GET /api/v1/health/marmin to check the credentials. |
This service |
2. Environments
APP_ENV selects the environment. Each one has a committed settings file without secrets.
| development | staging | production | |
|---|---|---|---|
| Settings file | .env.development |
.env.staging |
.env.production |
| Marmin | sandbox | sandbox | production (URL from Marmin at go-live) |
Swagger UI /docs |
on | on | off |
This guide /guide |
on | on | on |
Secrets (MARMIN_CLIENT_ID, MARMIN_CLIENT_SECRET, MARMIN_WEBHOOK_SECRET, SERVICE_API_KEYS,
WEBHOOK_FORWARD_SECRET) are provided as environment variables on the server, or in a git-ignored
.env.<APP_ENV>.local file on a developer machine. Staging and production refuse to start if a
required secret is missing. Use different credentials and keys for each environment.
| Setting | Meaning |
|---|---|
DEFAULT_BUSINESS_PROFILE_ID |
Profile used when a request doesn't pass ?business_profile_id=. |
SERVICE_API_KEYS |
Comma-separated keys your applications send in X-API-Key (one per app). |
WEBHOOK_FORWARD_URL / WEBHOOK_FORWARD_SECRET |
Optional: push status changes to your app (§7). |
3. Calling this API
- Base URL:
https://<host>/api/v1(locallyhttp://localhost:8000/api/v1). - Auth: header
X-API-Key: <key>on every request. Your app never needs the Marmin Client ID/Secret. This service handles Marmin's HMAC signature and JWT token itself. - Content type:
application/json, UTF-8. - Which company: pass
?business_profile_id=MBP-..., or leave it out to useDEFAULT_BUSINESS_PROFILE_ID. Always pass it if you issue documents for several legal entities. - Request body: identical to Marmin's (§6). Don't send totals; Marmin calculates them.
- Defaults added for you if you leave them out:
profile_execution_id=00000000,document_currency_code=AED,invoice_type_code=380(sales) /389(purchase),credit_note_type_code=381(sales) /261(purchase),endpoint_scheme_id=0235,price.base_quantity=1. - Checked before sending: the body is validated against Marmin's field rules. Mistakes come back immediately as 422 with the exact field path, and nothing is sent to Marmin.
- Pagination:
page(0-based) andsize(max 100). Responses are{content, page_number, total_pages, total_elements}.
curl -X POST "http://localhost:8000/api/v1/sales-invoices" \
-H "X-API-Key: dev-local-key" -H "Content-Type: application/json" \
-d @sales_invoice.json
Ready-made request bodies are in Swagger UI: open an endpoint, click Try it out, and pick from the Examples dropdown.
4. Endpoint catalogue
All paths are relative to /api/v1.
Sales and purchase documents
The same operations exist for each collection:
| Collection | What it is |
|---|---|
/sales-invoices |
Tax invoice you issue to a customer |
/sales-credit-notes |
Credit note against one of your sales invoices |
/purchase-invoices |
Self-billed invoice: you, the buyer, issue it for your supplier |
/purchase-credit-notes |
Self-billed credit note |
| Method | Path | Purpose |
|---|---|---|
POST |
/{collection} |
Create and submit. Returns 201 with the stored document; keep its id (UUID). |
GET |
/{collection} |
List. Filters: document_number, billed_by_name, billed_to_name, billed_by_vat, billed_to_vat, status, document_date, page, size (+ invoice_number on sales credit notes). |
GET |
/{collection}/uuids?created_on=yyyy-MM-dd |
UUIDs created on a date (reconciliation). |
GET |
/{collection}/{id} |
Full document with totals and status. |
GET |
/{collection}/{id}/status |
Simplified status (§5). Only in this service. |
PUT |
/{collection}/{id} |
Correct and resubmit, only when status is VALIDATION_FAILED. Send the complete document. |
GET |
/{collection}/{id}/pdf |
PDF. |
GET |
/{collection}/{id}/xml |
UBL 2.1 XML. |
GET |
/{collection}/{id}/attachments/{attachment_id} |
A supporting attachment you uploaded. |
GET |
/{collection}/{id}/peppol-status |
Detailed Peppol transmission status. |
GET |
/{collection}/{id}/peppol-status-logs |
Peppol event log. |
Other endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/business-profiles/claim/request-otp |
Send claim OTP to the EmaraTax email (valid 2 minutes). |
POST |
/business-profiles/claim |
Claim the profile with the OTP. Returns profile_id. |
GET |
/business-profiles |
List profiles. |
GET / PUT |
/business-profiles/{profile_id} |
Retrieve / update a profile. |
POST |
/proforma-invoices |
Create a proforma (not a tax document). |
POST / PUT |
/proforma-invoices/drafts, /proforma-invoices/drafts/{id} |
Create / update a draft proforma. |
GET |
/proforma-invoices, /uuids, /{id}, /{id}/pdf |
List / UUIDs / retrieve / PDF. |
GET |
/codelists, /codelists/{name} |
Official code values, e.g. tax-categories, uae-subdivisions, unit-of-measure-codes (cached 1 hour). |
POST |
/webhooks/marmin |
Receiver for Marmin webhooks. No API key; checked by signature. |
GET |
/webhook-events, /webhook-events/{id} |
Received webhook events, for polling (§7). |
GET |
/health/marmin |
Checks the Marmin credentials. /health (no auth) is the liveness probe. |
5. Document lifecycle and status
POST ──▶ 201 Created ──▶ PENDING ──▶ APPROVED (done: delivered to buyer + FTA)
│ └──▶ REJECTED (final)
└─▶ VALIDATION_FAILED ──▶ PUT /{id} (fix) ──▶ PENDING ...
GET /{collection}/{id}/status returns:
{
"id": "bd63aefd-df82-430a-858c-ff77108584fe",
"document_number": "INV-2026-0001",
"overall_status": "PENDING",
"participant_status": "PENDING",
"fta_status": null,
"is_final": false,
"can_resubmit": false,
"guidance": "Accepted by Marmin and in Peppol/FTA processing. Wait for a webhook or poll again later.",
"validation_results": []
}
overall_status |
What your app should do |
|---|---|
PENDING |
Show "submitted". Wait for a webhook, or poll every few minutes. |
APPROVED |
Mark as e-invoiced. Download the PDF/XML if needed. |
VALIDATION_FAILED |
Show validation_results[].text, fix the data, then PUT /{id} with the full corrected document. |
REJECTED |
Stop. Review validation_results; usually a new document is needed. |
null |
Not evaluated yet. Treat it as neither success nor failure. |
6. Document fields (Marmin documentation)
Request bodies are exactly Marmin's. Use the official pages below for every field, rule and code value.
Base: docs.ae.marmin.ai — Businesses overview
| Topic | Marmin documentation |
|---|---|
| Sales invoice fields | Create a sales invoice |
| Sales invoice examples (FTZ, export, summary, non-AED, ...) | Sales invoice examples |
| Sales credit note fields | Create a sales credit note |
| Purchase (self-billed) invoice fields | Create a purchase invoice |
| Purchase credit note fields | Create a purchase credit note |
| Proforma invoice fields | Create a proforma invoice |
| Customer (buyer) party | The Customer party object |
| Supplier (seller) party | The Supplier party object |
| Business profiles | About business profiles |
Response fields (totals, meta_info) |
Retrieve a sales invoice |
| Code lists (tax categories, units, emirates, ...) | Code lists. Also available live from this API at GET /api/v1/codelists/{name}. |
| Validation error codes (IBR-xxx-AE) | Common errors |
| Implementation notes | Implementation notes |
Differences from Marmin when calling this service:
* Use our URLs (/api/v1/...) and the X-API-Key header, not Marmin's URLs or tokens.
* business_profile_id goes in the query string (or comes from the default), not the URL path.
* Don't send the party that is you: no accounting_supplier_party on sales documents, no accounting_customer_party
on purchase documents. Marmin fills it from the business profile.
7. Webhooks
7.1 Marmin → this service
Marmin calls POST /api/v1/webhooks/marmin when a document changes state. The event types are
sale.invoice.*, sale.credit_note.*, purchase.invoice.* and purchase.credit_note.* (.create / .update).
Payload details are in Marmin webhooks.
For each webhook, this service:
1. Verifies the X-HMAC-Signature header with MARMIN_WEBHOOK_SECRET (and returns 401 if it's wrong).
2. Ignores duplicates (same webhook_event_id) and stores the event.
3. Replies 200 immediately.
4. Fetches the latest document from Marmin in the background and records its overall_status.
5. Forwards it to your application if WEBHOOK_FORWARD_URL is set.
7.2 This service → your application (optional)
POST {WEBHOOK_FORWARD_URL} with:
{
"event": { "event_type": "sale.invoice.update", "resource_id": "<document uuid>", "webhook_event_id": "...", "...": "..." },
"document": { "...": "full latest document (null if it could not be fetched)" }
}
| Header | Value |
|---|---|
X-EInvoice-Event-Id |
Unique event ID. Use it to ignore duplicates. |
X-EInvoice-Event-Type |
e.g. sale.invoice.update |
X-EInvoice-Signature |
Base64(HMAC-SHA256(WEBHOOK_FORWARD_SECRET, raw body)) |
Reply with any 2xx. Failed deliveries are retried 3 times. Verify the signature over the raw body before parsing it:
import base64, hashlib, hmac
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = base64.b64encode(hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()).decode()
return hmac.compare_digest(expected, signature)
static bool Verify(byte[] rawBody, string signature, string secret)
{
using var h = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToBase64String(h.ComputeHash(rawBody));
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(expected), System.Text.Encoding.UTF8.GetBytes(signature));
}
7.3 Polling instead
If your app can't receive webhooks, poll GET /api/v1/webhook-events?since=<last ISO time>.
Each item has event_type, resource_id, overall_status and the full document.
8. Errors and retries
| HTTP | Source | Body | Action |
|---|---|---|---|
401 |
this service | {"detail": "Missing or invalid X-API-Key header"} |
Fix the API key. |
422 |
this service | {"detail": [{"loc": ["body","document_lines",0,"price","base_amount"], "msg": "..."}]} |
Fix the field in loc. Nothing was sent to Marmin. |
413 |
this service | Payload size should not exceed 8MB |
Shrink attachments. |
400, 403, 404, 409, 5xx |
Marmin | {"error": {"source": "marmin", "status_code": 400, "detail": <Marmin's error>}} |
Read detail and look up the code in Marmin common errors. |
429 |
Marmin | same wrapper, plus Retry-After / X-RateLimit-* headers |
Slow down. |
502 |
this service | {"error": {"source": "marmin", ...}} |
Marmin unreachable or credentials wrong. |
Automatic retries:
* 429 (rate limited) is retried automatically for every request.
* 5xx and network errors are retried for GET / PUT only.
* A create (POST) is never retried on 5xx, so a failure can't produce a duplicate invoice. Before you try again, check
GET /{collection}?document_number=....
Marmin's limits are 60 requests/minute for each document type and 30/minute for business profiles (rate limiting). For bulk uploads, send at most about 1 request per second.
9. Open points to confirm with Marmin
| # | Topic | Current handling |
|---|---|---|
| 1 | ~~Token response format~~ Confirmed in sandbox: {"token": "...", "expires_in": 86400} (24 h). |
Handled; refreshed 60 s before it expires. |
| 2 | Production API base URL | Set MARMIN_BASE_URL in production at go-live. |
| 3 | Whether a fully zero-rated export from a VAT-registered company uses 380 or 480 |
Not checked locally; Marmin validates it. |
| 4 | Proforma retrieve / list-uuids / PDF paths | Assumed to follow the sales invoice pattern. |
| 5 | Sale / Purchase register | Not exposed; Marmin hasn't published these pages. |