My App

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, .localhost or .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-2xx answer 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

EventFires when
document.submittedA document is accepted into LHDN for processing (initial submission).
document.validLHDN validated the document.
document.invalidLHDN rejected the document (see failReason).
document.cancelledThe 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's environment.

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 are null.
  • invoiceCode — the document's own code, for every document type (invoice, adjustment note, consolidated invoice).
  • externalId, externalIdPlatform — your own reference (see API reference). They are null unless the document is an invoice.
  • status — the raw LHDN status; event is its mapped form.
  • failReason — populated for document.invalid, otherwise null.

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 attempt

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

MethodPathPurpose
GET/webhooksList endpoints
POST/webhooksCreate (url, events, optional isActive, environment); the response carries the signing secret once
GET/webhooks/:idGet one (secret masked as secretHint)
PUT/webhooks/:idUpdate url, events, isActive, environment
DELETE/webhooks/:idDelete
POST/webhooks/:id/regenerate-secretIssue a new secret (shown once; the old one stops working)
GET/webhooks/:id/deliveriesDelivery 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.

On this page