Back to Documentation

UAE FTA E-Invoicing API — ERP Partner Integration Guide

Connect your ERP to TrustBill for UAE FTA e-invoicing compliance · REST API · Webhooks · v1.5

Overview

Three credentials, three owners

  • API Key + API Secretone set per ERP install, API Key + API Secret — one set per ERP install, held by the consultancy or ERP vendor. All SMEs on this ERP share these.
  • Webhook Secretone per ERP install. Webhook Secret — one per ERP install. TrustBill sends it as x-webhook-secret on every status webhook; your ERP verifies it.
  • Connection Secret (tb_secret_…)one per SME. Connection Secret (tb_secret_…) — one per SME. Each SME clicks Connect in their TrustBill portal, gets a unique secret, pastes it into their company settings inside your ERP. Sent as x-erp-partner-secret on every push.

If you architect a "per invoice recipient" or "per customer" secret, you're doing it wrong — the Connection Secret identifies the seller (the SME issuing the invoice), never the buyer.

This guide describes how to integrate your ERP system (Odoo, NetSuite, SAP, custom, etc.) with TrustBill so that your SME clients can submit UAE FTA e-invoices directly from your ERP — without leaving their existing workflow.

The integration is two-way. Your ERP pushes invoices to TrustBill via HTTP POST. TrustBill submits them to the FTA/ASP and calls back to your webhook when the status changes (delivered or rejected).

Who does what

  • Consultancy (you): Consultancy (you): registers the ERP integration once, provides a webhook URL + secret, gets test API credentials, requests live approval.
  • SME (your client): SME (your client): clicks "Connect" on their TrustBill Sources page, gets a per-SME connection secret to paste into your ERP.
  • Your ERP: Your ERP: pushes each invoice to POST /v1/partner/invoices with the API credential + the per-SME connection secret.
  • TrustBill: TrustBill: authenticates the push, submits to the FTA, then POSTs status updates to your webhook URL.

The four-step flow

  Step 1 (once, per consultancy)
  ─────────────────────────────
  Consultancy portal  ──(register ERP + webhook URL + secret)──►  TrustBill
                                     │
                                     └── admin approval → status: live_active

  Step 2 (once, per SME)
  ──────────────────────
  SME TrustBill portal ──(click "Connect")──►  TrustBill
                                     │
                                     └── returns tb_secret_<64hex> (shown ONCE)
                                     │
                                     ▼
                            SME pastes into your ERP settings

  Step 3 (per invoice)
  ────────────────────
  Your ERP ──POST /v1/partner/invoices──►  TrustBill ──►  FTA/ASP
                headers:
                  X-API-Key: erp_live_...
                  X-API-Secret: ...
                  x-erp-partner-secret: tb_secret_...
                  Idempotency-Key: <unique>
                → 201 { invoiceId, number, status: "queued" }

  Step 4 (async, after FTA responds)
  ──────────────────────────────────
  TrustBill ──POST <your_webhook_url>──►  Your ERP
                header:
                  x-webhook-secret: <the secret you registered in Step 1>
                body:
                  { event, erpPartnerId, invoiceId, status, reason, timestamp }

Features — required, recommended, optional

Not every feature has to ship on day 1. This matrix tells your engineering team what the minimum viable integration looks like (ship this and you're FTA-compliant), what makes it production-grade, and what's optional polish. Also lists what capability each feature unlocks so you know what you're trading off if you skip it.

RequiredRequired — day-1 blocker, integration doesn't work without itRecommendedRecommended — needed to survive production incidentsOptionalOptional — polish or advanced use-case

Required — the minimum viable integration

FeatureWhat it doesUnlocks
Credential storage — API Key + Secret, per-SME connection secret, webhook secret4 encrypted-at-rest strings. API Key/Secret + Webhook Secret are one-per-ERP; connection secret is one-per-SME.Auth for every single call.
Submit invoicePOST /v1/partner/invoicesMap local invoice → TrustBill body; send with API creds + connection secret + a unique Idempotency-Key; persist the returned invoiceId.Push invoice to the FTA. Nothing else in the integration matters if this doesn't work.
Status webhook receiver — public HTTPS endpointAccept POST from TrustBill, verify auth (see next row), update local invoice status + reason, return 2xx within 8 s.Real-time status without polling. Without it, invoices stay Queued in your UI forever.
Webhook auth — one of: HMAC (v1.5+) OR plain x-webhook-secretVerify the caller is genuinely TrustBill. Constant-time compare or HMAC-SHA256; reject with 401 otherwise.Security. Without this an attacker who guesses your URL can forge status updates into your ERP.
Local invoice status field — enumStore queued / delivered / rejected on the local invoice row. Populate at Step 3 (queued) and Step 4 (delivered/rejected).Accounting can see what's FTA-compliant. Filters & reports depend on this.
SME's own TRN — settings input15-digit field in company/SME setup. This is what goes in top-level body.trn — must exactly match the TRN registered on trustbill.ae for the same company.Passes TrustBill's TRN-vs-SME cross-check. Any mismatch → 403.
Submit button on invoice detailManual trigger to push. Disable when local validation fails.Operator control — some invoices need review before FTA.
Idempotency-Key generationStable per (invoice, attempt). Pattern: <erp-tenant-id>-<local-invoice-id>-<attempt>. Store the key you sent so a retry uses the same one.Safe retries — TrustBill de-dupes; you never create duplicate invoices on network flakes.

Recommended — needed to survive production

FeatureWhat it doesUnlocks
Polling fallbackGET /v1/partner/invoices/{id} (v1.5)Scheduled job every ~15 min that fetches status for anything stuck in queued for > 5 min. Also a manual "Sync now" button.Survival when webhooks are lost — TrustBill fires once, no retry. Without polling, one dropped webhook = permanently stuck invoice.
HMAC signature verify (v1.5)Verify x-webhook-signature header (HMAC-SHA256 over timestamp + body) with a 5-min replay window. Fall back to plain secret only when HMAC absent.Replay-safe production security. Auditors will ask.
Test connection buttonGET /v1/partner/whoamiOne-click check from Settings that shows mode (test/live) + connected SME count + partner name.Saves 30-min "why doesn't it work?" debug sessions per install.
Retry-on-rejection buttonOnly visible when status = Rejected. Generates a NEW Idempotency-Key and pushes again.Recovery path when a rejection was due to fixable data (wrong TRN, missing address). Without it, operator has to duplicate the invoice.
Rejection reason panelShow the reason string from the webhook on the invoice detail screen so the SME can fix + retry without contacting support.Faster time-to-fix; reduces support load.
Structured error handlingSwitch on TrustBill's code field in 4xx responses: invalid_body → show validation errors; trn_mismatch → tell SME to fix TRN; sme_not_connected → tell them to reconnect on trustbill.ae.Human-friendly errors instead of "500 Server Error".
Test vs Live mode isolationDetect key prefix (erp_test_ vs erp_live_) and surface which mode is active in the ERP UI. Ideally allow separate test-mode credentials in a QA install.Safe QA. Prevents "we thought we were on test but pushed to production" incidents.
Webhook audit logPersist every inbound webhook (raw body, headers, signature-ok flag, timestamp). 90-day retention minimum.Support triage. When someone reports "invoice X wasn't marked delivered", you can prove whether TrustBill ever called.
Invoice list status filterFilter chip: TrustBill status Queued / Delivered / Rejected.Accounting can find everything that needs attention in one click.
Dashboard KPI widgetN queued · N delivered today · N rejected today · N stuck > 15 min.Operator noticies problems before customers do.

Optional — polish and advanced use-cases

FeatureWhat it doesUnlocks
Document-type pickerdocumentType field (v1.5)Dropdown on invoice detail: Tax Invoice / Simplified Tax Invoice / Credit Note / Debit Note / Commercial Invoice / Self-Billed. Sent as documentType in the body.Full UBL taxonomy. Without this you can only push standard tax_invoice (388) — fine for most B2B, insufficient for refunds/simplified/self-billed.
Credit / debit note supportprecedingInvoiceReferenceWhen user creates a credit or debit note, capture which original invoice it amends, send as precedingInvoiceReference. Required by FTA for amendments.Legally-valid refunds and adjustments. Without it, the FTA rejects amendments.
Simplified tax invoice — B2C supportFor sales < AED 10,000 to non-VAT-registered buyers. Set documentType: "simplified_tax_invoice". Buyer TRN not required.B2C sales. Skip if your ERP only serves B2B.
Self-billed invoicedocumentType: "self_billed_invoice" — buyer generates the invoice on the supplier's behalf. Rare but required in some industries (utilities, agriculture).Industry-specific. Skip unless you serve utility/telecom/agri customers.
Bulk-submit actionAccounting-staff shortcut: multi-select 50 invoices → "Push all to TrustBill" → generates a job that submits with rate-limiting.Month-end catch-up. Otherwise operators click Submit 50 times.
Auto-submit on invoice postToggle in settings: "Push to TrustBill immediately when invoice is posted." Off by default — some SMEs want manual review.Zero-touch compliance for high-volume SMEs.
Analytics dashboardDeeper view: 7/30/90-day delivery rate, top rejection reasons, avg time-to-delivered per SME.Insight for consultancies managing many SMEs.
Multi-language settings labelsArabic + English side-by-side on the TrustBill Integration settings screen (most UAE ERPs already do this).Local-market fit. Optional if your ERP is English-only.
Slack/Teams webhook for rejectionsPost to a channel when invoice status flips to rejected, so ops sees it without checking the dashboard.Proactive incident response.
Reserved-TRN test suite (v1.5)Automated tests using TRN 100000000000000 (always accepts) and 100000000000666 (always rejects) so CI can verify both paths without real SME data.Regression-proof deploys. Highly recommended for any partner shipping updates weekly.

Capability matrix — at a glance

Business capabilityFeatures requiredEffort
Push a B2B tax invoice to FTA
(the 80% use case)
All Required rows.~1 week for a Laravel/Node ERP dev.
Handle rejections gracefullyAll Required + rejection panel + retry button + structured error switch.+2 days.
Survive an incident (webhook down, network flake)Above + polling fallback + webhook audit log + dashboard KPI.+2 days.
Serve B2C customersAbove + document-type picker + simplified-invoice type.+1 day.
Handle refunds & adjustmentsAbove + credit/debit note support + precedingInvoiceReference capture.+1 day.
Month-end mass-catch-upAbove + bulk-submit action + rate-limiter.+1 day.
Production-audit-readyEverything Recommended + HMAC verification + reserved-TRN CI tests.+2 days.
Recommended MVP roadmap: Recommended MVP roadmap: ship all Required rows in week 1 → ship all Recommended rows in week 2 → cherry-pick Optional rows in weeks 3-4 based on your SMEs' actual use cases. Total time-to-production for a mid-size ERP team: ~3 weeks.

What your ERP team must build

TrustBill provides the API + portal. Your ERP team owns everything on the ERP side to complete the two-way sync. Here's the checklist — screens, buttons, backend workers, and data model changes — grouped by the direction they serve.

1. Screens & buttons (inside your ERP)

ScreenElementWhat it does
Settings → TrustBill Integration (system-wide, one per ERP tenant)Input: TrustBill API KeyPaste the erp_test_... / erp_live_... from Step 1. Store in ERP config.
Same screenInput: TrustBill API Secret (password type)Paste the one-time secret from Step 1. Store encrypted at rest.
Same screenButton: Test connectionCalls GET https://trustbill.ae/api/v1/partner/ping with the credentials, shows OK/failed.
Company/Customer/Client setup (per SME the ERP serves)Input: TrustBill Connection Secret (password type)Paste the tb_secret_... the SME copied from their TrustBill portal in Step 2. Store encrypted, per-SME.
Invoice detail screenButton: Submit to FTATriggers the outbound push (Step 3). Disable until the invoice validates against your existing rules.
Invoice detail screenStatus pill: Queued / Delivered / RejectedReflects the TrustBill state. Queued right after Step 3's 201; updated by the webhook (Step 4).
Invoice detail screen (when rejected)Rejection reason panelShow the reason string from the webhook so the SME can fix and retry.
Invoice detail screenButton: Retry submissionOnly visible when status is Rejected. Generates a NEW Idempotency-Key and pushes again.
Invoice list screenFilter chip: TrustBill statusLets accounting staff surface everything queued/rejected quickly.

2. Direction 1 — ERP → TrustBill (outbound, invoice submission)

An HTTP client in your ERP that fires when the SME clicks Submit to FTA:

  • Look up the SME's stored TrustBill Connection Secret (from company settings) and your global API Key/Secret.
  • Generate a stable Idempotency-Key — recommended: <erp-tenant-id>-<local-invoice-id>-<attempt>. Same key = same result; a new attempt increments the suffix.
  • Map your local invoice schema to TrustBill's body shape (see Step 3 body example).
  • POST to /v1/partner/invoices. Handle 2xx → save the returned invoiceId against your local invoice, set local status = Queued.
  • Handle 4xx with a clear UI message (see Error responses section). 5xx / network failures: retry with the SAME Idempotency-Key (safe — TrustBill de-dupes).
  • Log every attempt (request id, HTTP status, response body) for support triage.

3. Direction 2 — TrustBill → ERP (inbound, status sync)

A public HTTPS endpoint on your ERP that TrustBill POSTs to when the FTA/ASP responds:

  • Expose an HTTPS endpoint — the URL you registered as testWebhookUrl (Step 1). No auth token; auth is via header.
  • On every call: constant-time compare x-webhook-secret header to the secret you registered. Reject with 401 otherwise.
  • Parse the JSON body → look up your local invoice by TrustBill's invoiceId. If not found, log & 200 (don't reject; another region might replay).
  • Update local status (delivered → your Delivered, rejected → your Rejected) + store reason.
  • Return 2xx quickly (< 8 s). Slow work: enqueue an internal job.
  • Make it idempotent — if TrustBill re-sends the same invoiceId+status, don't double-apply (compare against your last stored status).

4. Data model additions

TableColumnType / purpose
erp_config (single-row)trustbill_api_keystring — one per ERP install.
erp_configtrustbill_api_secret_encencrypted string — never log the plaintext.
erp_configtrustbill_webhook_secret_encencrypted string — used to verify inbound webhooks.
companies (or your SME/tenant table)trustbill_connection_secret_encencrypted string, per-SME. Store separately from the global API secret.
invoicestrustbill_invoice_idnullable UUID — populated after 201 from Step 3.
invoicestrustbill_statusenum: queued | delivered | rejected.
invoicestrustbill_status_reasonnullable string — populated on rejection.
invoicestrustbill_idempotency_keystring — the key you sent; needed for safe retry.
trustbill_webhook_log (new)received_at, invoice_id, status, raw_bodyaudit trail. Retention: 90 days min.

5. Security & ops

  • Never log secrets — API secret, connection secret, webhook secret. Scrub them from request/response logs.
  • HTTPS everywhere — TrustBill rejects http:// webhook URLs and won't follow redirects.
  • Constant-time compare for x-webhook-secret (e.g. crypto.timingSafeEqual in Node, hmac.compare_digest in Python).
  • Rate-limit your outbound POSTs to avoid tripping TrustBill's ingest limiter under a mass-send.
  • Metric + alert on: 4xx rate on push, webhook signature mismatches, invoices stuck in queued > 15 min.
  • Rotation — when an SME regenerates their connection secret, invalidate the old one in your ERP and prompt them to re-paste.

6. Nice-to-haves

  • "Sync now" button on the invoice that manually re-fetches status via GET /v1/invoices/:id (fallback for missed webhooks).
  • Dashboard widget: N invoices queued, N delivered today, N rejected today.
  • Bulk-submit action for accounting staff to push a filtered list of invoices at once (respect rate limits + generate distinct idempotency keys).
  • Test-mode toggle in ERP settings so QA can use erp_test_... credentials without touching live customer data.

Step 1 — Register your ERP

Register your ERP as a partner with TrustBill. This is a one-time operation per consultancy. You'll get API credentials and configure your webhook URL.

If you're used to integrating with an Accredited Service Provider (ASP) directly, you'd normally email the ASP, wait days for Sandbox keys, then set up your webhook after. TrustBill inverts that.

Terminology mapping
What ERP teams call itWhat TrustBill calls it
Sandbox API keytestApiKey (starts erp_test_...)
Sandbox API secretrevealedTestSecret
Production API keyliveApiKey (starts erp_live_...)
Production API secretrevealedLiveSecret
Sandbox / staging environment"test mode"
How the flow actually works
  1. You fill in the registration form (business info + your webhook URL + your webhook secret).
  2. The moment you submit, TrustBill mints testApiKey + revealedTestSecret and returns them in the response. No approval wait, no email, no KYC step. You're building in the next 30 seconds.
  3. You build against test creds — same endpoint as live (/v1/partner/invoices).
  4. When outbound + webhook are working end-to-end, click Request Live. A TrustBill admin reviews (only human step in the whole flow) and either approves or rejects with a reason. On approval, liveApiKey + revealedLiveSecret are minted; test creds keep working alongside so nothing breaks mid-transition.
"Can I get Sandbox keys first, before setting up my webhook?"

Yes. Webhook URL and Webhook Secret are optional on the registration form. You can register with them empty, get test creds instantly, build and validate outbound first, then come back to /erp-partner and add the webhook config later (“Webhook & connectivity test” card). Click Test integration to confirm TrustBill can reach your receiver.

Sign in to the consultancy portal and go to /erp-partner. Fill in the register form once. Test-mode credentials are minted instantly — no approval needed to start building.

Who provides the Webhook URL & Secret?

You do — the ERP partner. TrustBill doesn't hand these out. Your ERP owns the receiver endpoint (URL) and picks the shared secret. TrustBill just stores them and sends them back on every status callback so your ERP can verify the call actually came from TrustBill.

Webhook URL — the public HTTPS endpoint your ERP exposes
  • Must be reachable from the public internet (no localhost, no private IPs).
  • Format: https://your-erp.com/<any-path>. Common patterns: /webhooks/trustbill, /api/trustbill/webhook.
  • For the Cloud ERP shipped by TrustBill, this is fixed: https://erp.trustbill.ae/trustbill/webhook — the addon registers that route.
  • For a self-hosted ERP (Odoo, NetSuite, custom), you pick the path and wire it to your inbound webhook handler.
Webhook Secret — a strong random string you invent
  • Generate one now, e.g. openssl rand -hex 32 (256 bits of entropy). Any strong random string works — TrustBill doesn't care about format.
  • Save the same value in two places: paste it into this registration form AND into your ERP's webhook-verification setting (the value your ERP will constant-time compare against the x-webhook-secret header on every inbound call).
  • Store encrypted at rest inside your ERP. Never log the plaintext. Never expose it in client-side code.
  • If it ever leaks, generate a new one and update both sides — TrustBill's Webhook Secret can be rotated any time from the dashboard.
Analogy
Same pattern as GitHub / Stripe / Slack webhooks: you pick the URL, you pick the secret. The platform (TrustBill here) just echoes your secret back on every call so you can prove it came from them.

Fields

FieldRequiredNotes
erpNameYese.g. "Acme ERP"
descriptionOptionalShort description shown to SMEs.
contactEmailYesWhere TrustBill support reaches you.
website, logoUrlOptionalDisplayed on the SME Sources card.
companyCountOptionalApproximate number of SMEs you serve.
testWebhookUrlOptionalYou provide. Public HTTPS URL of the endpoint YOUR ERP exposes to receive status updates. Cloud ERP by TrustBill: https://erp.trustbill.ae/trustbill/webhook. Others: your own path. Can be added later from the dashboard.
testWebhookSecretOptionalYou invent. Generate with openssl rand -hex 32. TrustBill echoes it back on every webhook as the x-webhook-secret header. Save the same value in your ERP's webhook verifier. Envelope-encrypted at rest.

What you get back

  • testApiKey — an erp_test_... string you send as X-API-Key.
  • revealedTestSecret — shown exactly once in the response. Store it — you send it as X-API-Secret. TrustBill only stores its scrypt hash; regenerate if lost.
  • status: "test" — you can start integration testing immediately.

Going live

When testing is done, click Request live access. A TrustBill admin reviews it. On approval you get a liveApiKey/liveWebhookSecret pair; your test credentials keep working the whole time so nothing breaks mid-transition.

Step 2 — SME connects

Once you're registered (test or live), every SME parented to your consultancy sees your ERP as a card on their TrustBill Sources page.

  1. SME opens their TrustBill portal → ERP integration.
  2. SME clicks Connect on your ERP card.
  3. TrustBill generates a per-SME secret tb_secret_<64 hex chars>, shows it once.
  4. SME copies it and pastes it into your ERP's "TrustBill Integration" settings — this is the secret your ERP will send on every invoice push.
  5. SME can rotate the secret at any time from the same page (invalidates the old one immediately).

Why per-SME: your API credential proves the caller is your ERP, but only tb_secret_... proves the push belongs to a specific SME. TrustBill uses it (not the TRN in the body) to resolve which SME the invoice belongs to; the TRN is cross-checked as defense-in-depth.

Step 3 — Push invoices

When an SME clicks "Submit to FTA" inside your ERP, your ERP POSTs the invoice to TrustBill. TrustBill validates auth + TRN, persists a draft, auto-queues submission to the FTA/ASP, and returns 201 with the invoice ID.

Endpoint

POST https://trustbill.ae/api/v1/partner/invoices

Headers

HeaderPurpose
X-API-KeyYour ERP's API key from Step 1 (erp_test_... or erp_live_...).
X-API-SecretYour ERP's API secret from Step 1. Verified against a scrypt hash.
x-erp-partner-secretThe tb_secret_... the specific SME pasted in Step 2. This is what identifies the SME.
Idempotency-KeyRequired. Unique per invoice (1–128 chars). Retrying with the same key returns the original result, never a duplicate invoice.
Content-Typeapplication/json

Body

Invoice data plus the SME's 15-digit TRN (cross-checked against the connection secret). Real, working example (copy-paste against a test partner):

{ "trn": "100000000000042", "number": "INV-2026-0001", "issueDate": "2026-08-01", "dueDate": "2026-08-15", "currency": "AED", "invoiceType": "sale", "seller": { "name": "TrustBill Test Co LLC", "trn": "100000000000003", "country": "AE", "address": { "line1": "Sheikh Zayed Road", "city": "Dubai", "emirate": "Dubai" }, "kind": "seller" }, "buyer": { "name": "Acme Trading LLC", "trn": "100000000000042", "country": "AE", "address": { "line1": "Marina Plaza", "city": "Dubai", "emirate": "Dubai" }, "kind": "buyer" }, "lines": [ { "description": "Consulting service, August", "quantity": 1, "unitPrice": 5000, "vatRate": 0.05, "taxCategory": "standard_5" } ], "documentType": "tax_invoice" }

Field reference

FieldTypeNotes
trn15-digit stringTop-level TRN — must match the SME resolved by x-erp-partner-secret. Cross-checked; mismatch → 403 trn_mismatch.
numberstring (1–64)Unique per (SME, mode). Duplicates return 500 today (planned 409 in a future release).
issueDatedate YYYY-MM-DDDate string, not ISO-8601 datetime. Dubai timezone recommended.
dueDatedate (optional)Same format as issueDate.
currency"AED"Only AED accepted in Phase 2a.
invoiceType"sale" | "purchase"Defaults to "sale".
seller.address.line1stringRequired when address is present. Same for buyer.address.line1.
seller.address.citystringRequired when address present.
seller.country, buyer.countryISO 2-lettere.g. "AE". Required.
lines[].unitPricedecimal AEDNot fils. A price of AED 5,000 is 5000, not 500000.
lines[].vatRatefraction 0-1Not percent. 5% VAT is 0.05, not 5.
lines[].taxCategoryenumstandard_5, zero_rated_export, zero_rated_healthcare, zero_rated_education, exempt_financial.
lines[]array1–200 items. Each requires description, quantity > 0, unitPrice ≥ 0.

Common validation gotchas

  • Never send null for optional fields. Zod on the server accepts absent (undefined) or a valid value, but rejects explicit null. If you don't have an email, omit the key — don't send "email": null.
  • Fils vs AED. Line prices are decimal AED. Sending 500000when you mean AED 5,000 will make a very expensive invoice.
  • Percent vs fraction. Standard VAT is 0.05, not 5. A rate of 5 fails the schema (vatRate ≤ 1).
  • Top-level trn is the SME's own TRN (the seller for sale invoices). TrustBill resolves your x-erp-partner-secret to an SME tenant, then compares body.trn to that SME's registered TRN. For invoiceType: "sale" this equals seller.trn; forinvoiceType: "purchase" it equals buyer.trn. Any other value → 403 trn_mismatch.
  • Date format for issueDate/dueDate. Send "2026-08-01", not "2026-08-01T00:00:00Z".

Success response

HTTP/1.1 201 Created { "invoiceId": "…", "number": "INV-2026-0001", "status": "queued" }

Example (curl)

curl -X POST https://trustbill.ae/api/v1/partner/invoices \ -H "X-API-Key: erp_live_..." \ -H "X-API-Secret: ..." \ -H "x-erp-partner-secret: tb_secret_..." \ -H "Idempotency-Key: acme-inv-2026-0001" \ -H "Content-Type: application/json" \ -d @invoice.json

Testing the body shape locally

Before wiring your ERP client, POST the body directly with curl against the test API. If it returns 201, your mapping is correct. If it returns 400 with meta.issues, each issue names the exact JSON path that failed — e.g. "buyer.email: invalid_type" means you sent null where the schema wants an omitted key. Fix the mapper before touching the queue/HTTP client code.

Step 4 — Receive status

When the FTA/ASP responds, TrustBill POSTs a status change to the webhook URL you configured in Step 1. Fire-and-forget: TrustBill sends once, doesn't retry. If you miss one, you can always poll the status via your ERP's "Sync now" button.

Request

POST <your webhook URL> x-webhook-secret: <the secret you registered in Step 1> Content-Type: application/json { "event": "invoice.status_changed", "erpPartnerId": "…", "invoiceId": "…", "status": "delivered" | "rejected", "reason": null | "asp_rejected: <fingerprint>" | "validation_failed: …", "timestamp": "2026-08-01T12:34:56.789Z" }

Fields

FieldMeaning
eventAlways "invoice.status_changed" for now.
erpPartnerIdYour ERP Partner id — the same one GET /v1/consultancy/erp-partner returned.
invoiceIdTrustBill's UUID for the invoice — matches the invoiceId from the Step 3 response.
status"delivered" = FTA/ASP accepted. "rejected" = failed validation or ASP rejection.
reasonnull when delivered. Short reason string when rejected.
timestampISO-8601 UTC.

Handler checklist

  • v1.5+: verify x-webhook-signature (HMAC) with the recipe in the HMAC section. Fall back to a constant-time compare of x-webhook-secret only when the HMAC header is absent (pre-v1.5 sender).
  • Return HTTP 200 (or any 2xx) quickly. TrustBill uses an 8s timeout; long processing should happen async.
  • Process idempotently — if TrustBill re-sends the same invoiceId + status (e.g. after a reconfigure), don't double-apply.
  • Follow redirects yourself if needed — TrustBill sends with redirect: error to avoid SSRF, so it will not auto-follow 3xx.
  • Missed a webhook? Poll GET /v1/partner/invoices/{id}as your fallback (see Polling section) — TrustBill fires webhooks once, no automatic retry.

Polling fallback — v1.5+ v1.5+

When your webhook is down, mid-migration, or you just want a sanity check, poll the status directly. Same auth as the POST — API Key + Secret plus the SME's TRN in the header.

Endpoint

GET https://trustbill.ae/api/v1/partner/invoices/{invoiceId}

Headers

X-API-Key: erp_live_... X-API-Secret: ... x-erp-partner-secret: tb_secret_...

Success response

HTTP/1.1 200 OK { "invoiceId": "88132d4d-56f0-4721-8a3a-079ac8d4a709", "number": "INV-2026-0001", "status": "delivered", "reason": null, "createdAt": "2026-08-04T05:19:21.403Z", "updatedAt": "2026-08-04T05:19:23.078Z" }

Same status vocabulary as the webhook. Reuse your webhook handler's switch statement; only the delivery mechanism differs. Statuses you'll see:queued, transformed, validated, blocked_no_credit, delivered, rejected. Both reason and humanMessage are surfaced from the last status change event.

404 semantics

HTTP/1.1 404 Not Found { "type": "https://docs.trustbill.ae/errors/not_found", "title": "Not Found", "status": 404, "code": "not_found", "detail": "No invoice with that id exists under your connected SME.", "request_id": "..." }

Same 404 response whether the id doesn't exist OR belongs to a different SME. This is intentional — we don't leak whether an ID exists under a partner you're not authorized for. Use detail for user-facing text.

whoami — introspection v1.5+ v1.5+

Quick sanity check for your ERP: "am I on test or live, and how many SMEs are actively connected right now?" Used by the "Test connection" button in the Cloud ERP addon.

Endpoint

GET https://trustbill.ae/api/v1/partner/whoami

Response

HTTP/1.1 200 OK { "partnerId": "9c8f06bf-00f6-4df6-be58-343ce48a1a86", "erpName": "Acme ERP", "status": "live_active", "mode": "live", "connectedSmes": 17, "lastTestAt": "2026-08-04T05:19:21.397Z", "timestamp": "2026-08-04T10:00:00.000Z" }

No secrets are ever returned. Safe to expose the response to your internal ops dashboard.

x-webhook-secret: <the secret you registered in Step 1> ← legacy x-webhook-timestamp: <unix seconds, e.g. 1725441234> ← v1.5 x-webhook-signature: t=<unix>,v1=<HMAC-SHA256(t + "." + body, secret) hex> ← v1.5 x-webhook-signature: t=<unix>,v1=<HMAC-SHA256(t + "." + body, secret) base64> ← v1.5

Verifying in your ERP (Node.js)

import { createHmac, timingSafeEqual } from "node:crypto"; const MAX_SKEW_SECONDS = 300; // 5 minutes function verifyWebhook(req, storedSecret) { const header = req.headers["x-webhook-signature"]; if (!header) return false; const parts = Object.fromEntries( header.split(",").map(s => s.trim().split("=", 2)) ); const t = Number(parts.t); const sig = parts.v1; if (!t || !sig) return false; if (Math.abs(Date.now() / 1000 - t) > MAX_SKEW_SECONDS) return false; const rawBody = req.rawBody.toString(); // exact bytes as received const expected = createHmac("sha256", storedSecret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(sig, "hex"); return a.length === b.length && timingSafeEqual(a, b); }

Verifying in PHP

$header = $request->header('x-webhook-signature'); $parts = []; foreach (explode(',', $header) as $seg) { [$k, $v] = explode('=', trim($seg), 2); $parts[$k] = $v; } $t = (int) ($parts['t'] ?? 0); $sig = $parts['v1'] ?? ''; if (abs(time() - $t) > 300) return abort(401); $expected = hash_hmac('sha256', $t . '.' . $request->getContent(), $storedSecret); if (!hash_equals($expected, $sig)) return abort(401);

Prefer HMAC over the legacy header. If HMAC verification fails, reject the request — do NOT silently fall back to x-webhook-secret. That would let an attacker who intercepted a plain-secret call replay it forever. Fall-back to plain-secret is only appropriate when the HMAC header is entirely absent (indicating a pre-v1.5 caller).

Cloud ERP addon (Workdo Dash)

If you use the official Cloud ERP addon (TrustBillConnector), HMAC verification is built-in from v1.5. Just upload the ZIP — nothing else to configure.

Document types v1.5+ v1.5+

The optional documentType field in the POST body maps to the FTA/UBL invoice type codes. Defaults to tax_invoice (UBL 388) when omitted, so you don't need this for basic B2B.

Supported values

documentTypeUBL codeUse when
tax_invoice388Standard B2B tax invoice (the default).
commercial_invoice380Non-VAT commercial invoice.
simplified_tax_invoice751B2C sale < AED 10,000.
credit_note381Refund / reversal — set precedingInvoiceReference.
debit_note383Additional charge — set precedingInvoiceReference.
self_billed_invoice389Buyer issues on behalf of the supplier.

precedingInvoiceReference

Optional string (1–64 chars). Required when documentType is credit_note or debit_note. Value: the invoiceId or number of the invoice you're amending.

Reserved test TRNs v1.5+ v1.5+

Only honoured when your API key is in test mode (erp_test_...). Use these to exercise happy-path and rejection-path code in your ERP without needing real SME data.

TRNBehaviour
100000000000000Always accepts. Skips the TRN cross-check against the SME row — useful for smoke tests where you don't want to set up an SME just to prove your HTTP client works. Still requires a valid x-erp-partner-secret.
100000000000666Always rejects. Returns 403 test_trn_reject. Lets you verify your ERP handles the error path — retry logic, user-facing error message, dashboard flag.
Live mode ignores these constants. Send them toerp_live_... and they get the standard TRN-mismatch treatment. There is no way to short-circuit real invoice validation in production.

Testing

  1. Register with test credentials (Step 1). Save revealedTestSecret.
  2. Stand up a public HTTPS webhook receiver (e.g. webhook.site for a first pass).
  3. On the ERP Partner dashboard, click Test integration. TrustBill will hit your webhook once with a sample payload (test: true) and confirm the round-trip.
  4. Sign in as an SME parented to your consultancy, go to ERP integration, click Connect, copy the tb_secret_....
  5. From your test harness, do a real POST /v1/partner/invoices push with test API credentials + the SME's connection secret.
  6. Watch your webhook for the status change once the (test) ASP responds.
  7. Check the Recent activity log on the ERP Partner dashboard — every ping, webhook test, and outcome is recorded.

Error responses

Errors follow RFC 7807 (problem+json). Common codes on POST /v1/partner/invoices:

HTTPCodeWhen
401missing_api_credentialsNo X-API-Key / X-API-Secret headers.
401invalid_api_credentialsAPI key/secret don't match, or your integration hasn't been approved for live mode.
401missing_connection_credentialNo x-erp-partner-secret header.
401invalid_connection_credentialThe tb_secret_... doesn't match any active SME connection.
403connection_partner_mismatchSecret belongs to a different ERP Partner (never mix credentials).
403sme_not_connectedSME disconnected your ERP or the connection was revoked.
403trn_mismatchTRN in the body doesn't match the SME who owns the connection secret.
403test_trn_rejectv1.5+. You sent the reserved test TRN 100000000000666. Meant to help you test the rejection path.
404not_foundv1.5+ (on GET /v1/partner/invoices/{id}). Either the id doesn't exist, or it belongs to a different SME. detail field carries "No invoice with that id exists under your connected SME." — safe to display. Same response either way to avoid ID enumeration.
400idempotency_requiredMissing or malformed Idempotency-Key header.
400invalid_bodyBody failed schema validation. Details in meta.issues.

Going live

  1. Finish integration tests end-to-end in test mode.
  2. On /erp-partner, click Request live access. Status moves to live_pending.
  3. A TrustBill admin reviews and either approves (mints erp_live_... credentials, copies your test webhook config to live) or rejects with a reason.
  4. Switch your ERP to use the live API key/secret. The x-erp-partner-secret per SME does not change.
  5. Test mode keeps working, so you can regression-test at any time without touching production.

Support