Docs

Webhook delivery

How K-Block signs, sends, retries and redelivers webhook events, how to verify a delivery, and which addresses endpoints may use.

K-Block Cloud sends webhook events to your HTTPS endpoints as signed JSON POST requests. This page is for whoever writes the receiver: what a delivery looks like, how to check that it really comes from K-Block, and what happens when your receiver is slow, down or misconfigured.

To create endpoints, choose events and read the delivery log in the dashboard, see Webhooks. For the data of every event type, see Webhook events.

How events become deliveries

When something happens that an event reports (an approval is requested, a receipt is created…), K-Block records the event together with the change itself. An event therefore exists exactly when the change took effect, and it is never sent for a change that did not take effect.

At the same moment K-Block creates one delivery for every enabled endpoint that subscribes to the event type. K-Block then sends the deliveries in the background:

  • at most one delivery per endpoint is in flight at a time, and at most four per organization;
  • each request must finish within 10 seconds;
  • only a 2xx answer counts as success; anything else, a timeout or a connection error is a failed attempt.

Delivery is at least once: the same event can arrive more than once, for example when your receiver processed it but its answer did not reach K-Block. Deliveries are also not ordered: a delivery waiting for a retry does not hold back later events. Use the event's id to deduplicate and its created_at to order.

What a delivery looks like

POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: KBlock-Webhooks/1.0
KBlock-Event-Id: <event id>
KBlock-Delivery-Id: <delivery id>
KBlock-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
HeaderMeaning
KBlock-Event-IdThe event's ID, the same for every endpoint and every attempt. Deduplicate on it.
KBlock-Delivery-IdThis endpoint's delivery of the event, the same for every attempt of it.
KBlock-SignatureThe timestamp and one or more signatures (see below).

The body is a JSON object with the same five members for every event type. For example, a receipt.created event:

{
  "id": "<event id>",
  "type": "receipt.created",
  "created_at": "2026-01-01T12:00:00.000Z",
  "organization_id": "<organization id>",
  "data": {
    "receipt_id": "<receipt id>",
    "protected_action_id": "<protected action id>",
    "receipt_object_id": "<receipt object id>",
    "thread_id": "<thread id>",
    "outcome": "succeeded",
    "bundle_sha256": "<sha-256 of the .kbundle>"
  }
}

created_at is when the event happened, not when this attempt was sent. Payloads never contain secrets.

Verify the signature

Every delivery is signed with the endpoint's signing secret (whsec_…, shown once when you create the endpoint or rotate the secret):

KBlock-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
v1 = hex( HMAC-SHA256( key = the whole secret string, including "whsec_",
                       message = "<t>." followed by the exact raw request body ) )

To accept a delivery, your receiver must:

  1. Read the raw body bytes before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
  2. Parse the header: exactly one t, one or more v1.
  3. Refuse the delivery if t is more than 5 minutes away from your clock. Every attempt is signed at the time it is sent, so retries pass this check.
  4. Compute the HMAC and compare it, in constant time, with each v1. Accept if one matches.
  5. Deduplicate on KBlock-Event-Id, then answer 2xx quickly and process the event afterwards.

A receiver in TypeScript for Node.js, using only node:crypto:

verify-webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * Checks a K-Block webhook delivery. `rawBody` must be the exact bytes received,
 * `secret` the endpoint's signing secret (whsec_…).
 */
export function verifyKBlockSignature(header: string | undefined, rawBody: Buffer, secret: string, toleranceSeconds = 300): boolean {
  if (header === undefined) return false;
  let timestamp: number | undefined;
  const signatures: Buffer[] = [];
  for (const part of header.split(",")) {
    const eq = part.indexOf("=");
    if (eq <= 0) return false;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === "t") {
      if (timestamp !== undefined || !/^\d{1,15}$/.test(value)) return false;
      timestamp = Number(value);
    } else if (key === "v1") {
      if (!/^[0-9a-f]{64}$/.test(value)) return false;
      signatures.push(Buffer.from(value, "hex"));
    }
  }
  if (timestamp === undefined || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  let matched = false;
  for (const signature of signatures) matched = timingSafeEqual(signature, expected) || matched;
  return matched;
}

To try your receiver, select Send test event on the endpoint's page. K-Block sends a webhook.test event to that endpoint only, signed like every other delivery.

Retries and permanent failure

A failed attempt is retried with exponential backoff: the delay after attempt n is 30 seconds × 2n−1, at most 6 hours.

After failed attemptNext attempt in
130 seconds
21 minute
32 minutes
44 minutes
58 minutes
616 minutes
732 minutes
8none: the delivery is Failed

So a receiver that stays down gets its last attempt a little more than an hour after the first, and the delivery is then failed for good. Every attempt is kept in the endpoint's delivery log (Deliveries on the endpoint's page) with its time, HTTP status or error, and duration.

K-Block never disables an endpoint because its deliveries fail: every new event gets its own 8 attempts. Watch the endpoint's delivery log to notice a receiver that keeps failing.

Redelivery

A Failed delivery can be queued again: select Redeliver in the endpoint's delivery log, or call:

POST /api/v1/orgs/<org-id>/webhooks/<endpoint-id>/deliveries/<delivery-id>/redeliver

The delivery goes back to Pending with 8 new attempts; its earlier attempts stay in the log. It is sent with the same event ID, delivery ID and body, signed with the endpoint's current secret (and, within 24 hours of a rotation, also the previous one). Only failed deliveries can be redelivered (others answer 409 INVALID_STATE), only while the endpoint is enabled (409 ENDPOINT_DISABLED). Test events and redeliveries together are limited to 5 per minute per endpoint (429 RATE_LIMITED).

Like every webhook route, this one needs a signed-in owner, admin or developer: management API keys cannot manage webhooks.

Disabled endpoints

While an endpoint is disabled:

  • events that happen are not queued for it, and are never sent to it later;
  • deliveries that were already pending wait, and are sent when you enable it again;
  • redeliveries and test events are refused with 409 ENDPOINT_DISABLED; the dashboard disables Send test event and does not offer Redeliver.

K-Block disables endpoints by itself when the member who created an endpoint is removed, leaves, or gets a role that cannot manage webhooks (approver or viewer), and when K-Block deletes that person's account (accounts cannot be deleted from the dashboard or through the Cloud API). The audit log records this as webhook.updated with a reason. An owner, admin or developer can enable the endpoint again. Deleting an endpoint deletes all of its deliveries, pending or not, and its delivery log.

Secret rotation

Rotating a secret (Rotate secret, or PATCH with "rotate_secret": true) returns the new secret once. For the next 24 hours every delivery carries two signatures, v1=<new> and v1=<previous>, so a receiver that still has the old secret keeps verifying while you deploy the new one. After 24 hours only the new secret signs.

Only one previous secret is kept: if you rotate again within the 24 hours, the secret from before the first rotation stops signing at once.

Addresses an endpoint may use

The Cloud API checks an endpoint's URL when you create or change it, and checks the address again on every connection:

  • https:// only, at most 2,048 characters, with no user name or password and no fragment (#…).
  • The host name is resolved and every address it resolves to must be public. Loopback, private (RFC 1918), link-local (including the cloud metadata address 169.254.169.254), carrier-grade NAT, multicast, documentation and other special-purpose ranges are refused, for IPv4 and IPv6 alike; IPv6 forms that embed an IPv4 address are judged by that address. A refused URL, or a host name that does not resolve, answers 422 URL_NOT_ALLOWED.
  • Redirects are never followed. A 3xx answer is a failed attempt ("redirects are not followed"), so point the endpoint at its final URL.
  • Keep your answer's body small; its content is ignored. An answer whose Content-Length is over 64 KiB is a failed attempt (RESPONSE_TOO_LARGE), even with a 2xx status.

K-Block delivers only to public https:// addresses. A receiver that can be reached only on a private network or only over http:// cannot receive deliveries: make it reachable at a public https:// address and point the endpoint there.

What is not sent as a webhook

Some changes are recorded in the audit log but have no webhook event in this version: cancelling an action (only its receipt.created is sent, with outcome final), an approved action expiring before it ran, an approved action being superseded, the start of an execution, and changes to keys, delegations and members.

K-Block does not delete webhook events. Deliveries and their attempts are deleted only together with their endpoint.