Webhooks
Receive signed Invoa webhook events for issued, sent and viewed documents, payments, claims and e-invoice results, and verify each signature.
Webhooks let Invoa tell your system when something happens, such as an invoice being paid or an e-invoice being validated by LHDN, so you do not have to poll. Invoa sends an HTTPS POST with a JSON body to each endpoint subscribed to the event, signed with that endpoint's secret.
You can manage endpoints in Settings → Webhooks (owner or admin) or through the API below.
Events
Each event is sent once per business action, however it happened: in the app, by Claude, through the API, by a billing schedule or by Invoa's background jobs.
| Event | Sent when | Fields after the document fields |
|---|---|---|
document.issued | A document is issued. Receipts are not included; they arrive with payment.confirmed. Documents imported from another system do not send it. | None |
document.sent | A document is emailed to the customer (including receipt emails, but not payment reminders), or marked as sent in the app by sending it on WhatsApp or copying its link. Sent when the send is requested; the email itself goes out in the background. | channel (email, whatsapp, link or other), to (email addresses; empty when not emailed) |
document.viewed | The customer opens the document's hosted page. Repeat views from the same IP address within an hour are not sent again. | first (true for the first view of the document) |
payment.confirmed | A payment is confirmed, from any source: recorded in the app, by Claude, through the API, a confirmed customer claim or CHIP. | paymentId, amount, method, source, receivedAt, reference, receiptId, receiptNumber |
payment.claimed | The customer reports a bank transfer with I have paid by bank transfer on the hosted page. Confirm or reject the claim in the app; confirming it sends payment.confirmed. | claimId, amount, paidAt, reference, hasProof |
einvoice.valid | LHDN validated an e-invoice. | uuid, longId, validatedAt |
einvoice.invalid | LHDN, or Invoa's checks before submission, rejected an e-invoice. | errors (list of code, message, target, propertyPath) |
einvoice.cancelled | An e-invoice was cancelled at LHDN. | uuid, reason |
Amounts are in sen. GET /webhooks also returns the full list of event names you can subscribe to.
Document fields
Every event's data starts with the same fields describing the document as it is after the action. For payment events this is the invoice that was paid.
| Field | Type | Description |
|---|---|---|
documentId | string | Document ID. |
type | string | invoice, quotation, receipt and so on. |
status | string | Status after the action, for example sent or paid. |
number | string | Document number, for example INV-26-0042. |
contactId, contactName | string | The customer. |
currency | string | 3-letter code, usually MYR. |
total, paidAmount, creditedAmount, balance | integer | Sen. creditedAmount is the total of issued credit notes against the invoice; balance = total − paidAmount − creditedAmount. |
issueDate, dueDate | date or null | YYYY-MM-DD. |
einvoiceStatus | string | LHDN status, none if not submitted. |
hostedUrl | string | The customer's page for the document. |
Payload
POST /hooks/invoa HTTP/1.1
Host: lembahbayu.example
content-type: application/json
user-agent: Invoa-Webhooks/1.0
x-invoa-event: payment.confirmed
x-invoa-delivery: de100000-0000-4000-8000-000000000001
x-invoa-timestamp: 1760941872
x-invoa-signature: v1=5d41402abc4b2a76b9719d911017c592ae2b6e1c5f0a8d3c7e9b1f2a4c6d8e0f{
"id": "de100000-0000-4000-8000-000000000001",
"event": "payment.confirmed",
"tenantId": "7e0a0000-0000-4000-8000-000000000001",
"createdAt": "2026-10-20T06:31:12.000Z",
"data": {
"documentId": "d0c00000-0000-4000-8000-000000000042",
"type": "invoice",
"status": "paid",
"number": "INV-26-0042",
"contactId": "c0a10000-0000-4000-8000-000000000007",
"contactName": "Aina Rahman",
"currency": "MYR",
"total": 135000,
"paidAmount": 135000,
"creditedAmount": 0,
"balance": 0,
"issueDate": "2026-10-15",
"dueDate": "2026-11-14",
"einvoiceStatus": "none",
"hostedUrl": "https://app.invoa.io/d/example-public-token",
"paymentId": "9a700000-0000-4000-8000-000000000018",
"amount": 135000,
"method": "bank_transfer",
"source": "manual",
"receivedAt": "2026-10-20",
"reference": "IBG 20261020-0012",
"receiptId": "d0c00000-0000-4000-8000-000000000043",
"receiptNumber": "RC-26-0018"
}
}| Field | Description |
|---|---|
id | Delivery ID. The same value as the x-invoa-delivery header. |
event | Event name. |
tenantId | The Invoa company the event belongs to. |
createdAt | When the event happened (UTC). |
data | Event fields, listed in Events. |
| Header | Description |
|---|---|
x-invoa-event | Event name. |
x-invoa-delivery | Delivery ID. Stays the same when a delivery is retried. |
x-invoa-timestamp | Unix time in seconds when this attempt was signed. |
x-invoa-signature | v1= followed by the hex HMAC-SHA256 signature. |
Verify the signature
Every delivery is signed with the endpoint's secret (whsec_…):
x-invoa-signature: v1=hex( HMAC-SHA256( secret, "<x-invoa-timestamp>.<raw request body>" ) )To verify:
- Read the raw request body as text, before any JSON parsing. Parsing and re-serialising changes the bytes and breaks the signature.
- Reject the request if
x-invoa-timestampis more than 5 minutes from your clock. This blocks replays. - Compute the HMAC-SHA256 of
timestamp + "." + rawBodywith your secret, as lowercase hex, and prefixv1=. - Compare it with
x-invoa-signaturein constant time.
Node.js, with no dependencies:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyInvoaWebhook(
rawBody: string,
headers: Headers,
secret: string,
toleranceSeconds = 300,
): boolean {
const signature = (headers.get("x-invoa-signature") ?? "").trim();
const timestamp = Number(headers.get("x-invoa-timestamp"));
if (!signature || !Number.isFinite(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const expected =
"v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}Using it in a Next.js route handler:
export async function POST(request: Request) {
const rawBody = await request.text();
if (!verifyInvoaWebhook(rawBody, request.headers, process.env.INVOA_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody) as { id: string; event: string; data: Record<string, unknown> };
// Record event.id first; skip it if you have processed it before.
// Then queue the work and answer quickly.
return new Response("ok");
}The TypeScript SDK has the same check built in as verifyWebhookRequest.
Delivery and retries
- Answer with any
2xxstatus within 15 seconds. Anything else, or no answer, counts as a failure. - Failed deliveries are retried up to 8 attempts in total, waiting 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and 6 hours between attempts. After the eighth failure the delivery is marked
exhaustedand not retried again. - Deliveries to an endpoint that has been disabled are dropped.
- Your endpoint must use
https://. Plainhttp://is accepted only forlocalhost,127.0.0.1and[::1], for local testing.
Handle events idempotently. Retries reuse the same delivery id, and a delivery your endpoint accepted but answered too slowly is sent again. Key your processing on the delivery id, or on the business ID inside data, such as paymentId.
Delivery history, the last error and a Send test ping button are in Settings → Webhooks. The test ping sends an event named ping with data { "message": "Hello from Invoa", "at": "<timestamp>" }, whatever the endpoint is subscribed to. You can also rotate an endpoint's signing secret there; the old secret stops working immediately.
Manage endpoints
| Method | Path | Scope |
|---|---|---|
GET | /webhooks | documents:read |
POST | /webhooks | documents:draft |
GET | /webhooks/{id} | documents:read |
PATCH | /webhooks/{id} | documents:draft |
DELETE | /webhooks/{id} | documents:draft |
The endpoint object
{
"id": "e4d00000-0000-4000-8000-000000000001",
"tenantId": "7e0a0000-0000-4000-8000-000000000001",
"url": "https://lembahbayu.example/hooks/invoa",
"events": ["payment.confirmed", "einvoice.valid", "einvoice.invalid"],
"active": true,
"secretPreview": "whsec_AbCd…wXyZ",
"createdAt": "2026-10-15T03:10:00.000Z",
"updatedAt": "2026-10-15T03:10:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | string | Endpoint ID. |
url | string | Where events are sent. |
events | array of strings | Events this endpoint receives. |
active | boolean | Disabled endpoints receive nothing. |
secretPreview | string | The start and end of the signing secret, for recognising it. The full secret is returned only when the endpoint is created. |
List endpoints
GET /api/v1/webhookscurl https://app.invoa.io/api/v1/webhooks \
-H "Authorization: Bearer $INVOA_API_KEY"{
"data": [
{
"id": "e4d00000-0000-4000-8000-000000000001",
"url": "https://lembahbayu.example/hooks/invoa",
"events": ["payment.confirmed", "einvoice.valid", "einvoice.invalid"],
"active": true,
"secretPreview": "whsec_AbCd…wXyZ"
}
],
"events": [
"document.issued",
"document.sent",
"document.viewed",
"payment.confirmed",
"payment.claimed",
"einvoice.valid",
"einvoice.invalid",
"einvoice.cancelled"
]
}This list is not paginated.
Create an endpoint
POST /api/v1/webhooks| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | An https:// URL. |
events | array of strings | Yes | At least one event name from the list above. |
active | boolean | No | Default true. |
curl https://app.invoa.io/api/v1/webhooks \
-H "Authorization: Bearer $INVOA_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://lembahbayu.example/hooks/invoa",
"events": ["payment.confirmed", "einvoice.valid", "einvoice.invalid"]
}'{
"data": {
"id": "e4d00000-0000-4000-8000-000000000001",
"url": "https://lembahbayu.example/hooks/invoa",
"events": ["payment.confirmed", "einvoice.valid", "einvoice.invalid"],
"active": true,
"secretPreview": "whsec_AbCd…wXyZ"
},
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}Returns 201 Created. Store secret now: it is not shown again.
Errors: 400 validation for a non-HTTPS URL, an unknown event name or an empty events list.
Retrieve an endpoint
GET /api/v1/webhooks/{id}Returns the endpoint and its last 50 deliveries, newest first.
{
"data": { "id": "e4d00000-0000-4000-8000-000000000001", "url": "https://lembahbayu.example/hooks/invoa", "active": true },
"deliveries": [
{
"id": "de100000-0000-4000-8000-000000000001",
"endpointId": "e4d00000-0000-4000-8000-000000000001",
"endpointUrl": "https://lembahbayu.example/hooks/invoa",
"event": "payment.confirmed",
"payload": { "event": "payment.confirmed", "data": { "paymentId": "9a700000-0000-4000-8000-000000000018" } },
"status": "delivered",
"attempts": 1,
"lastError": null,
"nextAttemptAt": null,
"deliveredAt": "2026-10-20T06:31:13.000Z",
"createdAt": "2026-10-20T06:31:12.000Z",
"updatedAt": "2026-10-20T06:31:13.000Z"
}
]
}Delivery status is pending, delivered, failed (will be retried at nextAttemptAt) or exhausted. Errors: 404 not_found.
Update an endpoint
PATCH /api/v1/webhooks/{id}Send any of url, events (replaces the whole list) and active.
curl -X PATCH https://app.invoa.io/api/v1/webhooks/e4d00000-0000-4000-8000-000000000001 \
-H "Authorization: Bearer $INVOA_API_KEY" \
-H "content-type: application/json" \
-d '{ "active": false }'Returns { "data": <endpoint object> }. Errors: 400 validation, 404 not_found.
Delete an endpoint
DELETE /api/v1/webhooks/{id}curl -X DELETE https://app.invoa.io/api/v1/webhooks/e4d00000-0000-4000-8000-000000000001 \
-H "Authorization: Bearer $INVOA_API_KEY"Returns 204 No Content. The endpoint and its delivery history are removed. Errors: 404 not_found.
Related
Something unclear or out of date? Tell us at hello@invoa.io and we'll fix the page.