Webhooks

Verify webhook signatures from raw bytes, accept events durably, and handle retries and delivery diagnostics.

Build a receiver that authenticates the exact request bytes, accepts work durably, and can safely see the same event again. BusinessMode retries delivery; your receiver must still be idempotent.

Start with a BusinessMode Project, a public HTTPS receiver, and server-side storage for the endpoint's signing secret. You do not need a particular framework, a public SDK, or an internal BusinessMode package. The example below uses only standard Node APIs.

From domain change to durable acceptance
  1. BusinessModeBusiness mutation → Outbox eventEligible Webhook Endpoint → Webhook Delivery
  2. Signed HTTPS POSTExact raw body + timestampStable Event ID and Delivery ID
  3. Your receiverVerify → validate → deduplicateDurably enqueue/process → return 2xx

1. Configure an endpoint and store its secret

A Project developer creates an endpoint with a name and url, either in the developer product or through Create webhook endpoint. Management operations use DeveloperBearer with current direct Project authority. The receiver authenticates incoming deliveries using its signing secret, not a developer JWT or machine API key. See Authentication for those distinct boundaries.

The URL must be absolute HTTPS, at most 2048 characters, with no surrounding whitespace, embedded username/password, or fragment. Ordinary query parameters are allowed. Use the final receiver URL directly; do not rely on an HTTP-to-HTTPS or path redirect.

Creation validates URL shape, not reachability. It does not prove DNS, public-address eligibility, TLS, or a successful receiver response. During delivery, BusinessMode resolves and validates the destination and connects to a validated public address. Separate attempts need not resolve to the same IP. Local/private destinations are not supported webhook receivers.

The creation response includes the full signingSecret, formatted as whsec_ followed by 43 base64url characters. This is one-time management credential material: securely transfer it into your receiver's server-side secret storage. List webhook endpoints and subsequent management reads expose only secretPrefix, not a retrievable full secret. Never place the secret in source control, browser code, mobile apps, frontend environment variables, logs, or analytics.

Endpoint creation requires an Idempotency-Key. If its response is lost, retry the same request with the same key. Within durable response retention, replay can return the original creation response, including the signing secret; it is not a general secret-retrieval API. Current Project authority still gates replay. See Idempotency & retries.

Which events reach the endpoint?

Fanout uses the domain event's occurredAt, not the time the dispatcher eventually runs. An endpoint is eligible when:

  • endpoint.createdAt <= event.occurredAt; and
  • it has no disable time, or event.occurredAt < endpoint.disabledAt.

A new endpoint receives no historical backfill for pre-creation events. There is no event-subscription configuration or topic selector. Eligible endpoints receive supported Project webhook events, so implement a forward-compatible receiver rather than assuming a permanently closed event list.

2. Understand the signed HTTP request

BusinessMode sends an HTTPS POST with a JSON event envelope. This illustrative envelope shows the structure, not an exhaustive event schema or a complete seat.assigned payload:

Example code
{
    "id": "00000000-0000-4000-8000-000000000001",
    "type": "seat.assigned",
    "version": 1,
    "occurredAt": "2026-09-26T12:00:00.000Z",
    "resource": {
        "type": "seat_assignment",
        "id": "00000000-0000-4000-8000-000000000002"
    },
    "data": {}
}
  • id: stable domain Event ID.
  • type: event type.
  • version: event payload version, meaningful when interpreting data.
  • occurredAt: when the domain event occurred, not when this attempt was sent.
  • resource: primary resource type and ID.
  • data: event-specific JSON object; it is not one universal schema.

Current production producers use version 1. That is not a promise that every future event will. Representative event names include organization.created, license.updated, seat.assigned, invitation.redeemed, and access_code.redeemed; these examples are not a closed catalog.

The current request headers are:

Example code
Content-Type: application/json
Content-Length: <UTF-8 body byte length>
User-Agent: businessmode-webhooks/1.0
BusinessMode-Event-Id: <event ID>
BusinessMode-Delivery-Id: <endpoint delivery ID>
BusinessMode-Event-Type: <event type>
BusinessMode-Event-Version: <event version>
BusinessMode-Signature: t=<unix-seconds>,v1=<base64url-hmac>

Event headers duplicate envelope values for operational convenience. They do not replace signature verification. Use the verified body as the authoritative event; check duplicated header values against it if you consume them. The HMAC covers the timestamp and body, not the other headers. In particular, Delivery ID is transport metadata, not a separately HMAC-bound field. Use the verified body id when your security/business policy requires a signed deduplication identity.

3. Verify the exact raw bytes

The signature header is BusinessMode-Signature, with the current format t=<unix-seconds>,v1=<base64url-hmac>. The digest uses HMAC-SHA256.

The key is not the literal UTF-8 whsec_... string. Validate the prefix and the 43-character base64url suffix, decode the suffix, and confirm it is exactly 32 bytes. Those decoded bytes are the HMAC key.

The signed message is <timestamp>.<exact raw request body>: the UTF-8 timestamp and period, followed by the exact body bytes. Capture the bytes before body-parser middleware changes them. Do not verify JSON.stringify(JSON.parse(body)): whitespace, key order, and encoding differences change the signature even when the JSON has the same meaning.

Node / TypeScript — complete standalone verifier
import { createHmac, timingSafeEqual } from "node:crypto";

// These limits are YOUR receiver policy, not BusinessMode protocol defaults.
export type FreshnessPolicy = {
    nowSeconds: number;
    maxAgeSeconds: number;
    maxFutureSkewSeconds: number;
};

// Returns the verified timestamp, or null. Keep the secret server-side.
export function verifyWebhook(
    rawBody: Buffer,
    signatureHeader: string | null,
    signingSecret: string,
    policy: FreshnessPolicy,
): number | null {
    if (
        !Buffer.isBuffer(rawBody) ||
        typeof signatureHeader !== "string" ||
        typeof signingSecret !== "string" ||
        !/^whsec_[A-Za-z0-9_-]{43}$/.test(signingSecret)
    )
        return null;

    // Strict current format: rejects missing, duplicate, or malformed values.
    const match = /^t=(0|[1-9][0-9]{0,15}),v1=([A-Za-z0-9_-]{43})$/.exec(signatureHeader);
    if (!match || match[0] !== signatureHeader) return null;
    const timestamp = Number(match[1]);
    if (!Number.isSafeInteger(timestamp)) return null;

    const encodedKey = signingSecret.slice("whsec_".length);
    const key = Buffer.from(encodedKey, "base64url");
    const received = Buffer.from(match[2], "base64url");
    // Reject non-canonical encodings as well as incorrect byte lengths.
    if (
        key.length !== 32 ||
        key.toString("base64url") !== encodedKey ||
        received.length !== 32 ||
        received.toString("base64url") !== match[2]
    )
        return null;

    const signedBytes = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
    const expected = createHmac("sha256", key).update(signedBytes).digest();
    if (!timingSafeEqual(expected, received)) return null;

    // Check freshness after authenticating the exact bytes, before processing.
    const { nowSeconds, maxAgeSeconds, maxFutureSkewSeconds } = policy;
    if (
        ![nowSeconds, maxAgeSeconds, maxFutureSkewSeconds].every(
            (value) => Number.isSafeInteger(value) && value >= 0,
        ) ||
        timestamp < nowSeconds - maxAgeSeconds ||
        timestamp > nowSeconds + maxFutureSkewSeconds
    )
        return null;

    return timestamp;
}

This complete Node/TypeScript example returns the authenticated timestamp or null. It rejects malformed or duplicate signature values, non-canonical base64url, unsafe timestamps, wrong-length keys/digests, and failed constant-time comparisons. It imports no BusinessMode package. The same source shown here is executed by the documentation tests against a fixed synthetic signature vector and the canonical signer.

Supply all three FreshnessPolicy values yourself. Set nowSeconds from your receiver's clock and choose maxAgeSeconds and maxFutureSkewSeconds for your security, clock-skew, and operational requirements. BusinessMode does not prescribe a fixed receiver timestamp tolerance. No five-minute default is implied by this example. Keep clocks synchronized and reject requests outside your chosen policy.

For a Web-standard Request, capture the body with Buffer.from(await request.arrayBuffer()). Pass that Buffer, request.headers.get("BusinessMode-Signature"), your server-side secret, and your policy to verifyWebhook. Only when the result is not null should you parse JSON.parse(rawBody.toString("utf8")) for business processing. Apply request-size limits while reading the body in your chosen server stack; do not buffer unbounded unauthenticated input.

A retry gets a freshly generated signature timestamp. Timestamp freshness is not deduplication, and a different timestamp does not make the same event new. An old domain occurredAt is also not a reason to reject an otherwise fresh signed delivery attempt. Signatures alone do not prevent every replay; durable receiver state is still required.

4. Validate, deduplicate, and acknowledge

After verification and freshness checks, parse JSON and validate the envelope before dispatching on type + version. Validate data for the combinations you support. For an unfamiliar type or version, use a safe default branch: intentionally ignore or record the unsupported event according to your policy, then acknowledge it successfully if it was validly received. Do not return 500 merely because a new event type appeared.

BusinessMode has at-least-once delivery behavior. An accepted request can arrive again, for example if its acknowledgement is lost. Design for duplicates; retry budgets and terminal failures still mean some deliveries can remain Failed.

Pick the deduplication identity for your responsibility

  • Delivery ID identifies one event's delivery to one webhook endpoint. BusinessMode-Delivery-Id remains stable across its retries. It is useful for endpoint-level transport deduplication after signature verification, subject to the unsigned-header boundary above.
  • Event ID identifies the domain event. The same event can reach several configured endpoints. Use the verified envelope's id for business-level deduplication across endpoint configurations, including overlap during endpoint replacement.

Store deduplication state durably, not in an in-memory Set. Atomically record the selected identity with accepted work in your durable inbox/queue or transaction. Concurrent duplicates must not independently apply the same business effect. Only acknowledge a duplicate when you know its work was already durably accepted; a transient “seen” flag is insufficient.

The recommended sequence is:

  1. Capture raw bytes; verify HMAC and your timestamp policy.
  2. Parse and validate the envelope and supported event data.
  3. Deduplicate and durably accept/enqueue the work atomically.
  4. Return 2xx promptly.
  5. Process expensive downstream work asynchronously, with your own retry/error handling.

Any HTTP status from 200 through 299 counts as successful delivery; you need not choose a particular 200 versus 204. A 2xx means BusinessMode stops retrying this delivery. If downstream processing fails after durable acceptance, your system owns that recovery. Returning 500 instead deliberately leaves BusinessMode responsible for another delivery attempt and can generate duplicate work. Do not use it as an accidental substitute for an internal job queue.

5. Understand automatic retries and failure classes

Every non-2xx status is a retryable http_status failure, including 3xx, 4xx, and 5xx. A 4xx is not immediately terminal. The worker does not follow application-level redirects; a 301 or 302 is a failure, not a successful handoff. Expose the final HTTPS route directly.

The current worker policy schedules the next automatic attempt after a completed retryable failure as follows. Delays are measured from attempt completion; actual execution also depends on worker availability.

Completed attemptDelay before next automatic attempt
130 seconds
22 minutes
310 minutes
430 minutes
52 hours
66 hours
724 hours
8None automatically

The normal automatic ceiling is 8 completed attempts, including the initial attempt. This is not a lifetime maximum: a later manual Retry can produce attempt 9 or higher. A repeated retryable failure at/above the automatic ceiling becomes Failed again rather than starting a new eight-attempt budget.

lastErrorCodeMeaningAutomatic handling
timeoutRequest timed out.Retry while below the automatic limit
dns_errorDNS lookup failed.Retry while below the automatic limit
connection_errorConnection failed.Retry while below the automatic limit
tls_errorTLS connection failed.Retry while below the automatic limit
http_statusReceiver returned a non-2xx status, including 3xx, 4xx, or 5xx.Retry while below the automatic limit
destination_not_publicDestination did not resolve to a permitted public address.Immediately terminal
invalid_destinationDestination URL could not be used.Immediately terminal
payload_too_largeRequest body exceeded the 256 KiB outbound limit.Immediately terminal

The five retryable categories may schedule another attempt while below that ceiling. destination_not_public, invalid_destination, and payload_too_large are immediately terminal and enter Failed without the normal automatic sequence. Failed does not necessarily mean eight attempts.

The current outbound body limit is 256 KiB (262,144 bytes), not a public configurable setting. Return a successful acknowledgement promptly; an internal worker timeout is not a public latency SLA or a guaranteed processing allowance for your receiver.

6. Inspect delivery state, not imagined response bodies

Use the developer product's Webhook Deliveries surface or List webhook deliveries and Get webhook delivery. The only delivery states are:

  • pending: not currently Delivered or Failed. It may be never attempted, waiting for nextAttemptAt, already due, or leased/being attempted. The public response does not expose the lease; Pending is not one exact queue state.
  • delivered: a worker attempt received HTTP 2xx. It does not prove your downstream business processing finished.
  • failed: currently terminal for automatic processing, either from the retry ceiling or an immediate terminal policy failure.
FieldMeaning
attemptCountRecorded completed attempts; manual Retry preserves this count.
nextAttemptAtNext scheduled time, or null when none is scheduled.
lastAttemptAtLast attempt start recorded when work was claimed.
lastStatusCodeLast recorded HTTP status; null if that attempt had no HTTP response.
lastErrorCodeLast recorded failure category; cleared after successful delivery.
deliveredAtSuccessful completion time, or null.
failedAtCurrent terminal-failure time; cleared by a scheduled manual Retry.

The last* fields describe recorded attempts, not a promise about the worker's current state. An attempt may be in progress while the previous status/error remains visible. Manual rescheduling preserves those diagnostics until subsequent worker updates.

BusinessMode records HTTP status codes, failure categories, and attempt timestamps. Receiver response bodies are not stored or exposed by the Delivery management API. Use your own receiver logs, correlated by Event ID/Delivery ID, to investigate its response without unnecessarily recording sensitive payloads.

Delivery detail includes event.data for debugging. Pretty management JSON is not guaranteed to be the exact signed raw body: it is formatted event data, not a captured HTTP request. Never copy it into signature verification or try to reproduce BusinessMode serialization. The received raw bytes are signature truth.

7. Request a deliberate manual Retry

Retry webhook delivery calls POST /projects/{projectId}/webhook-deliveries/{deliveryId}/retry. It requires current developer Project authority, no request body, and no generic Idempotency-Key. It reschedules the existing Delivery; it is not a replay endpoint for successfully delivered events.

Manual Retry does not reset attemptCount, lastAttemptAt, lastStatusCode, or lastErrorCode. A scheduled retry clears failedAt, sets nextAttemptAt to now, and leaves the prior diagnostics in place. Thus Pending can legitimately show lastStatusCode = 500 and lastErrorCode = http_status.

For example: automatic attempt 8 fails; an operator fixes the receiver and requests Retry; attempt 9 returns 204. The result is Delivered with attemptCount = 9, not “9 / 8.”

Current conditionResult / operator action
Failed

Reschedule now, preserve attempt history. Correct the underlying failure first.

Pending with future retryCan bring the existing Delivery due now if it is not actively leased.
Pending, already due, no active lease

The ordinary no-lease already-due path returns 204 as a no-op, with no additional retry-request audit event.

Worker holds a live lease

webhook_delivery_in_progress conflict. Refresh and try later only if still necessary.

Delivered

webhook_delivery_already_delivered conflict. Do not use this endpoint to replay a successful delivery.

204 does not prove that a new retry was scheduled or that webhook_delivery.retry_requested was emitted. The response does not distinguish scheduling from the already-due no-op. Refresh canonical Delivery state. An expired worker lease may require rescheduling even when a displayed due time is in the past; the public API does not expose that lease detail.

If the Retry request's network result is uncertain, refresh before submitting another Retry. The first request may already have rescheduled the Delivery, allowed the worker to claim it, or resulted in successful delivery. Do not loop Retry calls automatically.

8. Disable or replace an endpoint safely

Disable webhook endpoint stops fanout for events occurring at or after the endpoint's disable time. Disable does not cancel existing deliveries: it does not delete Pending work, cancel Failed work, or erase Delivered history. Existing Pending/Failed deliveries remain processable and manually retryable after disable.

An event that occurred before disable can still be materialized as a Delivery or sent afterward. Eligibility follows occurredAt, so this is expected behavior, not proof that disable failed. Likewise, creating an endpoint after an old event does not backfill it.

There is no endpoint-update, signing-secret-rotation, or re-enable API. To change the signing secret:

  1. Create a replacement endpoint and securely capture its new secret.
  2. Configure the receiver for the replacement secret and endpoint identity.
  3. Transition traffic with duplicate handling: overlapping endpoints can receive the same Event ID.
  4. Disable the old endpoint once the transition is ready.

Creating the replacement can start its event stream before receiver configuration is complete. Plan that rollout and inspect its deliveries. Keep the old receiver/secret available for its remaining historical deliveries when an orderly drain is appropriate; disable alone will not stop them. For a compromised secret, prioritize your incident policy over accepting old signed traffic. Do not try to “re-enable” the old endpoint; no such operation exists.

9. Troubleshoot from observable evidence

SymptomCheck next
No deliveries for an event

Did a supported Project event occur? Was the endpoint created before it? Was it disabled at that event's time? Pre-creation events are not backfilled.

Pending with a future nextAttemptAt

A retry is scheduled. Inspect last-attempt diagnostics; do not assume the worker is currently sending.

Failed + http_status

The receiver returned non-2xx. Check routing, redirects, raw-body handling, signature secret, and your own correlated receiver logs.

Failed + tls_error

Check TLS/certificate configuration.

Failed + dns_error

Check public DNS resolution.

Failed + destination_not_public

The destination must resolve to permitted public addresses. A successful endpoint-create response did not test this.

Failed + invalid_destination / payload_too_large

Inspect the destination or event size. Repeating the same request without addressing the cause will not remove a policy failure.

Delivered, but no downstream effect

BusinessMode received 2xx. Inspect your durable inbox, dedupe decision, unsupported-event policy, and internal processing.

Delivery after disable

Compare the event's occurrence time with disable time. Earlier events and existing deliveries remain valid work.

For a signature mismatch, first check that the framework preserved raw bytes, the correct endpoint secret was selected, and its suffix was decoded rather than used literally. Then inspect timestamp-policy/clock issues without logging the secret or full sensitive payload. A receiver verification failure often appears to BusinessMode only as your non-2xx response.

10. Receiver security checklist

  • Expose a final public HTTPS receiver and bound unauthenticated request sizes.
  • Keep whsec_ secrets server-side; never log or send them to analytics.
  • Verify the exact raw body using the decoded 32-byte key and constant-time HMAC comparison.
  • Enforce your receiver-owned timestamp freshness policy; do not confuse it with deduplication.
  • Parse and process business data only after verification. Validate the envelope and supported (type, version) data.
  • Deduplicate durably and atomically with accepted work; do not treat unsigned headers as signed business identity.
  • Return 2xx promptly after durable acceptance, and own subsequent processing failures.
  • Avoid unnecessary full-payload logging. Payloads can contain customer identifiers and operational data.

Continue with the API reference for exact management contracts, or the developer platform for how webhooks fit alongside machine APIs and operational tooling. This guide describes current delivery behavior; it does not define a second protocol, an exhaustive event catalog, or a new retry service.