Webhooks
Receive signed HTTP notifications when invoices are reported, paid, received and more.
Webhooks push events to an https endpoint you control. Create them in the app under API access → Webhooks or with the API (scope webhooks:manage).
What an invoice event carries
Every invoice.* payload has the invoice header (id, number, type, status, dates, currency, totals, buyer, isTest, metadata, externalId) and two blocks that let a receiver act without calling back:
validation:validityand the blockingerrors[]from the last check, each withrule,field,messageand a publicdocsUrl.rejection: oninvoice.rejected, thestage(taxilla,buyerorfta), the provider'serrortext, thesubmissionIdand when it happened;nullotherwise.
For the explained version of each error (API field path, English and Arabic explanation and fix) call GET /invoices/{id}/validation.
Create a subscription
curl -X POST "$BASE/webhooks" -H "X-API-KEY: $KEY" -H "Content-Type: application/json" -d '{
"name": "ERP sync",
"targetUrl": "https://erp.example.com/hooks/einvoicing",
"events": ["invoice.reported", "invoice.rejected", "invoice.paid", "incoming.validated"],
"headers": { "Authorization": "Bearer my-inbound-token" }
}'
The response includes secret (whsec_…) once. Use ["*"] to subscribe to every event. Targets must be public https hosts; private networks, localhost and cloud metadata addresses are rejected, and DNS answers pointing to private ranges are refused at delivery time.
Events
| Event | When |
|---|---|
invoice.created | An invoice, credit note or debit note was created (draft or validated) |
invoice.sent | Emailed or logged as sent |
invoice.submitted | Handed to the ASP |
invoice.delivered | Accepted by the buyer's access point |
invoice.reported | Accepted by the FTA (status accepted) |
invoice.rejected | Rejected by the ASP, the buyer's access point or the FTA |
invoice.paid | Payments cover the full amount |
invoice.overdue | The daily job flagged the invoice as overdue |
invoice.deleted | A draft was deleted |
validation.completed | An API-triggered validation finished (validate: true or POST …/validate) |
incoming.created, incoming.validated, incoming.accepted, incoming.rejected, incoming.paid | Received documents |
quote.created, quote.sent, order_confirmation.created, proforma.sent, reminder.created, reminder.sent, document.sent | Other documents |
customer.created, product.created | Catalogue |
payment_link.paid | A pay-by-link payment was confirmed by the gateway (data.invoiceId, data.paymentId, data.provider) |
bank.transaction_matched | A bank transaction was matched to an invoice or supplier invoice and a payment recorded |
connector.synced | An accounting connector finished a sync (data.provider, counts, errors) |
GET /webhooks/events returns the same list with descriptions.
Delivery format
POST /hooks/einvoicing HTTP/1.1
Content-Type: application/json
User-Agent: Webhook-Delivery/1.0
X-Webhook-Event: invoice.paid
X-Webhook-Delivery: 7c0d… (unique per delivery attempt series)
X-Webhook-Event-Id: 9b1e… (unique per event; the same for every subscriber)
X-Webhook-Attempt: 1
X-Webhook-Signature: t=1700000000,v1=5f1a…
{
"id": "9b1e…",
"event": "invoice.paid",
"createdAt": "2026-09-23T10:15:00Z",
"organizationId": "…",
"data": {
"id": "3f2e…", "invoiceNumber": "INV-0042", "status": "accepted", "paymentStatus": "paid",
"total": 6100, "paidAmount": 6100, "currency": "AED",
"buyer": { "id": "…", "name": "Buyer LLC", "trn": "100000000000011" }, "isTest": false
}
}
Respond with any 2xx within 10 seconds. Redirects are not followed.
Verify the signature
v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with your secret. Reject timestamps older than five minutes to prevent replays.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Use the raw request body, not a re-serialised JSON object.
Retries and monitoring
Failed deliveries (non-2xx, timeout, connection error) are retried after at least 1 min, 5 min, 30 min, 2 h and 12 h (a job sends due retries every 5 minutes); after six attempts the delivery is marked failed. A webhook that accumulates 25 exhausted deliveries in a row is paused with a disabledReason; fix the endpoint and set isActive: true to resume.
| Route | Purpose |
|---|---|
GET /webhooks/{id}/deliveries?status=failed | Delivery log with attempts, response status, error, duration |
GET /webhooks/{id}/deliveries/{deliveryId} | One delivery including its payload |
POST /webhooks/{id}/deliveries/{deliveryId}/retry | Retry now |
POST /webhooks/{id}/test | Send a webhook.test event and return the result |
POST /webhooks/{id}/rotate-secret | New signing secret |
Deliveries are idempotent from your side when you key on X-Webhook-Event-Id: the same event may be delivered more than once after a retry.
How delivery runs
Events are written to an outbox by database triggers the moment the change is committed. A worker pushes them out immediately after API and app actions (submission, sending, imports, payments) and every minute from the scheduler, so a delivery normally arrives within seconds and never later than a minute.