> For the complete documentation index, see [llms.txt](https://help.cerby.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://help.cerby.com/developer-tools/cerby-webhooks/implement-a-webhook-receiver.md).

# Implement a webhook receiver

This article describes the webhook delivery format, HTTP headers, signature verification, event types, and error codes for developers building webhook receiver integrations.

This article describes the key components you need as a developer to implement a webhook receiver that accepts Cerby event deliveries. The following sections are covered:

* [Envelope format](#envelope-format)
* [HTTP headers](#http-headers)
* [Signature verification](#signature-verification)
* [Event types](#event-types)
* [Automation failure codes](#automation-failure-codes)
* [Delivery semantics](#delivery-semantics)
* [Correlating related events](#correlating-related-events)
* [Sending to a Slack Incoming Webhook](#sending-to-a-slack-incoming-webhook)
* [Current release limitations](#current-release-limitations)

For an overview of how webhooks work and how to create a webhook endpoint in Cerby, refer to [Explore webhook notifications](https://help.cerby.com/setup-and-admin/workspace-settings/webhooks/explore-webhook-notifications) and [Create a webhook](https://help.cerby.com/setup-and-admin/workspace-settings/webhooks/create-a-webhook).

## Envelope format

Every webhook request body is a JSON object with a fixed top-level structure. The `error` field is present only on failure events and on alert events, described in the [Event types](#event-types) section. It is absent, rather than null, on every other event type.

The following is an example of a success event delivery:

```json
{
  "envelope_version": "1.0",
  "event_id": "01977f2e-9c1a-7d3b-8f00-2b9a4c5d6e7a",
  "event_type": "account.created",
  "occurred_at": "2026-06-09T20:12:01.342Z",
  "workspace_id": "9f8e7d6c-5b4a-4f3e-9d2c-1b0a99887766",
  "data": {
    "account_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "application": "Salesforce",
    "domain": "salesforce.com",
    "requestor_id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
    "vault_id": null
  },
  "trace_id": null,
  "correlation_id": null
}
```

The following is an example of a failure event delivery for `automation.failed`:

```json
{
  "envelope_version": "1.0",
  "event_id": "01977f2e-9c1a-7d3b-8f00-2b9a4c5d6e7f",
  "event_type": "automation.failed",
  "occurred_at": "2026-06-09T20:12:01.342Z",
  "workspace_id": "9f8e7d6c-5b4a-4f3e-9d2c-1b0a99887766",
  "data": {
    "account_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "action": "password_rotation",
    "application": "Salesforce",
    "automation_job_id": "7b3c1d5e9f0a4b2c8d6e1f3a5b7c9d0e",
    "automation_link": "https://acme.cerby.com/api/v1/jobs/7b3c1d5e9f0a4b2c8d6e1f3a5b7c9d0e",
    "child_count": null,
    "child_failure_count": null,
    "remediation": null
  },
  "error": {
    "code": "BadCredentials",
    "message": "The username or password stored in Cerby is incorrect",
    "user_action": "review_credentials"
  },
  "trace_id": null,
  "correlation_id": null
}
```

Every `automation.failed` delivery carries every `data` key shown above. The `action`, `child_count`, `child_failure_count`, and `remediation` keys are present and null when they have no value, so do not write a handler that tests for their absence.

The following is an example of an event that joins a correlation chain, where both trace fields carry a value:

```json
{
  "envelope_version": "1.0",
  "event_id": "01977f2e-9c1a-7d3b-8f00-2b9a4c5d6e81",
  "event_type": "account.credentials.rotation_failed",
  "occurred_at": "2026-06-09T20:12:01.342Z",
  "workspace_id": "9f8e7d6c-5b4a-4f3e-9d2c-1b0a99887766",
  "data": {
    "account_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "application": "Salesforce",
    "automation_job_id": "7b3c1d5e9f0a4b2c8d6e1f3a5b7c9d0e",
    "automation_link": "https://acme.cerby.com/api/v1/jobs/7b3c1d5e9f0a4b2c8d6e1f3a5b7c9d0e"
  },
  "error": {
    "code": "BadCredentials",
    "message": "The username or password stored in Cerby is incorrect",
    "user_action": "review_credentials"
  },
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "correlation_id": "ed3873d4-4e24-5673-a36f-802a7eca85fe"
}
```

The following table describes each field in the JSON object:

| Field              | Type                                 | Description                                                                                                                                                                                                       |
| ------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `envelope_version` | string                               | Always `"1.0"` in this release. Cerby bumps this value only for a semantic change to an existing field. New fields may be added without a version bump.                                                           |
| `event_id`         | string (UUIDv7)                      | Unique per event, and stable across all retry attempts and re-sends of the same event. Use it as the per-event idempotency key.                                                                                   |
| `event_type`       | string                               | The event type string. Refer to the [Event types](#event-types) section.                                                                                                                                          |
| `occurred_at`      | string (RFC 3339, UTC, milliseconds) | When Cerby recorded the event. This is not the source-system time.                                                                                                                                                |
| `workspace_id`     | string (UUID)                        | The Cerby workspace where the event occurred.                                                                                                                                                                     |
| `data`             | object                               | Event-specific payload. Fields vary by event type.                                                                                                                                                                |
| `error`            | object                               | Present on failure events and on alert events. Contains `{ "code": string, "message": string, "user_action": string \| null }`. Absent, rather than null, on every other event type.                              |
| `trace_id`         | string or null                       | An opaque support handle identifying the Cerby operation behind the event. Quote it to Cerby Support. It is null when the operation that produced the event was not traced.                                       |
| `correlation_id`   | string or null                       | The chain key that joins a failure to the later event that resolved it. It is null for events that make no claim about causality. Refer to the [Correlating related events](#correlating-related-events) section. |

Key order inside the `data` object is alphabetical on the wire, and key order is not part of the contract. Parse by key name, never by position.

### The error object

The `error` object is present on every failure event type and on every alert event type. An alert event is not a failure, but it still asks you to act. Test for the presence of the `error` key rather than inferring it from the event name, because the event-type suffix is not a reliable signal in either direction.

The following table describes each field in the `error` object:

| Field         | Type           | Description                                                                                                                                                                                                                              |
| ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`        | string         | A stable machine identifier for what happened. Treat any unrecognized value as an opaque generic failure.                                                                                                                                |
| `message`     | string         | A human-readable description. Always populated. A code that has no specific copy falls back to a generic message. Display it, but never depend on its exact wording.                                                                     |
| `user_action` | string or null | A coarse, stable remediation category your integration can branch on without parsing `message`. Refer to the [Automation failure codes](#automation-failure-codes) section for the full list. Null means no action is expected from you. |

{% hint style="info" %}
**NOTE:** A null `user_action` is permanent and intentional, and it is the machine-readable signal that Cerby expects no action from you. Treat an unrecognized `user_action` value as opaque and fall back to reviewing the event in Cerby.
{% endhint %}

### Forward compatibility

Apply the following forward compatibility rules when processing deliveries:

* **Ignore unknown fields:** New top-level keys may appear without a version bump.
* **Tolerate null on nullable fields:** A field that has always been null for you may start carrying a value without a version bump. Never branch on a field being permanently absent or permanently null.
* **Do not assume field ordering:** Parse by key name.
* **Treat unknown `error.code` and `error.user_action` values as opaque strings:** Never reject an event because of an unrecognized value.

## HTTP headers

Every webhook request includes the following headers:

| Header                        | Description                                                                                                                                                                             |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Cerby-Signature`           | `<algo>=<base64url(sig)>` where `<algo>` is `ed25519` or `hmac-sha256`. Repeated during a key rotation, with the new key first and the old key second. The base64url value is unpadded. |
| `X-Cerby-Signature-Key-Id`    | UUID of the signing key used. One header per signature row, in the same order as `X-Cerby-Signature`.                                                                                   |
| `X-Cerby-Signature-Timestamp` | RFC 3339 millisecond-precision UTC timestamp. The timestamp is part of the signed message. Reject requests where it is more than five minutes from the current time.                    |
| `X-Cerby-Delivery`            | UUID constant across all retry attempts for the same event and webhook. Use it as the per-delivery idempotency key.                                                                     |
| `X-Cerby-Attempt`             | Attempt number starting at 1, incremented on each retry, up to 6.                                                                                                                       |
| `Idempotency-Key`             | Same value as `X-Cerby-Delivery`. Cerby provides it as a convenience alias.                                                                                                             |

## Signature verification

Verify the signature on every incoming request before processing the event. You must reject a request that fails verification with a 4xx response.

### How the signature is constructed

Cerby signs the following byte sequence:

```
<timestamp_ms>.<raw_body_bytes>
```

In this sequence, `<timestamp_ms>` is the `X-Cerby-Signature-Timestamp` header converted to an integer number of milliseconds since the Unix epoch, `.` is a literal period, and `<raw_body_bytes>` is the unmodified request body as received. Cerby applies no JSON canonicalization.

### Verification algorithm

To verify an incoming request, you must complete the following steps:

1. Parse `X-Cerby-Signature-Timestamp` to milliseconds. Reject the request if `|now_ms - ts_ms|` exceeds 300,000, which is five minutes.
2. Build the signed byte sequence `b"{ts_ms}." + raw_body`.
3. For each `(X-Cerby-Signature, X-Cerby-Signature-Key-Id)` pair, do the following:

   * Skip rows whose algorithm prefix you do not handle.
   * Look up the public key or shared secret for that key ID.
   * Decode the signature value. Strip the `ed25519=` or `hmac-sha256=` prefix, then base64url-decode. The value is unpadded, so re-pad it to the next multiple of four before decoding. An Ed25519 signature is 86 characters and needs `==`, and an HMAC-SHA256 signature is 43 characters and needs a single `=`. Never hard-code `==` across both algorithms.
   * Verify the signature and accept on the first row that validates. Refer to the [Key rotation](#key-rotation) section for how this applies during a key change.

   The request is verified as soon as any row validates.
4. Reject the request if no row validates.

### Signing algorithms

Cerby supports two signing algorithms, which you choose when you create the webhook:

* **Ed25519:** Asymmetric signing, and the recommended option. You hold Cerby's public key for each key ID.
* **HMAC-SHA256:** Symmetric signing. You hold a shared secret for each key ID, and you must compare signatures using a constant-time comparison such as `hmac.compare_digest` in Python or `crypto.timingSafeEqual` in Node.js.

### Reference implementations

The following implementations are kept in sync with the CI test suite. Adapt the input plumbing to your framework before use. Both examples verify Ed25519 signatures. For HMAC-SHA256, replace the key lookup and the verify call with a constant-time comparison, and re-pad the signature with a single `=` rather than `==`.

{% hint style="danger" %}
**IMPORTANT:** The following mistakes each cause every valid delivery to fail silently, producing a blanket reject that looks identical to a real signature failure.

* **Use the raw request body, not a re-serialized object.** Parsing the JSON and then calling `JSON.stringify(req.body)` or an equivalent produces different bytes than Cerby signed. Capture the raw body before any parsing, for example using `express.raw()` in Node.js.
* **Split repeated `X-Cerby-Signature` headers.** During a key rotation, Cerby sends two signature rows. Many frameworks collapse repeated headers into a comma-separated string. Split the value and build separate `sigs[]` and `kids[]` arrays before iterating. A verifier that only checks the first row rejects all rotated deliveries.
* **Fail closed on parse errors.** Treat a missing header or a malformed body as a verification reject and respond with 4xx, not 5xx. Cerby treats 5xx as retryable and redelivers.
  {% endhint %}

#### Python (Ed25519)

The following is the CI-tested reference implementation in Python:

```python
import base64, json, sys
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

SKEW_MS = 300_000

def rfc3339_to_ms(ts):
    import datetime
    t = datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=datetime.timezone.utc)
    return int(t.timestamp() * 1000)

d = json.load(open(sys.argv[1]))
ts = d["ts"]
body = base64.b64decode(d["body_b64"])
public_keys = {k: base64.b64decode(v) for k, v in d["pubkeys"].items()}
now_ms = d["now_ms"]

ms = rfc3339_to_ms(ts)
if abs(now_ms - ms) > SKEW_MS:
    sys.exit(1)
signed_bytes = f"{ms}.".encode() + body
for sig, kid in zip(d["sigs"], d["kids"]):
    if not sig or not sig.startswith("ed25519="):
        continue
    pk = public_keys.get(kid)
    if not pk:
        continue
    try:
        # An Ed25519 signature is always 86 characters, so "==" is the correct
        # padding here. Pad to the next multiple of four for other algorithms.
        VerifyKey(pk).verify(signed_bytes, base64.urlsafe_b64decode(sig[len("ed25519="):] + "=="))
        print("ACCEPT")
        sys.exit(0)
    except BadSignatureError:
        continue
sys.exit(1)
```

#### Node.js (Ed25519, Node 16 and later)

The following is the CI-tested reference implementation for Node.js 16 and later:

```javascript
const crypto = require("crypto");
const fs = require("fs");

const SKEW_MS = 300000;

function rfc3339ToMs(ts) {
  return Date.parse(ts);
}

const d = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const ts = d.ts;
const body = Buffer.from(d.body_b64, "base64");
const pubkeys = {};
for (const k of Object.keys(d.pubkeys)) {
  pubkeys[k] = Buffer.from(d.pubkeys[k], "base64");
}
const nowMs = d.now_ms;

const ms = rfc3339ToMs(ts);
if (Number.isNaN(ms) || Math.abs(nowMs - ms) > SKEW_MS) {
  process.exit(1);
}
const signedBytes = Buffer.concat([Buffer.from(String(ms) + "."), body]);

for (let i = 0; i < d.sigs.length; i++) {
  const sig = d.sigs[i];
  const kid = d.kids[i];
  if (!sig || !sig.startsWith("ed25519=")) {
    continue;
  }
  const rawPub = pubkeys[kid];
  if (!rawPub) {
    continue;
  }
  try {
    const sigBytes = Buffer.from(sig.slice("ed25519=".length), "base64url");
    const key = crypto.createPublicKey({
      key: { kty: "OKP", crv: "Ed25519", x: rawPub.toString("base64url") },
      format: "jwk",
    });
    if (crypto.verify(null, signedBytes, key, sigBytes)) {
      console.log("ACCEPT");
      process.exit(0);
    }
  } catch {
    continue;
  }
}
process.exit(1);
```

{% hint style="info" %}
**NOTE:** `Date.parse` requires the exact RFC 3339 form Cerby emits, with milliseconds and a `Z` suffix. A timestamp without those parts parses as local time or as `NaN`, and the `Number.isNaN` guard fails closed on the second case.
{% endhint %}

### Key rotation

Retrieve your public key from `GET /v1/event-webhooks/{webhook_id}/keys`. During a rotation, this endpoint returns both the primary and secondary keys. Key your lookup by `X-Cerby-Signature-Key-Id` so each row resolves to the correct key.

When Cerby rotates a key, it dual-signs every request for 24 hours with both the old key as secondary and the new key as primary, sending the new key first. Accept the request if either row validates. Both keys are equally valid during the window, so accepting the first valid row is correct and simplest. When the 24 hours elapse, the old key expires and only the new key is used.

Apply the following two rules so a rotation never costs you a valid delivery:

* **Treat a cache miss as the rotation signal.** When an incoming request carries an `X-Cerby-Signature-Key-Id` you do not recognize, re-fetch both public keys from the `/keys` endpoint, update your local lookup, and then verify. Do not reject on an unknown key ID before refreshing.
* **Resolve the key by key ID, never by delivery.** Retries are signed with whatever key is primary at retry time, not at first-send time, so a delivery first attempted before a rotation and retried after it arrives signed under the new key.

If a key is compromised, Cerby can revoke the secondary key immediately rather than waiting out the 24-hour window. Only the secondary key is revocable. Re-fetch your public keys after a revocation.

For HMAC-SHA256 webhooks, the same dual-sign mechanism applies with shared secrets instead of public keys. Cerby displays an HMAC secret exactly once, when you create the webhook or rotate the key, and cannot recover it afterward, so store it immediately.

## Event types

This section lists the event types available for subscription, grouped by domain. Each table describes the `data` fields specific to that event type, in addition to the envelope fields described in the [Envelope format](#envelope-format) section.

Subscriptions match an exact event type string. Cerby supports no wildcards, so subscribing to `account.*` is not possible.

{% hint style="danger" %}
**IMPORTANT:** Identifier formats are not uniform across the catalog. Most event types render the identifiers in `data` as 32-character undashed hexadecimal. Two types, `account.access.role_changed` and `account.access.revoked`, pass `account_id` through in whatever form the originating operation supplied, which is commonly a dashed 36-character UUID but can also be undashed hexadecimal. Always normalize identifiers before you join or compare them. Comparing a dashed identifier to an undashed one for the same account reads as two different accounts.
{% endhint %}

### Account events

The account event types are grouped into the following tables by domain: lifecycle, credentials, access, authentication, and vault transfers.

#### Account lifecycle events

The following table lists the account lifecycle event types available for subscription:

| Event type               | `data` fields                                                                                                               | Notes                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account.created`        | `account_id`, `application`, `domain`, `requestor_id`, `vault_id`                                                           | `vault_id` is null for an account created without a vault.                                                                                                                                                                                                                                                                                                                                                        |
| `account.disabled`       | `account_id`, `account_name`, `application`, `requestor_id`                                                                 | `account_name` is PII. Delivered once per action rather than once per state change, so a repeated disable delivers again.                                                                                                                                                                                                                                                                                         |
| `account.enabled`        | `account_id`, `account_name`, `application`, `requestor_id`                                                                 | `account_name` is PII. Same per-action delivery as `account.disabled`.                                                                                                                                                                                                                                                                                                                                            |
| `account.vault.assigned` | `account_id`, `requestor_id`, `temporary`, `vault_id`                                                                       | Covers a vault assigned directly to an account. `temporary` is true when Cerby provisioned the vault for one of its own automations. Treat it as non-standing access: the grant stops working 24 hours after Cerby creates it, and Cerby removes it when the automation succeeds. No event reports the removal or the expiry, so do not wait for one before retiring a `temporary: true` grant from your records. |
| `account.policy.updated` | `actor_id`, `auto_mfa_setup`, `auto_password_rotation`, `mfa_validation`, `password_rotation_days`, `policy_id`, `provider` | Workspace-scoped, so it carries no `account_id`. The payload is the state after the change, not a difference. `provider` is `default` for the workspace-wide policy.                                                                                                                                                                                                                                              |

#### Account credential events

The following table lists the account credential event types available for subscription:

| Event type                            | `data` fields                                                         | Notes                                                                                                                                                                                                       |
| ------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account.credentials.updated`         | `account_id`, `application`, `credentials_identifier`                 | A user edited the stored credentials. `credentials_identifier` is the username or email, and is PII.                                                                                                        |
| `account.credentials.rotated`         | `account_id`, `application`, `credentials_identifier`                 | A rotation automation changed the credentials.                                                                                                                                                              |
| `account.credentials.rotation_failed` | `account_id`, `application`, `automation_job_id`, `automation_link`   | Carries an `error` object.                                                                                                                                                                                  |
| `account.credentials.breach_detected` | `account_id`, `breach_count`, `breached_credential_id`, `detected_at` | An alert event that carries an `error` object with code `CredentialBreached`. Refer to [Handle breach detection events](/developer-tools/cerby-webhooks/webhook-events-for-credential-breach-detection.md). |

#### Account access events

The following table lists the account access event types available for subscription:

| Event type                             | `data` fields                                                                   | Notes                                                                                                                                                                                                                                              |
| -------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account.access.shared`                | `account_id`, `account_name`, `application`, `new_teams_ids`, `role`            | An account was shared with a team. `account_name` is PII. May co-fire with `account.access.shared_with_user`.                                                                                                                                      |
| `account.access.shared_with_user`      | `account_id`, `account_name`, `application`, `new_users_ids`, `role`            | An account was shared with individual users. May co-fire with `account.access.shared`.                                                                                                                                                             |
| `account.access.role_changed`          | `account_id`, `account_name`, `role`, `requestor_id`, `users_with_role_changed` | `users_with_role_changed` is a sorted list, not a single identifier. Delivered once per action rather than once per actual change. Team-only and organization-only role changes emit nothing.                                                      |
| `account.access.revoked`               | `account_id`, `account_name`, `removed_user_ids`, `requestor_id`                | Delivered once per action rather than once per actual change. Team-only and organization-only revocations emit nothing. Never treat the absence of this event as proof that no revocation occurred.                                                |
| `account.access.ownership_transferred` | `account_id`, `owner_ids_after`, `owner_ids_before`, `requestor_id`             | Fires only from administrator offboarding. The transfer adds an owner without removing the departing owner. Refer to [Handle account ownership transfer events](/developer-tools/cerby-webhooks/webhook-events-for-account-ownership-transfer.md). |

#### Account authentication events

The following table lists the account authentication event types available for subscription:

| Event type                            | `data` fields                                                       | Notes                                                                                                                                                                                                                            |
| ------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account.login.failed`                | `account_id`, `application`                                         | Carries an `error` object whose `code` comes from a separate set of reason codes. Refer to the [Login failure reason codes](#login-failure-reason-codes) section.                                                                |
| `account.mfa.enabled`                 | `account_id`, `application`, `automation_job_id`                    | An MFA setup automation succeeded. Correlates with `account.mfa.setup_failed` by `automation_job_id`.                                                                                                                            |
| `account.mfa.disabled`                | `account_id`, `provider`                                            | MFA was removed from an account.                                                                                                                                                                                                 |
| `account.mfa.setup_failed`            | `account_id`, `application`, `automation_job_id`, `automation_link` | Carries an `error` object.                                                                                                                                                                                                       |
| `account.session.terminated`          | `rotate_passwords`, `user_id`                                       | Scoped to a user, not an account, so it carries no `account_id`. Fires once per universal logout, covering all that user's sessions. `rotate_passwords` is the request flag from the trigger, not proof that rotation completed. |
| `account.trusted_session.established` | `device_id`, `user_id`                                              | Scoped to a user, so it carries no `account_id`. Fires on approval of a device, not on request. A rejected or expired request emits nothing.                                                                                     |

#### Account vault transfer events

The following table lists the account vault transfer event types available for subscription. All four carry the same `data` fields, where `transfer_id` ties the stages of one transfer together, `requestor_id` is the user who opened the transfer, and `actor_id` is the user who performed that stage.

| Event type                         | `data` fields                                                                                      | Notes                                                                                                           |
| ---------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `account.vault_transfer.requested` | `account_id`, `actor_id`, `destination_vault_id`, `requestor_id`, `source_vault_id`, `transfer_id` | A transfer was opened and is awaiting approval. Cerby delivers these events for accounts only, not for secrets. |
| `account.vault_transfer.accepted`  | `account_id`, `actor_id`, `destination_vault_id`, `requestor_id`, `source_vault_id`, `transfer_id` | An approver approved the transfer and the move is enqueued. The completion of the move itself is not an event.  |
| `account.vault_transfer.rejected`  | `account_id`, `actor_id`, `destination_vault_id`, `requestor_id`, `source_vault_id`, `transfer_id` | An approver rejected the transfer. Nothing moves.                                                               |
| `account.vault_transfer.cancelled` | `account_id`, `actor_id`, `destination_vault_id`, `requestor_id`, `source_vault_id`, `transfer_id` | The requester withdrew their own transfer, so `actor_id` and `requestor_id` are the same user. Nothing moves.   |

### Automation outcome events

Two catch-all events fire for every terminal automation, whatever the action. Both carry the base automation `data` fields `account_id`, `application`, `automation_job_id`, and `automation_link`, plus the fields listed below.

The following table lists the automation outcome event types available for subscription:

| Event type             | Extra `data` fields                                           | Notes                                                                                                                                   |
| ---------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `automation.succeeded` | `action`, `child_count`, `child_failure_count`                | Fires when any automation completes successfully. It fires alongside the matching action-specific success event for the same job.       |
| `automation.failed`    | `action`, `child_count`, `child_failure_count`, `remediation` | Fires when any automation fails. Carries an `error` object. Refer to the [Automation failure codes](#automation-failure-codes) section. |

`automation.succeeded` fires when a job reaches terminal completion even if some child operations in a bulk run failed. To detect a partially failed bulk run, read `child_failure_count` rather than reading the catch-all as confirmation that every child succeeded.

### Automation completion events

Each of the following event types fires for one automation action. All five carry the base automation `data` fields `account_id`, `application`, `automation_job_id`, and `automation_link`.

The following table lists the automation completion event types available for subscription:

| Event type                       | Extra `data` fields                  | Notes                                  |
| -------------------------------- | ------------------------------------ | -------------------------------------- |
| `automation.users.provisioned`   | `child_count`, `child_failure_count` | Users were provisioned into an app.    |
| `automation.users.deprovisioned` | `child_count`, `child_failure_count` | Users were deprovisioned from an app.  |
| `automation.users.role_updated`  | `child_count`, `child_failure_count` | User roles or access were updated.     |
| `automation.password.rotated`    | None                                 | A password was rotated successfully.   |
| `automation.mfa.enabled`         | None                                 | MFA was enabled through an automation. |

{% hint style="info" %}
**NOTE:** `automation_link` is the public API URL for the source automation job, in the form `GET https://<workspace>.cerby.com/api/v1/jobs/<automation_job_id>`. Fetching it requires an `X-API-Key` with the **Read automated jobs** (`read:automations`) scope. Two preconditions apply: the workspace must have public API access turned on, and the key's user must have visibility of the job's target account. A key user without account visibility returns 401, and a key missing the scope returns 403. It is not a browser link. To inspect the job interactively, use the Automation Dashboard in Cerby.
{% endhint %}

{% hint style="info" %}
**NOTE:** `action` is an opaque pass-through of the internal workflow code that identifies the automation type, for example `password_rotation` or `provide_access`. Treat any value as an opaque string, and do not condition logic on specific values.
{% endhint %}

### How the rollup counts work

A bulk run is an execution group of several automation jobs, and each job in the group emits its own event. There is no separate parent event. The `child_count` and `child_failure_count` pair rides on the event of whichever job finds the group with no automation work left: `child_count` is the number of automation jobs in the group, and `child_failure_count` is how many of them ended in error. Every other member's event carries both keys present and null.

The counts appear only on runs that targeted more than one automation job. A single-target automation, which includes every run launched from the Cerby web app or the browser extension, carries both keys present and null throughout. Treat a non-null `child_count` as the marker of a multi-target run rather than as a field every automation event fills in.

Apply the following rules when you consume the rollup counts:

* **Usually one envelope carries the tally,** which is the member that found the group finished.
* **Several envelopes may carry identical counts** when the last members finish at the same moment. Each has its own `event_id`, so deduplicating by event will not collapse them, and because the counts agree either one answers the question.
* **Occasionally no envelope carries a tally.** Never block on a rollup envelope arriving, and treat its absence as normal.
* **A tally can be superseded by a higher one.** An early finisher can report a low `child_count` for a group that is still growing. When two envelopes disagree, the one with the later `occurred_at` is authoritative.

{% hint style="info" %}
**NOTE:** `child_count` is the size of the run, not a count of webhooks to expect. It counts the run's automation jobs and leaves out internal background work. A subscription covering only some automation event types receives fewer deliveries than the count. Do not reconcile `child_count` against the number of envelopes you received and read the difference as lost deliveries.
{% endhint %}

### Deprecated event types

The following event type is still delivered and fully supported for existing subscribers, but a correctly named replacement exists and Cerby rejects new subscriptions to it:

| Event type                              | `data` fields                 | Replacement                  |
| --------------------------------------- | ----------------------------- | ---------------------------- |
| `automation.universal_logout.completed` | `rotate_passwords`, `user_id` | `account.session.terminated` |

{% hint style="danger" %}
**IMPORTANT:** If you subscribe to `automation.universal_logout.completed` today, three things about it changed. First, it now delivers, where previously it could not fire at all. Second, its `data` fields are now `user_id` and `rotate_passwords`, so a handler written against the previously documented automation fields will not find them. Third, subscribing to both this event type and `account.session.terminated` delivers two webhooks per logout, with different `event_id` values. Replace the old event type rather than holding both.
{% endhint %}

### Test event

Cerby sends the following event type only when you use the **Test endpoint** action in the Cerby web app, never from real account or automation activity. It is not subscribable, and it is delivered to the endpoint under test through the same signing and rendering as a real delivery.

| Event type     | `data` fields | Notes                                                                                                |
| -------------- | ------------- | ---------------------------------------------------------------------------------------------------- |
| `webhook.test` | `application` | `application` is always `Cerby test message`. Carries no PII and no account or automation reference. |

### Event types reserved for future delivery

You can subscribe to the following event type today, but Cerby does not deliver it yet. Subscribing succeeds and no request is ever sent to your endpoint:

| Event type        | `data` fields   | Notes                                                                                                 |
| ----------------- | --------------- | ----------------------------------------------------------------------------------------------------- |
| `account.deleted` | Not yet defined | The event picker marks this type as **Not delivered**. Cerby defines its payload when delivery ships. |

Subscribe to a reserved type when you want the subscription in place ahead of delivery. Cerby delivers to everyone already subscribed as soon as the event ships.

{% hint style="danger" %}
**IMPORTANT:** Do not build a handler against an assumed payload for a reserved event type. Cerby has not defined the `data` fields for `account.deleted`, and the fields it carries when delivery ships may differ from anything published earlier. Confirm the payload against this article before you process the event.
{% endhint %}

### Event types not available for subscription

The following event types are reserved for a future release and are not yet open for subscription. Subscribing to them currently returns HTTP 400:

* `automation.users.attributes_updated`
* `automation.users.synced`

## Automation failure codes

The `automation.failed` event type covers all automation failures. The specific failure is identified by `error.code`, not by the event type. Individual error codes are not separately subscribable.

The following table lists the error codes that carry a remediation category in this release, grouped by tier. Any other internal error name can also appear in `error.code`, in which case `message` is still populated and `user_action` is null:

| `error.code`                         | Tier | Description                                                         | `error.user_action`       | `data.remediation`                                  |
| ------------------------------------ | ---- | ------------------------------------------------------------------- | ------------------------- | --------------------------------------------------- |
| `NotEnoughSeats`                     | 0    | Not enough available licenses or seats to complete the action       | `add_licenses`            | `{ seats_required, seats_available, license_type }` |
| `SubscriptionDeactivated`            | 0    | The app subscription is deactivated                                 | `reactivate_subscription` | null                                                |
| `UnderpermissionedAccount`           | 0    | The Cerby-managed account lacks permission for the action           | `grant_permissions`       | null                                                |
| `InvalidBusinessId`                  | 0    | The configured business or tenant ID is invalid                     | `fix_business_id`         | null                                                |
| `BadCredentials`                     | 1    | The stored username or password is incorrect                        | `review_credentials`      | null                                                |
| `EmptyPasswordError`                 | 1    | No password is saved for the account                                | `review_credentials`      | null                                                |
| `AccountVaultError`                  | 1    | The credentials sit in a local vault Cerby cannot decrypt           | `contact_support`         | null                                                |
| `EmailNotProvidedError`              | 2    | No email is configured in Cerby for the account                     | `update_contact_info`     | null                                                |
| `EmailNotManagedError`               | 2    | The email verification challenge needs a Cerby-managed email        | `configure_mfa`           | null                                                |
| `InvalidEmail`                       | 2    | The email is invalid, expired, or already used                      | `update_contact_info`     | null                                                |
| `PhoneNotProvidedError`              | 2    | No phone is configured in Cerby                                     | `update_contact_info`     | null                                                |
| `PhoneNotManagedError`               | 2    | The verification phone is not Cerby-managed                         | `configure_mfa`           | null                                                |
| `GeneralMFANotManagedError`          | 2    | MFA is not Cerby-managed or is turned off                           | `configure_mfa`           | null                                                |
| `MFANotManagedError`                 | 2    | The authenticator app is not Cerby-managed                          | `configure_mfa`           | null                                                |
| `MFAAlreadyEnable`                   | 2    | A non-Cerby authenticator is already active                         | `configure_mfa`           | null                                                |
| `OTPNotReceived`                     | 2    | The one-time passcode never arrived                                 | `configure_mfa`           | null                                                |
| `OtherMFAMethodEnabledError`         | 2    | Another non-Cerby verification method is turned on                  | `configure_mfa`           | null                                                |
| `VerificationMethodsNotManagedError` | 2    | No available verification method is Cerby-managed                   | `configure_mfa`           | null                                                |
| `SecureDeviceNotFoundInStorage`      | 3    | The Cerby browser extension has no secure session                   | `reauthenticate`          | null                                                |
| `CaptchaResolutionRequired`          | 4    | Manual CAPTCHA resolution was required to continue                  | `retry_later`             | null                                                |
| `BotNotAvailable`                    | 4    | The automation bot is not available                                 | `contact_support`         | null                                                |
| `EnterMissingInformation`            | 4    | The provider asked for information Cerby does not hold              | `provide_information`     | null                                                |
| `InvalidOTP`                         | 4    | The one-time passcode was not correct for the provider's challenge  | `configure_mfa`           | null                                                |
| `InvalidTOTP`                        | 4    | The authenticator code was not correct for the provider's challenge | `configure_mfa`           | null                                                |
| `BlockedAccount`                     | 5    | The provider has blocked the account                                | `contact_support`         | null                                                |
| `SuspendedAccountError`              | 5    | The provider has suspended the account                              | `contact_support`         | null                                                |
| `TooManySavedLogins`                 | 5    | The app's limit of saved logins has been reached                    | `contact_support`         | null                                                |
| `LoginLimitReachedError`             | 5    | The app's login limit has been reached                              | `retry_later`             | null                                                |
| `TooManyLogins`                      | 5    | The app temporarily restricted access after too many logins         | `retry_later`             | null                                                |
| `TooManyAttempts`                    | 5    | The app temporarily restricted access after too many attempts       | `retry_later`             | null                                                |
| `ConfirmValuesMatch`                 | 5    | A value stored in Cerby does not match the one in the app           | `review_credentials`      | null                                                |
| `PasswordChangedError`               | 5    | The password is out of sync with Cerby                              | `review_credentials`      | null                                                |

{% hint style="info" %}
**NOTE:** `MFAAlreadyEnable` is the exact value Cerby sends. The missing letter is intentional, so match the value as written.
{% endhint %}

The following table describes what each tier means for remediation:

| Tier | Meaning                                                                                                                                            |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0    | Remediate outside Cerby, then retry the automation.                                                                                                |
| 1    | Fix the stored credentials or the vault in Cerby.                                                                                                  |
| 2    | Fix the verification or MFA configuration in Cerby.                                                                                                |
| 3    | Re-establish the browser extension trusted session.                                                                                                |
| 4    | The provider demanded a step Cerby cannot complete unattended. Re-run the automation, supply what it asks for, or wait for the bot.                |
| 5    | Provider-side state. Wait out a throttle, fix the value in the app, or take it up with the provider. Nothing in your Cerby configuration is wrong. |

The following table describes each `user_action` value:

| `user_action`             | Meaning                                                                      |
| ------------------------- | ---------------------------------------------------------------------------- |
| `review_credentials`      | Update the username or password stored in Cerby                              |
| `update_contact_info`     | Add or correct an email or phone number in Cerby                             |
| `configure_mfa`           | Set up or transfer MFA management to Cerby                                   |
| `reauthenticate`          | Re-establish the Cerby browser extension trusted session                     |
| `contact_support`         | Contact Cerby Support to resolve the issue                                   |
| `add_licenses`            | Purchase additional seats in the target application                          |
| `reactivate_subscription` | Reactivate the target application subscription                               |
| `grant_permissions`       | Grant the Cerby-managed account the required permissions                     |
| `fix_business_id`         | Correct the business or tenant ID configured in Cerby                        |
| `retry_later`             | Wait for a provider throttle or lockout to clear, then re-run the automation |
| `provide_information`     | Supply information the provider asked for that Cerby does not hold           |

Only `NotEnoughSeats` populates `data.remediation` with machine-readable detail in this release. Every other code delivers `data.remediation` as null, so branch on `error.code` and `user_action` rather than on `remediation` being populated.

### Login failure reason codes

The `account.login.failed` event type uses its own set of reason codes, which are lowercase and never appear on any other event type. The following table lists them:

| `error.code`      | Meaning                                                                 | `error.user_action`  |
| ----------------- | ----------------------------------------------------------------------- | -------------------- |
| `bad_credentials` | The stored username or password was rejected when logging in to the app | `review_credentials` |
| `mfa_required`    | The app asked for an MFA code Cerby does not manage for this account    | `configure_mfa`      |
| `captcha`         | The app presented a CAPTCHA challenge Cerby cannot complete             | `retry_later`        |
| `account_locked`  | The app reported the account as locked or blocked                       | `contact_support`    |
| `unknown`         | The login attempt failed for a reason Cerby could not classify          | `contact_support`    |

{% hint style="info" %}
**NOTE:** An `unknown` code does not rule out a credential problem. Failures Cerby cannot classify, including genuine credential rejections on a login page the automation could not parse, arrive as `unknown`. The `account_locked` code is also a mixed category, covering temporary provider throttling alongside durable blocks and suspensions.
{% endhint %}

Cerby collapses repeated failures for the same account and reason into a single delivery within a fixed 60-second window. The bound is one delivery per window rather than one per any 60 seconds, so two failures that straddle a window boundary can both deliver. The reporting user is not part of the collapse key, so two people failing on a shared account inside one window arrive as one delivery.

## Delivery semantics

This section describes the guarantees your receiver can rely on when processing Cerby webhook deliveries: how to handle duplicate deliveries, how Cerby retries failed requests, and how your endpoint's response code determines what happens next. Understanding these semantics helps you build a receiver that fails safely instead of dropping events or exhausting the retry budget.

### At-least-once delivery and deduplication

Cerby guarantees at-least-once delivery. Your receiver may receive the same event more than once, on retries or in rare cases as replays. Deduplicate as follows:

* **Per delivery:** Use `X-Cerby-Delivery`, which is constant across retries, combined with `X-Cerby-Attempt`, which rises. A retry is identified only by a repeated `X-Cerby-Delivery` with a higher `X-Cerby-Attempt`.
* **Per event:** Use `event_id` to collapse the same logical event across different webhooks or delivery channels.

Do not deduplicate on payload content, URL, or signature equality. A re-send from Cerby Support reuses the original `X-Cerby-Delivery`, so a receiver that deduplicates correctly does not double-process it.

### Retry policy

The following table describes the retry policy parameters:

| Parameter        | Value                                                                                                     |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| Max attempts     | 6, which is 1 initial attempt plus 5 retries                                                              |
| Initial interval | 60 seconds                                                                                                |
| Backoff          | Four times per attempt, which is approximately 1 minute, 4 minutes, 16 minutes, 64 minutes, and 4.3 hours |
| Max interval     | 6 hours                                                                                                   |
| Hard deadline    | 24 hours total                                                                                            |

A delay driven by a `Retry-After` header consumes one of the six attempts, because it is a real attempt you asked Cerby to defer.

### Response handling

Your endpoint must return a 2xx response within 25 seconds. Respond quickly and process the event asynchronously, because slow synchronous processing risks burning your retry budget.

The following table describes how Cerby handles each response from your endpoint:

| Your endpoint returns                     | Cerby behavior                                                                |
| ----------------------------------------- | ----------------------------------------------------------------------------- |
| `2xx`                                     | Delivery complete.                                                            |
| `408`, `429`, or `503` with `Retry-After` | Retried. Cerby honors `Retry-After`, clamped to one hour.                     |
| Any other `4xx`                           | Fail-fast. Cerby dead-letters the delivery immediately and does not retry it. |
| `5xx`, timeout, or TCP error              | Retried with standard backoff.                                                |
| `3xx`                                     | Refused. Cerby does not follow redirects.                                     |

A 401 or 404 response fails fast and will not heal on retry. Fix the endpoint configuration and ask Cerby Support to re-send if needed.

{% hint style="info" %}
**NOTE:** Repeated delivery failures also open a per-endpoint circuit breaker, which pauses delivery to that endpoint and retries on a cooldown. For how to recognize and clear that state, refer to [Troubleshooting: Webhook deliveries failing](https://help.cerby.com/tips-and-troubleshooting/troubleshooting/webhooks/troubleshooting-webhook-deliveries-failing).
{% endhint %}

## Correlating related events

Most events stand alone. When you do need to connect events, Cerby provides two different keys and one field that is not a key at all. Choosing the wrong one silently discards data, so use the following guidance:

* **`data.automation_job_id` correlates within one automation job.** Every event a single job emits carries it. It does not reach across jobs, because a retried operation is a new job with a new identifier. Use it to recognize that a specific event and a catch-all event describe the same job.
* **`correlation_id` correlates across jobs.** It is a deterministic key over the workspace, the account, and the kind of work, so a failure and its eventual fix agree on it without either one knowing about the other. Use it to answer whether a failure was ever resolved.
* **`trace_id` is a support handle, not a key.** It identifies the Cerby operation behind the event. One operation routinely produces events for many unrelated accounts, and all of them carry the same `trace_id`. Grouping or deduplicating on it discards real events for other accounts. Quote it to Cerby Support and nothing else.

Three properties of `correlation_id` decide how you should consume it:

* **It is a stream identifier, not an incident identifier.** Every failure and fix cycle on the same account and the same kind of work shares one key indefinitely, and there is no end-of-chain marker. Order a chain by `occurred_at`, break ties by `event_id`, and read a failure as resolved by the next success event after it. Scope your queries to a time window rather than fetching a whole key.
* **One occurrence can produce several events that share the key.** Cerby reports some outcomes under both a specific event type and a catch-all, so a single rotation failure can arrive as both `account.credentials.rotation_failed` and `automation.failed`. These are distinct events with distinct `event_id` values, not redeliveries, and deduplicating will not merge them.
* **It is scoped, not universal.** Only password rotation and MFA setup events carry it today. Null means the event asserts nothing about causality, so never read null as confirmation that no fix exists.

{% hint style="info" %}
**NOTE:** Events sourced from automation jobs fire in both namespaces for the same job. One successful MFA setup delivers three webhooks: `account.mfa.enabled`, `automation.mfa.enabled`, and the `automation.succeeded` catch-all. A failed rotation or MFA setup delivers two. This is intentional, not a duplicate-delivery problem. For a single signal per outcome, subscribe to one exact event type or filter in your handler.
{% endhint %}

## Sending to a Slack Incoming Webhook

When your endpoint URL is a Slack Incoming Webhook at `hooks.slack.com`, Cerby delivers a Slack Block Kit message instead of the standard JSON envelope. This format is required for successful Slack delivery.

The Block Kit message is a lossy subset of the full event payload. It includes the following fields:

* Event type
* Workspace ID
* Application, when present in `data`
* Account ID, when present in `data`
* User ID, when present in `data`, for the event types scoped to a user
* Automation job ID, when present in `data`, labeled "Automation Job" and rendered as plain text
* The `occurred_at` timestamp
* For failure and alert events, `error.code`, `error.message`, and `error.user_action`, where `user_action` is labeled "Remediation"

Fields present in the standard envelope that the Block Kit message does not include are `data.action`, `data.automation_link`, `trace_id`, `correlation_id`, and all other `data` fields not listed above. HTTPS receivers receive the full payload including these fields.

Cerby colors the attachment sidebar based on severity, using `#2EB67D` for success events and `#D50200` for failure and alert events.

The following is an example Block Kit message for a success event:

```json
{
  "text": "account.credentials.rotated — Salesforce",
  "attachments": [
    {
      "color": "#2EB67D",
      "blocks": [
        { "type": "header", "text": { "type": "plain_text", "text": "account.credentials.rotated" } },
        { "type": "section", "fields": [
          { "type": "mrkdwn", "text": "*Workspace*\n9f8e7d6c-5b4a-4f3e-9d2c-1b0a99887766" },
          { "type": "mrkdwn", "text": "*Application*\nSalesforce" },
          { "type": "mrkdwn", "text": "*Account*\n3fa85f64-5717-4562-b3fc-2c963f66afa6" },
          { "type": "mrkdwn", "text": "*Occurred*\n2026-07-21T16:45:12.031Z" }
        ]}
      ]
    }
  ]
}
```

## Current release limitations

The following limitations affect how you write your receiver:

* **`account.deleted` is not delivered:** The event type is available for subscription, but Cerby does not currently deliver it and has not defined its payload. Refer to the [Event types reserved for future delivery](#event-types-reserved-for-future-delivery) section. To audit account deletions, use the Cerby API or the workspace audit log.
* **No group identifier for bulk runs:** No `data` field names the execution group behind a bulk run, so you cannot join a group's envelopes from their contents alone. Refer to the [How the rollup counts work](#how-the-rollup-counts-work) section.

For the workspace-level limitations, including the regions where webhooks are unavailable and the events Cerby does not emit, refer to the "Current release limitations" section of [Explore webhook notifications](https://help.cerby.com/setup-and-admin/workspace-settings/webhooks/explore-webhook-notifications).

## Related articles

**Feature guides:**

* [Explore webhook notifications](https://help.cerby.com/setup-and-admin/workspace-settings/webhooks/explore-webhook-notifications)
* [Create a webhook](https://help.cerby.com/setup-and-admin/workspace-settings/webhooks/create-a-webhook)

**Event references:**

* [Handle breach detection events](/developer-tools/cerby-webhooks/webhook-events-for-credential-breach-detection.md)
* [Handle account ownership transfer events](/developer-tools/cerby-webhooks/webhook-events-for-account-ownership-transfer.md)

**Troubleshooting:**

* [Troubleshooting: Webhook deliveries failing](https://help.cerby.com/tips-and-troubleshooting/troubleshooting/webhooks/troubleshooting-webhook-deliveries-failing)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://help.cerby.com/developer-tools/cerby-webhooks/implement-a-webhook-receiver.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
