Overview
Three credentials, three owners
- API Key + API Secret — one 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 Secret — one 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.
Required — the minimum viable integration
| Feature | What it does | Unlocks |
|---|---|---|
| Credential storage — API Key + Secret, per-SME connection secret, webhook secret | 4 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 invoice — POST /v1/partner/invoices | Map 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 endpoint | Accept 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-secret | Verify 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 — enum | Store 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 input | 15-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 detail | Manual trigger to push. Disable when local validation fails. | Operator control — some invoices need review before FTA. |
| Idempotency-Key generation | Stable 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
| Feature | What it does | Unlocks |
|---|---|---|
Polling fallback — GET /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 button — GET /v1/partner/whoami | One-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 button | Only 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 panel | Show 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 handling | Switch 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 isolation | Detect 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 log | Persist 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 filter | Filter chip: TrustBill status Queued / Delivered / Rejected. | Accounting can find everything that needs attention in one click. |
| Dashboard KPI widget | N queued · N delivered today · N rejected today · N stuck > 15 min. | Operator noticies problems before customers do. |
Optional — polish and advanced use-cases
| Feature | What it does | Unlocks |
|---|---|---|
Document-type picker — documentType 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 support — precedingInvoiceReference | When 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 support | For 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 invoice | documentType: "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 action | Accounting-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 post | Toggle 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 dashboard | Deeper 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 labels | Arabic + 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 rejections | Post 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 capability | Features required | Effort |
|---|---|---|
| Push a B2B tax invoice to FTA (the 80% use case) | All Required rows. | ~1 week for a Laravel/Node ERP dev. |
| Handle rejections gracefully | All 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 customers | Above + document-type picker + simplified-invoice type. | +1 day. |
| Handle refunds & adjustments | Above + credit/debit note support + precedingInvoiceReference capture. | +1 day. |
| Month-end mass-catch-up | Above + bulk-submit action + rate-limiter. | +1 day. |
| Production-audit-ready | Everything Recommended + HMAC verification + reserved-TRN CI tests. | +2 days. |
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)
| Screen | Element | What it does |
|---|---|---|
| Settings → TrustBill Integration (system-wide, one per ERP tenant) | Input: TrustBill API Key | Paste the erp_test_... / erp_live_... from Step 1. Store in ERP config. |
| Same screen | Input: TrustBill API Secret (password type) | Paste the one-time secret from Step 1. Store encrypted at rest. |
| Same screen | Button: Test connection | Calls 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 screen | Button: Submit to FTA | Triggers the outbound push (Step 3). Disable until the invoice validates against your existing rules. |
| Invoice detail screen | Status pill: Queued / Delivered / Rejected | Reflects the TrustBill state. Queued right after Step 3's 201; updated by the webhook (Step 4). |
| Invoice detail screen (when rejected) | Rejection reason panel | Show the reason string from the webhook so the SME can fix and retry. |
| Invoice detail screen | Button: Retry submission | Only visible when status is Rejected. Generates a NEW Idempotency-Key and pushes again. |
| Invoice list screen | Filter chip: TrustBill status | Lets 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 globalAPI 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 returnedinvoiceIdagainst 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-secretheader 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→ yourDelivered,rejected→ yourRejected) + storereason. - 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
| Table | Column | Type / purpose |
|---|---|---|
erp_config (single-row) | trustbill_api_key | string — one per ERP install. |
erp_config | trustbill_api_secret_enc | encrypted string — never log the plaintext. |
erp_config | trustbill_webhook_secret_enc | encrypted string — used to verify inbound webhooks. |
companies (or your SME/tenant table) | trustbill_connection_secret_enc | encrypted string, per-SME. Store separately from the global API secret. |
invoices | trustbill_invoice_id | nullable UUID — populated after 201 from Step 3. |
invoices | trustbill_status | enum: queued | delivered | rejected. |
invoices | trustbill_status_reason | nullable string — populated on rejection. |
invoices | trustbill_idempotency_key | string — the key you sent; needed for safe retry. |
trustbill_webhook_log (new) | received_at, invoice_id, status, raw_body | audit 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.timingSafeEqualin Node,hmac.compare_digestin 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.
| What ERP teams call it | What TrustBill calls it |
|---|---|
| Sandbox API key | testApiKey (starts erp_test_...) |
| Sandbox API secret | revealedTestSecret |
| Production API key | liveApiKey (starts erp_live_...) |
| Production API secret | revealedLiveSecret |
| Sandbox / staging environment | "test mode" |
- You fill in the registration form (business info + your webhook URL + your webhook secret).
- The moment you submit, TrustBill mints
testApiKey+revealedTestSecretand returns them in the response. No approval wait, no email, no KYC step. You're building in the next 30 seconds. - You build against test creds — same endpoint as live (
/v1/partner/invoices). - 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+revealedLiveSecretare minted; test creds keep working alongside so nothing breaks mid-transition.
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.
- 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.
- 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-secretheader 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.
Fields
| Field | Required | Notes |
|---|---|---|
erpName | Yes | e.g. "Acme ERP" |
description | Optional | Short description shown to SMEs. |
contactEmail | Yes | Where TrustBill support reaches you. |
website, logoUrl | Optional | Displayed on the SME Sources card. |
companyCount | Optional | Approximate number of SMEs you serve. |
testWebhookUrl | Optional | You 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. |
testWebhookSecret | Optional | You 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— anerp_test_...string you send asX-API-Key.revealedTestSecret— shown exactly once in the response. Store it — you send it asX-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.
- SME opens their TrustBill portal → ERP integration.
- SME clicks Connect on your ERP card.
- TrustBill generates a per-SME secret
tb_secret_<64 hex chars>, shows it once. - 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.
- 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/invoicesHeaders
| Header | Purpose |
|---|---|
X-API-Key | Your ERP's API key from Step 1 (erp_test_... or erp_live_...). |
X-API-Secret | Your ERP's API secret from Step 1. Verified against a scrypt hash. |
x-erp-partner-secret | The tb_secret_... the specific SME pasted in Step 2. This is what identifies the SME. |
Idempotency-Key | Required. Unique per invoice (1–128 chars). Retrying with the same key returns the original result, never a duplicate invoice. |
Content-Type | application/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
| Field | Type | Notes |
|---|---|---|
trn | 15-digit string | Top-level TRN — must match the SME resolved by x-erp-partner-secret. Cross-checked; mismatch → 403 trn_mismatch. |
number | string (1–64) | Unique per (SME, mode). Duplicates return 500 today (planned 409 in a future release). |
issueDate | date YYYY-MM-DD | Date string, not ISO-8601 datetime. Dubai timezone recommended. |
dueDate | date (optional) | Same format as issueDate. |
currency | "AED" | Only AED accepted in Phase 2a. |
invoiceType | "sale" | "purchase" | Defaults to "sale". |
seller.address.line1 | string | Required when address is present. Same for buyer.address.line1. |
seller.address.city | string | Required when address present. |
seller.country, buyer.country | ISO 2-letter | e.g. "AE". Required. |
lines[].unitPrice | decimal AED | Not fils. A price of AED 5,000 is 5000, not 500000. |
lines[].vatRate | fraction 0-1 | Not percent. 5% VAT is 0.05, not 5. |
lines[].taxCategory | enum | standard_5, zero_rated_export, zero_rated_healthcare, zero_rated_education, exempt_financial. |
lines[] | array | 1–200 items. Each requires description, quantity > 0, unitPrice ≥ 0. |
Common validation gotchas
- Never send
nullfor optional fields. Zod on the server accepts absent (undefined) or a valid value, but rejects explicitnull. 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, not5. A rate of5fails the schema (vatRate ≤ 1). - Top-level
trnis the SME's own TRN (the seller for sale invoices). TrustBill resolves yourx-erp-partner-secretto an SME tenant, then comparesbody.trnto that SME's registered TRN. ForinvoiceType: "sale"this equalsseller.trn; forinvoiceType: "purchase"it equalsbuyer.trn. Any other value → 403trn_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.jsonTesting 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
| Field | Meaning |
|---|---|
event | Always "invoice.status_changed" for now. |
erpPartnerId | Your ERP Partner id — the same one GET /v1/consultancy/erp-partner returned. |
invoiceId | TrustBill's UUID for the invoice — matches the invoiceId from the Step 3 response. |
status | "delivered" = FTA/ASP accepted. "rejected" = failed validation or ASP rejection. |
reason | null when delivered. Short reason string when rejected. |
timestamp | ISO-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 ofx-webhook-secretonly 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: errorto 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/whoamiResponse
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.5Verifying 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)
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
| documentType | UBL code | Use when |
|---|---|---|
tax_invoice | 388 | Standard B2B tax invoice (the default). |
commercial_invoice | 380 | Non-VAT commercial invoice. |
simplified_tax_invoice | 751 | B2C sale < AED 10,000. |
credit_note | 381 | Refund / reversal — set precedingInvoiceReference. |
debit_note | 383 | Additional charge — set precedingInvoiceReference. |
self_billed_invoice | 389 | Buyer issues on behalf of the supplier. |
precedingInvoiceReference
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.
| TRN | Behaviour |
|---|---|
100000000000000 | Always 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. |
100000000000666 | Always rejects. Returns 403 test_trn_reject. Lets you verify your ERP handles the error path — retry logic, user-facing error message, dashboard flag. |
erp_live_... and they get the standard TRN-mismatch treatment. There is no way to short-circuit real invoice validation in production.Testing
- Register with test credentials (Step 1). Save
revealedTestSecret. - Stand up a public HTTPS webhook receiver (e.g. webhook.site for a first pass).
- 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. - Sign in as an SME parented to your consultancy, go to ERP integration, click Connect, copy the
tb_secret_.... - From your test harness, do a real
POST /v1/partner/invoicespush with test API credentials + the SME's connection secret. - Watch your webhook for the status change once the (test) ASP responds.
- 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:
| HTTP | Code | When |
|---|---|---|
| 401 | missing_api_credentials | No X-API-Key / X-API-Secret headers. |
| 401 | invalid_api_credentials | API key/secret don't match, or your integration hasn't been approved for live mode. |
| 401 | missing_connection_credential | No x-erp-partner-secret header. |
| 401 | invalid_connection_credential | The tb_secret_... doesn't match any active SME connection. |
| 403 | connection_partner_mismatch | Secret belongs to a different ERP Partner (never mix credentials). |
| 403 | sme_not_connected | SME disconnected your ERP or the connection was revoked. |
| 403 | trn_mismatch | TRN in the body doesn't match the SME who owns the connection secret. |
| 403 | test_trn_reject | v1.5+. You sent the reserved test TRN 100000000000666. Meant to help you test the rejection path. |
| 404 | not_found | v1.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. |
| 400 | idempotency_required | Missing or malformed Idempotency-Key header. |
| 400 | invalid_body | Body failed schema validation. Details in meta.issues. |
Going live
- Finish integration tests end-to-end in test mode.
- On
/erp-partner, click Request live access. Status moves tolive_pending. - A TrustBill admin reviews and either approves (mints
erp_live_...credentials, copies your test webhook config to live) or rejects with a reason. - Switch your ERP to use the live API key/secret. The
x-erp-partner-secretper SME does not change. - Test mode keeps working, so you can regression-test at any time without touching production.
Support
- WhatsApp: +971 52 260 9313
- Email: info@trustbill.ae
- Docs: docs.trustbill.ae