Webhooks
Receive real-time notifications when document statuses change
BizCARE can call your server whenever a submitted document's status changes — for example when an invoice is validated or rejected by LHDN — so you don't have to poll.
How webhooks work
When a document's status changes, BizCARE sends an HTTP POST to each of your active
endpoints that subscribe to the matching event. Your endpoint should respond with a
2xx status to acknowledge receipt. Non-2xx responses (or timeouts) are retried with
exponential backoff (up to 5 attempts).
Requirements for your endpoint
- HTTPS in production. Plain
http://URLs are refused when the API runs in production. - Public hosts only. BizCARE refuses
localhost, private and reserved IP addresses, and hostnames ending in.local,.localhostor.internal. URLs with a username or password are refused too. - No redirects. BizCARE does not follow them. A redirect response counts as a failed delivery.
- Answer within 10 seconds with a
2xx. A slower or non-2xxanswer counts as a failure. - Up to 5 attempts. After a failure BizCARE retries with exponential backoff, starting at 5 seconds. Make your handler idempotent.
- Signing secret. It starts with
whsec_and is shown once.
Events
| Event | Fires when |
|---|---|
document.submitted | A document is accepted into LHDN for processing (initial submission). |
document.valid | LHDN validated the document. |
document.invalid | LHDN rejected the document (see failReason). |
document.cancelled | The document was cancelled. |
Subscribe an endpoint to specific events, or to * to receive all of them.
Environments
Every payload carries an environment field ("test" or "live"). An endpoint may be scoped
to one environment with its own environment setting:
"live"or"test"— the endpoint receives only that environment's events.null— the endpoint receives both, and you branch on the payload'senvironment.
When you create an endpoint through the API and omit environment, it defaults to the
environment of the API key making the call, so one key per environment gives you one
endpoint per environment. Pass "environment": null explicitly to receive both. Endpoints
created in the dashboard receive both unless you set one.
Payload
{
"event": "document.valid",
"documentId": "0f9c…",
"documentType": "INVOICE",
"invoiceId": "3b1e…",
"consolidatedInvoiceId": null,
"adjustmentNoteId": null,
"invoiceCode": "INV-0042",
"externalId": "don_8841",
"externalIdPlatform": "donorcare",
"environment": "live",
"status": "Valid",
"submissionUid": "HJSDHSA…",
"uuid": "F9D425…",
"longId": "YPN1…",
"failReason": null,
"timestamp": "2026-06-30T08:00:00.000Z"
}documentId— the submitted-document id in BizCARE.invoiceId/consolidatedInvoiceId/adjustmentNoteId— the BizCARE document the event is about. Exactly one is set; the others arenull.invoiceCode— the document's own code, for every document type (invoice, adjustment note, consolidated invoice).externalId,externalIdPlatform— your own reference (see API reference). They arenullunless the document is an invoice.status— the raw LHDN status;eventis its mapped form.failReason— populated fordocument.invalid, otherwisenull.
Verifying the signature
Each request includes five headers:
X-Webhook-Event: document.valid
X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw request body> # legacy, body only
X-Webhook-Signature-V2: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>"> # verify this one
X-Webhook-Delivery-Id: 8c1f6a5e-… # stable across retries of one delivery
X-Webhook-Timestamp: 1782806400 # unix seconds, set on every attemptX-Webhook-Signature covers the body only, so its timestamp can be forged on a captured delivery.
Verify X-Webhook-Signature-V2 instead: it signs the X-Webhook-Timestamp value and the body
together. Use X-Webhook-Delivery-Id to dedupe retries. "Send test" pings carry the same headers.
Compute HMAC-SHA256("<X-Webhook-Timestamp>.<rawRequestBody>", yourSigningSecret) using the
timestamp header string exactly as received, and compare (constant-time) to the hex value after
sha256=. The signing secret is shown once when you create or regenerate
an endpoint — store it securely.
import { createHmac, timingSafeEqual } from "node:crypto";
function isValid(
rawBody: string,
timestampHeader: string,
signatureV2Header: string,
secret: string
): boolean {
const expected = createHmac("sha256", secret)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
const got = signatureV2Header.replace(/^sha256=/, "");
return (
got.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected))
);
}// Reject stale or future-dated deliveries. Retries re-stamp (and re-sign) on each attempt, so
// a generous 1 hour past window is safe; allow only ~5 minutes of clock skew into the future.
function isFresh(timestampHeader: string): boolean {
const ts = Number(timestampHeader);
if (!Number.isFinite(ts)) return false;
const age = Date.now() / 1000 - ts;
return age <= 3600 && age >= -300;
}Always verify the signature against the raw request body before processing, and reject requests with a missing or mismatched signature or a missing or stale timestamp.
Treat a webhook as a signal, not the source of truth. On receipt, verify it, then re-read
the document (GET /invoices/:id, whose latestSubmission holds the current LHDN status)
instead of trusting the payload's status. Deliveries can arrive more than once and out of order.
Managing endpoints through the API
Register and manage endpoints with your API key on /api/v1/webhooks — no dashboard needed:
| Method | Path | Purpose |
|---|---|---|
GET | /webhooks | List endpoints |
POST | /webhooks | Create (url, events, optional isActive, environment); the response carries the signing secret once |
GET | /webhooks/:id | Get one (secret masked as secretHint) |
PUT | /webhooks/:id | Update url, events, isActive, environment |
DELETE | /webhooks/:id | Delete |
POST | /webhooks/:id/regenerate-secret | Issue a new secret (shown once; the old one stops working) |
GET | /webhooks/:id/deliveries | Delivery log. limit is 1 to 100 (default 25). before is an ISO date cursor. Returns { deliveries, nextCursor }. |
Events are document.submitted, document.valid, document.invalid, document.cancelled, or
"*". document.cancelled fires for every cancellation (invoice, adjustment note, and a
reverted consolidation run); document.submitted fires for invoice, adjustment-note and
consolidated submissions.
Configuration
You can also manage endpoints in the BizCARE dashboard under Settings → Webhooks: set the URL, pick the
events, and copy the signing secret (shown once). Use Send test to deliver a sample
document.valid event (with environment: "test") so you can confirm your receiver verifies
the signature and returns 2xx — it appears in the endpoint's delivery log like any real event.
Delivery is retried with exponential backoff if your endpoint is temporarily unavailable, so make your handler idempotent — a document may be delivered more than once.