Webhooks
Polling answers "is it finished yet"; a webhook removes the asking. Register an HTTPS endpoint and BeCause POSTs a signed notification to it every time one of your bulk requests changes status — the same lifecycle Asynchronous processing describes, pushed to you instead of polled. Certifiers can additionally subscribe to application events, which push movements of the certification applications they own.
Webhooks are configured in the BeCause app at Webhooks, not through the API. Sign in as the profile whose requests you want to hear about: like an API key, a webhook belongs to a profile, and it fires for that profile's bulk requests and applications.
Configuring a webhook
Endpoint URL. HTTPS only, reachable from the public internet — addresses that resolve to private or reserved networks are rejected, as are URLs carrying credentials. Up to 2048 characters.
Topics. Which status changes you want delivered; pick at least one. The topic names are the contract your receiver matches on:
| Topic | Status it reports |
|---|---|
bulk.task.pending | Pending |
bulk.task.scheduled | Scheduled |
bulk.task.in_progress | InProgress |
bulk.task.succeeded | Success |
bulk.task.partially_succeeded | PartialSuccess |
bulk.task.failed | Error |
bulk.task.cancelled | Cancelled |
Two names differ between the topic and the status it carries: bulk.task.succeeded delivers status Success, and bulk.task.failed delivers status Error. Most integrations need only the four terminal topics — succeeded, partially_succeeded, failed and cancelled; the in-flight topics exist for the rare receiver that mirrors progress.
Two further topics, application.status_changed and application.answers_changed, carry certification-application events rather than bulk-request ones — Application events below. They are for certifiers: any profile may subscribe, but they fire only for applications the subscribing profile owns.
Custom header. Optionally one header of your choosing, sent with every delivery — typically your own credential, such as X-Api-Key. The value is write-only once saved. The names webhook-id, webhook-timestamp and webhook-signature are reserved for the signature.
A profile can have up to five webhooks. Each can be disabled and re-enabled without losing its configuration, and the Test button sends a real delivery with type webhook.test so you can verify your receiver end to end — it works even while the webhook is disabled. webhook.test exists only behind that button and is not a topic you can subscribe to.
The payload
Every delivery is a JSON envelope of the same shape:
{
"id": "8f1c0e2a-3b44-4d90-9a1e-7c25f0b8e311",
"type": "bulk.task.succeeded",
"timestamp": "2026-07-27T10:15:03.221Z",
"data": {
"correlationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"taskKind": "BulkProcessingRequest",
"requestType": "BulkGetCompanies",
"status": "Success",
"profileId": 1234,
"apiKeyId": "9c2f1d80-1f4a-4a2e-9d1b-6a0f2b7c4e55"
}
}
The payload is a pointer, not the data. It tells you which request changed and what state it reached; it never carries results. On receipt, call GET /api/v2/bulk/tasks/{correlationId} — that response is the truth, and it is where the result lives once there is one. This is deliberate: the push channel carries nothing sensitive, and its shape stays stable no matter how the result models evolve.
| Field | Meaning |
|---|---|
id | The event id. Stable across retries — your deduplication key — and equal to the webhook-id header. |
type | The topic that matched. |
timestamp | When the event was recorded. Verification uses the webhook-timestamp header, which carries the send time, not this field. |
data.correlationId | What you pass to the task endpoint. |
data.taskKind | BulkProcessingRequest, or CompanyMappingRequest for POST /api/v1/bulk/company-mappings. |
data.requestType | The request type as the task endpoint names it, e.g. BulkGetCompanies. null for company mappings. |
data.status | The status as the task endpoint names it. |
data.profileId | The profile whose request this is. |
data.apiKeyId | The API key that submitted the request. If several integrations share one profile, filter on this. |
Test deliveries reuse the envelope with type webhook.test and data.status Test; their correlationId is the event id itself, and the remaining data fields are null.
Verifying deliveries
Deliveries are signed following Standard Webhooks. Three headers accompany every POST:
| Header | Content |
|---|---|
webhook-id | The event id — the same value as id in the body. |
webhook-timestamp | Unix seconds at the moment of sending. |
webhook-signature | v1, followed by a base64 HMAC-SHA256. May carry several space-separated entries. |
The signature is computed over {id}.{timestamp}.{raw body}, keyed with the bytes your signing secret base64-encodes after its whsec_ prefix. The secret is shown exactly once — when the webhook is created, and again if you rotate it. Losing it means rotating it and updating your receiver.
Verify before you trust: check the signature against the raw request bytes — before any JSON parsing or re-serialization, which can reorder and reformat until the match breaks — compare in constant time, and reject deliveries whose webhook-timestamp is more than five minutes from your clock.
const crypto = require("crypto");
// secret is the whsec_… value shown when the webhook was created or last rotated.
// rawBody must be the request body exactly as received, before any parsing.
function isGenuine(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
return (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Standard Webhooks publishes verification libraries for most stacks if you would rather not hand-roll this.
Application events
A certifier's applications fire two topics of their own. The envelope is the same shape; data narrows to a pointer at the application:
{
"id": "5b7d9c31-8e02-4f6a-b3d5-1a9e6c4f7d20",
"type": "application.status_changed",
"timestamp": "2026-08-27T09:41:07.512Z",
"data": {
"applicationId": "98e3c390-19eb-484c-a9d1-d4f8b4af4839",
"status": "AwaitingApplicationReview",
"profileId": 1234
}
}
| Field | Meaning |
|---|---|
data.applicationId | The application that moved — what you pass to the application endpoints. |
data.status | The status name reached, on application.status_changed. null on answers events. |
data.profileId | The certifier profile that owns the application. |
As with bulk events, the payload is a pointer: on receipt, read the application or its answers — Driving your certification scheme walks the full loop. Two behaviors are specific to these topics:
- Answers events are batched. While a delivery for an application's answers is pending, further answer changes fold into it — an applicant's autosave session arrives as a delivery or two, not a stream. The event means "the answers are different now — re-read them", never "one answer changed".
- Your own API writes echo back. Setting a status or writing answers through the application endpoints publishes to your subscription like any other change; make your receiver idempotent against its own actions.
Delivery and retries
Answer with any 2xx within ten seconds. The response body is ignored, though an excerpt is kept in the delivery log for your debugging. Redirects are not followed — a 3xx counts as a failure — so register the final URL.
A failed delivery is retried on a fixed ladder: one minute after the first failure, then five minutes, thirty minutes, two hours and six hours after each subsequent one — six attempts in all, spanning roughly eight and a half hours. After the sixth failure the delivery is marked failed and nothing further notifies you; repeated failures never disable a webhook by themselves.
Delivery is at least once and unordered. The same event can arrive twice — deduplicate on id — and a terminal event can overtake an in-flight one, so when order matters, let the task endpoint arbitrate rather than trusting arrival order. Dispatch runs about once a minute: near-real-time, not instant.
The app shows a delivery log per webhook — every delivery with its attempts, response codes, latency and payload, kept for thirty days — and failed or stuck deliveries can be re-sent from there. Disabling a webhook also stops anything still queued for it.
Which requests produce webhooks
Application topics fire on every status transition and answer change of an application the subscribing profile owns, wherever the change originates — the BeCause app, the applicant, or the API. For the bulk topics: every status change of a bulk request submitted with an API key — everything under /bulk/, including company mappings. Requests started from the BeCause app rather than the API do not fire webhooks, and the initial Pending a request is created in is not a change, so nothing fires until processing moves it. A request that hits transient infrastructure trouble can be re-queued and legitimately pass through the same in-flight status more than once — one more reason the in-flight topics suit dashboards better than logic.