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?" Useful for a "Test connection" button in your ERP settings.
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).
More endpoints v2
All endpoints share the same auth (X-API-Key + X-API-Secret + x-erp-partner-secret) and per-partner rate limit (120 req/min). Successful responses carry x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset; a 429 also sets retry-after.
Machine-readable specs
Import the Postman JSON directly into Postman/Insomnia, or pipe the OpenAPI file intoopenapi-generator / orval / oazapftsto generate a typed client in your language. Both files are regenerated from the catalog on every release.
GET /v1/partner/validate-trn
Pre-flight FTA registry check. Cached 24 h. No x-erp-partner-secret required.
curl -sS 'https://trustbill.ae/api/v1/partner/validate-trn?trn=100000000000003' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...'
# → { "trn": "100000000000003", "status": "verified",
# "legalName": "ACME Trading LLC", "cachedAt": "2026-09-16T09:12:04.000Z" }
POST /v1/partner/customers & GET :externalRef
Upsert a buyer under the connected SME. Idempotent on (tenant, externalRef). Same body on subsequent POSTs updates the existing row.
curl -sS -X POST 'https://trustbill.ae/api/v1/partner/customers' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...' \
-H 'Idempotency-Key: cust-upsert-001' \
-H 'Content-Type: application/json' \
-d '{
"externalRef": "acme-2024-001",
"name": "ACME Trading LLC",
"kind": "buyer",
"country": "AE",
"trn": "100000000000003",
"email": "billing@acme.example",
"address": { "line1": "Office 501", "city": "Dubai", "emirate": "Dubai", "country": "AE" }
}'
# Read it back:
curl -sS 'https://trustbill.ae/api/v1/partner/customers/acme-2024-001' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...'
POST /v1/partner/invoices/bulk
Submit up to 100 invoices per call. Returns HTTP 207 with a per-item result array. Per-item idempotency keys are derived as {batchKey}:{index}, to recover from a partial failure, resend the original body (same items, same order, same batch key). The API replays each item's stored result deterministically. Retrying a subset with a fresh index range will 409 on hash mismatch. Bulk requests have a server-side deadline (~55 s); items not attempted before it hits returnbulk_deadline_exceeded and are safe to retry the same way.
curl -sS -X POST 'https://trustbill.ae/api/v1/partner/invoices/bulk' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...' \
-H 'Idempotency-Key: nightly-2026-09-16' \
-H 'Content-Type: application/json' \
-d '{
"invoices": [
{
"externalRef": "acme-1001",
"trn": "100000000000003",
"number": "INV-2026-1001",
"issueDate": "2026-09-16",
"currency": "AED",
"seller": { "name": "Your SME Legal Name", "trn": "100000000000003", "country": "AE" },
"buyer": { "name": "ACME", "trn": "100000000000003", "country": "AE" },
"lines": [{ "description": "Item", "quantity": 1, "unitPrice": 100, "vatRate": 0.05 }]
}
]
}'
# → HTTP 207
# { "results": [ { "index": 0, "externalRef": "acme-1001",
# "ok": true, "invoiceId": "…", "number": "INV-2026-1001", "status": "queued" } ],
# "summary": { "total": 1, "ok": 1, "failed": 0 } }
DELETE /v1/partner/invoices/{id}
Cancel a pre-delivery invoice. Returns 204 on success. Post-delivery invoices (delivered /acknowledged) return 409 with the "use a credit note" hint.
curl -sS -X DELETE 'https://trustbill.ae/api/v1/partner/invoices/<id>' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...' -i
POST /v1/partner/invoices/{id}/retry
Retry a rejected / blocked_no_credit invoice. Same cap the SME UI uses (3 per hour per invoice). Returns 202 with the requeued invoice.
curl -sS -X POST 'https://trustbill.ae/api/v1/partner/invoices/<id>/retry' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...' \
-H 'Idempotency-Key: retry-<id>-01' \
-H 'Content-Type: application/json' -d '{}'
POST /v1/partner/invoices/{id}/replay-webhook
Force a fresh webhook delivery for the invoice. New row in the queue, refreshed timestamp so the HMAC stays inside your anti-replay window. Original attempts stay in the audit log.
curl -sS -X POST 'https://trustbill.ae/api/v1/partner/invoices/<id>/replay-webhook' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...'
# → 202 { "deliveryId": "…", "status": "pending" }
GET /v1/partner/invoices/{id}/webhook-deliveries
Audit trail. Every attempt, last HTTP status, last error, next scheduled retry. Terminal states:delivered, exhausted. Max 100 rows.
curl -sS 'https://trustbill.ae/api/v1/partner/invoices/<id>/webhook-deliveries' \
-H 'X-API-Key: erp_test_...' \
-H 'X-API-Secret: ...' \
-H 'x-erp-partner-secret: tb_secret_...'
# → { "data": [
# { "deliveryId": "…", "status": "delivered", "event": "invoice.status_changed",
# "mode": "test", "attempts": 1, "maxAttempts": 6,
# "lastResponseStatus": 200, "lastError": null,
# "nextAttemptAt": "…", "deliveredAt": "…", "createdAt": "…", "updatedAt": "…" }
# ] }
Every outbound webhook is persisted to a durable queue and retried with backoff (30 s / 2 m / 10 m / 30 m / 1 h / 3 h) before being marked exhausted. Partners on signature_version = "hmac" only ever seex-webhook-signature + x-webhook-timestamp; the legacy x-webhook-secret header is not emitted.