> ## Documentation Index
> Fetch the complete documentation index at: https://docs.checkfu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Receive webhooks

> Verify a Checkfu signature, deduplicate deliveries, and survive retries.

Webhooks tell you something happened without holding a stream open. The delivery is deliberately thin: verify it, deduplicate it, then fetch what you need.

<Warning>
  Plain HTTP against the Checkfu API needs no private package.
  The Checkfu CLI and TypeScript SDK are optional private-alpha artifacts; the HTTP requests work without them.
  Part of this flow is behind a rollout fence; a deployment that has not enabled it refuses the request rather than running it.
  Confirm [capability status](/getting-started/status) before making availability part of your application's contract.
</Warning>

## Register an endpoint

`CHECKFU_API_KEY` and `CHECKFU_WORKSPACE_ID` come from [Get access](/reference/access#environment-variables-used-by-the-guides). The TypeScript tabs below use the [TypeScript SDK](/reference/typescript-sdk) with this client:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { Checkfu } from "@checkfu/sdk"

const checkfu = new Checkfu({
  apiKey: process.env.CHECKFU_API_KEY,
})
```

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl --request POST https://api.checkfu.com/v1/webhook-endpoints \
    --header "Authorization: Bearer $CHECKFU_API_KEY" \
    --header "Checkfu-Version: 2026-08-31" \
    --header "Idempotency-Key: endpoint-production" \
    --header "Content-Type: application/json" \
    --data '{
      "url": "https://example.com/hooks/checkfu",
      "event_types": [
        "run.requires_action",
        "action_approval.pending",
        "outcome.evaluation_started",
        "outcome.evaluation_completed",
        "run.completed"
      ]
    }'
  ```

  ```ts TypeScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const endpoint = await checkfu.webhooks.create(
    {
      url: "https://example.com/hooks/checkfu",
      event_types: [
        "run.requires_action",
        "action_approval.pending",
        "outcome.evaluation_started",
        "outcome.evaluation_completed",
        "run.completed",
      ],
    },
    { idempotencyKey: "endpoint-production" },
  )
  ```
</CodeGroup>

To subscribe to the whole catalog, **omit `event_types` entirely**. Sending `null` is valid when patching an existing endpoint but is rejected at creation, and an empty array is rejected in both.

Beside the session-log lifecycle, the catalog carries resource-lifecycle events, delivered with a `null` `data.session_id` and the mutated resource's identity in `data.resource_id` so one get-by-id completes the read:

* `memory_store.created` / `memory_store.archived` / `memory_store.deleted` — a memory store changed.
* `agent.created` / `agent.updated` / `agent.archived` — an Agent changed. Every
  effective update automatically creates the next immutable Version; fetch the
  Agent to read its current `version`.
* `connection.created` / `connection.revoked` / `connection.refresh_failed` — a connection was created, entered `revoked`, or a credential refresh failed and left it needing reauthorization.
* `vault_credential.refresh_failed` — an attached Vault Credential's OAuth access token expired and could not be renewed. The expired token is not released to the Session; use `data.vault_id` and `data.resource_id` as the nested Vault/Credential lookup pair to inspect its secret-free state before replacing or revalidating it.
* `automation.created` / `automation.paused` / `automation.resumed` / `automation.deleted` — an automation's lifecycle changed. Automations have no archive state; deletion is the terminal transition.
* `automation_firing.failed` — an automation firing settled as a failure: its launch never produced a Session (the firing's `status` is `failed` and its `error` names why), or its step program settled `failed` or could no longer run. `data.resource_id` is the firing id; fetch `GET /v1/automation-firings/{id}` for its `automation_id` and the reason. Delivered once per firing. A step program that settles `denied` (a person refused an approval) or `rejected` (a Check's negative verdict or an exhausted outcome) reached a decision it was built to make, so it emits no failure. A firing that launched but whose Run later failed is reported by `automation_firing.run_failed` instead.
* `automation_firing.run_failed` — an automation firing created its Session, but the Run it launched then failed (for an automation with an outcome, this includes the revision Run that continued its work). `data.resource_id` is the firing id; its `GET /v1/automation-firings/{id}` read stays `submitted` and names the Run in `failed_run_id`, and `GET /v1/runs/{run_id}` carries the error. Delivered once per firing, and never for the same firing as `automation_firing.failed`. A Run someone else started in the automation's Session, a canceled Run, and an outcome's verdict emit nothing. Subscribe to both events to hear about every automation failure.
* `workspace.created` / `workspace.updated` — a non-bootstrap Workspace was created from an existing Workspace context, or a Workspace's versioned configuration/status changed. Creation is delivered through the request Workspace's pre-existing custodian; bootstrap creates the first Workspace and has no possible Workspace-scoped subscriber, so it emits no fact. Workspaces have no delete/archive operation; disabling one is an update and its custodian remains addressable.

The response contains the signing `secret`. Create and rotation require an `Idempotency-Key`; an
exact retry can recover the same response if the first response was lost, but never mints a second
secret generation for that operation. Store the secret before using a different key or changing
the endpoint version.

## Verify the signature

Verify the raw request and signature header at your own public HTTPS receiver, using the secret returned when you create or rotate the endpoint. Delivery receipt reads report status and attempts; they do not expose a replayable signed request or its signing secret. The endpoint test operation can exercise receiver verification with a synthetic request, but its public result alone does not prove that your receiver verified the signature.

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
Checkfu-Signature: t=1752686400,v1=<hex hmac-sha256>
```

The HMAC is computed over `ASCII(t) || "." || raw_request_body`, keyed by the whole `whsec_…` secret as UTF-8 bytes.

```ts path=examples/support-desk/server/src/checkfu-webhook.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { createHmac, timingSafeEqual } from "node:crypto"

const DEFAULT_TOLERANCE_SECONDS = 300
const SHA256_HEX_LENGTH = 64

export interface CheckfuWebhookVerification {
	rawBody: Uint8Array
	signatureHeader: string | null
	secrets: ReadonlyArray<string>
	nowSeconds?: number
	toleranceSeconds?: number
}

const parseSignatureHeader = (
	header: string,
): { readonly timestamp: string; readonly signature: string } | null => {
	const fields = new Map<string, Array<string>>()
	for (const rawField of header.split(",")) {
		const field = rawField.trim()
		const separator = field.indexOf("=")
		if (separator <= 0) return null
		const key = field.slice(0, separator)
		const value = field.slice(separator + 1)
		fields.set(key, [...(fields.get(key) ?? []), value])
	}

	const timestamps = fields.get("t") ?? []
	const signatures = fields.get("v1") ?? []
	if (timestamps.length !== 1 || signatures.length !== 1) return null
	const timestamp = timestamps[0]
	const signature = signatures[0]
	if (timestamp === undefined || !/^\d+$/u.test(timestamp)) return null
	if (signature === undefined || !/^[a-f\d]{64}$/iu.test(signature)) return null
	return { timestamp, signature }
}

/** Verify one Checkfu delivery against the active and, during rotation, previous secret. */
export const verifyCheckfuWebhookSignature = ({
	rawBody,
	signatureHeader,
	secrets,
	nowSeconds = Math.floor(Date.now() / 1000),
	toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
}: CheckfuWebhookVerification): boolean => {
	if (
		signatureHeader === null ||
		secrets.length === 0 ||
		!Number.isSafeInteger(nowSeconds) ||
		!Number.isSafeInteger(toleranceSeconds) ||
		toleranceSeconds < 0
	) {
		return false
	}
	const parsed = parseSignatureHeader(signatureHeader)
	if (parsed === null) return false

	const timestampSeconds = Number(parsed.timestamp)
	if (
		!Number.isSafeInteger(timestampSeconds) ||
		Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds
	) {
		return false
	}

	const provided = Buffer.from(parsed.signature, "hex")
	if (provided.length * 2 !== SHA256_HEX_LENGTH) return false
	return secrets.some((secret) => {
		const expected = createHmac("sha256", secret)
			.update(parsed.timestamp, "ascii")
			.update(".", "ascii")
			.update(rawBody)
			.digest()
		return timingSafeEqual(provided, expected)
	})
}
```

Pass the bytes returned by your framework's raw-body API, the `Checkfu-Signature` header, and an array containing the current secret. Malformed or duplicate `t`/`v1` fields, invalid hex, and timestamps more than five minutes away all fail closed.

Two details that cause most failures:

* **Sign the raw bytes.** The body is not normalized before verification. If your framework parses JSON and you re-serialize it to verify, the signature will not match. Capture the raw body.
* **The key is the full secret string,** including the `whsec_` prefix. Do not key on the decoded suffix.

## Handle the delivery

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "type": "event",
  "id": "evt_0123456789abcdef0123456789abcdef",
  "seq": 12,
  "created_at": "2026-07-16T18:00:03.000Z",
  "data": {
    "type": "run.requires_action",
    "session_id": "sess_0123456789abcdef0123456789abcdef",
    "workspace_id": "wrkspc_0123456789abcdef0123456789abcdef"
  }
}
```

There is no payload content by design. Deduplicate on `id`, then fetch the Session or Run for detail. Respond `2xx` quickly and do the work asynchronously. The delivery timeout is 10 seconds.

`data.session_id` is nullable: it is `null` on test deliveries, which also carry `seq: 0`. Handle that before dereferencing it, or the first press of the test button crashes your handler.

## Retries and auto-disable

Delivery is at-least-once. A failed delivery retries at 30 seconds, 2 minutes, 10 minutes, 1 hour, 4 hours, and 12 hours, for seven attempts in total.

After **20 consecutive terminal failures** the endpoint disables itself with a reason of `delivery_failures`. Re-enable it by patching `status` back to `active`, which also resets the failure count. Check `GET /v1/webhook-endpoints/{id}/deliveries` to see what was failing before you re-enable.

The failure counter advances when real delivery attempts reach a terminal failure, after the configured retry schedule; a successful delivery resets it. Synthetic endpoint tests do not increment this counter. There is no public operation to fast-forward retry time or force the automatic-disable threshold. Observe `consecutive_failures`, `status`, `disabled_reason`, and delivery receipts during real delivery processing.

Endpoint reads and changes can temporarily fail while a recorded delivery outcome is
waiting for its counters to be applied. Checkfu withholds the unsettled endpoint
state and retries outcome reporting through its existing delivery custodian. This
report-only recovery does not send the delivery again. A failed mutation response
alone does not prove that the change was absent: read the endpoint back before
retrying, preserve the original idempotency key where required, and use its
version fence. If readback is still unavailable, wait for the endpoint to settle
before deciding whether to retry the change. Do not request redelivery merely to
repair endpoint counters.

That listing takes `order=asc` or `order=desc`, and `desc` is what you want here: it walks the newest deliveries first, so the failures that disabled the endpoint are on the first page. The cursor does not encode a direction, so send the same `order` with every page request of a walk, including the ones that carry a `page` cursor.

## Redeliver a dead letter

After fixing the receiver, re-enable or update the endpoint first. Then queue
the failed delivery again:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl --request POST \
  "https://api.checkfu.com/v1/webhook-endpoints/$ENDPOINT_ID/deliveries/$DELIVERY_ID/redeliver" \
  --header "Authorization: Bearer $CHECKFU_API_KEY" \
  --header "Checkfu-Version: 2026-08-31" \
  --header "Idempotency-Key: redeliver-$DELIVERY_ID"
```

Only a dead-lettered delivery on an active endpoint can be redelivered. The
response is a `202` queued receipt. An exact retry with the same idempotency
key returns that receipt without queueing twice.

Redelivery preserves the original delivery ID, event ID, sequence, timestamp,
and thin body. It adds one delivery attempt; it does not fabricate a second
event or reset the attempt count. Because webhook attempts are independent,
the redelivered notification can arrive after notifications for newer events.
Continue deduplicating on `id` and do not infer event order from arrival order.

## Test and rotate

Read the endpoint's current `version` from its create, get, list, or patch response. Testing and rotation require that reviewed version and reject the request if the endpoint changed before the action ran.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl --request POST \
  "https://api.checkfu.com/v1/webhook-endpoints/$ENDPOINT_ID/test" \
  --header "Authorization: Bearer $CHECKFU_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Checkfu-Version: 2026-08-31" \
  --data '{"expected_version":'"$ENDPOINT_VERSION"'}'
```

This sends a synthetic delivery with the event type `webhook.test` and reports whether it arrived and with what status code. That type never appears in a real event log, so it is safe to special-case.

Plan a coordinated cutover, then rotate with a unique idempotency key:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl --request POST \
  "https://api.checkfu.com/v1/webhook-endpoints/$ENDPOINT_ID/rotate-secret" \
  --header "Authorization: Bearer $CHECKFU_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Checkfu-Version: 2026-08-31" \
  --header "Idempotency-Key: rotate-$ENDPOINT_ID-$ENDPOINT_VERSION" \
  --data '{"expected_version":'"$ENDPOINT_VERSION"'}'
```

Rotation advances the endpoint version and returns the new secret. Store the response and deploy the new secret to every receiver promptly. If the response is interrupted, retry the identical request with the same `Idempotency-Key` and `expected_version` to recover the response while that original secret generation remains current.

Checkfu uses the current secret and endpoint configuration when it authorizes a new delivery request. A request already authorized before rotation may finish with a signature from the previous secret; rotation does not cancel that request. Account for these requests when switching receiver secrets.

Deletion uses the same fence: send `{ "expected_version": <current version> }` as the `DELETE /v1/webhook-endpoints/{id}` body.

## Choosing webhooks or streaming

| | Webhooks | SSE stream |
| - | - | - |
| Best for | Server-to-server, long-running, many Sessions | One Session a user is watching |
| Delivery | At-least-once with retries | At-least-once with cursor resume |
| Content | Notification only | Full event envelopes |
| Excludes | Your own `user.*` events | Nothing |

They are complementary: a dashboard usually streams the Session in front of the user and takes webhooks for everything else.

## Next steps

<CardGroup cols={2}>
  <Card title="Automations and operations" icon="clock" href="/concepts/automations">
    Inbound ingest uses the identical signature scheme in the other direction.
  </Card>

  <Card title="Resume a stream" icon="arrows-rotate" href="/guides/resume-a-stream">
    The streaming half of the same problem.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.