Docs / API reference

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.

EventSent whenFields after the document fields
document.issuedA document is issued. Receipts are not included; they arrive with payment.confirmed. Documents imported from another system do not send it.None
document.sentA 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.viewedThe 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.confirmedA 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.claimedThe 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.validLHDN validated an e-invoice.uuid, longId, validatedAt
einvoice.invalidLHDN, or Invoa's checks before submission, rejected an e-invoice.errors (list of code, message, target, propertyPath)
einvoice.cancelledAn 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.

FieldTypeDescription
documentIdstringDocument ID.
typestringinvoice, quotation, receipt and so on.
statusstringStatus after the action, for example sent or paid.
numberstringDocument number, for example INV-26-0042.
contactId, contactNamestringThe customer.
currencystring3-letter code, usually MYR.
total, paidAmount, creditedAmount, balanceintegerSen. creditedAmount is the total of issued credit notes against the invoice; balance = total − paidAmount − creditedAmount.
issueDate, dueDatedate or nullYYYY-MM-DD.
einvoiceStatusstringLHDN status, none if not submitted.
hostedUrlstringThe customer's page for the document.

Payload

http
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
json
{
  "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"
  }
}
FieldDescription
idDelivery ID. The same value as the x-invoa-delivery header.
eventEvent name.
tenantIdThe Invoa company the event belongs to.
createdAtWhen the event happened (UTC).
dataEvent fields, listed in Events.
HeaderDescription
x-invoa-eventEvent name.
x-invoa-deliveryDelivery ID. Stays the same when a delivery is retried.
x-invoa-timestampUnix time in seconds when this attempt was signed.
x-invoa-signaturev1= followed by the hex HMAC-SHA256 signature.

Verify the signature

Every delivery is signed with the endpoint's secret (whsec_…):

http
x-invoa-signature: v1=hex( HMAC-SHA256( secret, "<x-invoa-timestamp>.<raw request body>" ) )

To verify:

  1. Read the raw request body as text, before any JSON parsing. Parsing and re-serialising changes the bytes and breaks the signature.
  2. Reject the request if x-invoa-timestamp is more than 5 minutes from your clock. This blocks replays.
  3. Compute the HMAC-SHA256 of timestamp + "." + rawBody with your secret, as lowercase hex, and prefix v1=.
  4. Compare it with x-invoa-signature in constant time.

Node.js, with no dependencies:

ts
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:

ts
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 2xx status 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 exhausted and not retried again.
  • Deliveries to an endpoint that has been disabled are dropped.
  • Your endpoint must use https://. Plain http:// is accepted only for localhost, 127.0.0.1 and [::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

MethodPathScope
GET/webhooksdocuments:read
POST/webhooksdocuments:draft
GET/webhooks/{id}documents:read
PATCH/webhooks/{id}documents:draft
DELETE/webhooks/{id}documents:draft

The endpoint object

json
{
  "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"
}
FieldTypeDescription
idstringEndpoint ID.
urlstringWhere events are sent.
eventsarray of stringsEvents this endpoint receives.
activebooleanDisabled endpoints receive nothing.
secretPreviewstringThe start and end of the signing secret, for recognising it. The full secret is returned only when the endpoint is created.

List endpoints

http
GET /api/v1/webhooks
bash
curl https://app.invoa.io/api/v1/webhooks \
  -H "Authorization: Bearer $INVOA_API_KEY"
json
{
  "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

http
POST /api/v1/webhooks
FieldTypeRequiredDescription
urlstringYesAn https:// URL.
eventsarray of stringsYesAt least one event name from the list above.
activebooleanNoDefault true.
bash
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"]
  }'
json
{
  "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

http
GET /api/v1/webhooks/{id}

Returns the endpoint and its last 50 deliveries, newest first.

json
{
  "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

http
PATCH /api/v1/webhooks/{id}

Send any of url, events (replaces the whole list) and active.

bash
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

http
DELETE /api/v1/webhooks/{id}
bash
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.

Something unclear or out of date? Tell us at hello@invoa.io and we'll fix the page.