# Using webhooks

A webhook sends an HTTP `POST` to your URL each time an event happens in your account: an email bounces, a
contact unsubscribes, a campaign is scheduled. This guide covers creating and managing webhooks, the payload
your URL receives, checking that a delivery comes from the platform, and the three classes of events.

- [Event catalog](/guides/webhook-event-catalog): every event you can subscribe to, and when it fires.
- [Compliance events](/guides/webhook-compliance-events): the exact payloads of bounces, spam complaints,
  unsubscribes, and refused or failed sends.

## Creating a webhook

A webhook subscribes one URL to one event. To receive several events, create one webhook per event; they can
share a URL.

```bash
curl --request POST \
  --url 'https://api.cakemail.dev/webhooks' \
  --header 'Authorization: Bearer <access token>' \
  --header 'Content-Type: application/json' \
  --data '{
    "event": "Email.Bounced",
    "url": "https://hooks.example.com/platform-events",
    "rate_limit": 10,
    "rate_limit_period": "second"
  }'
```

The response (`201`) returns the webhook:

```json
{
  "id": "<webhook_id>",
  "data": {
    "id": "<webhook_id>",
    "status": "active",
    "event": "Email.Bounced",
    "url": "https://hooks.example.com/platform-events",
    "archived_at": null,
    "rate_limit": 10,
    "rate_limit_period": "second"
  }
}
```

| Field | Required | Description |
|---|---|---|
| `event` | Yes | The event, such as `Email.Bounced`. See the [event catalog](/guides/webhook-event-catalog). |
| `url` | Yes | Where deliveries go: an `http` or `https` URL. |
| `rate_limit` | No | The most deliveries per period. The default is 50, and the maximum is 50 per second or 3,000 per minute. |
| `rate_limit_period` | No | `second` (the default) or `minute`. |

**Sub-accounts.** Webhook requests act on the account of the access token. A partner or an organization can act
on a sub-account by adding `?account_id=<account_id>` to any webhook request. That webhook then receives the
sub-account's events.

**Access tokens.** With a personal access token, listing and showing webhooks needs the `webhooks:read` scope,
and creating or changing them needs `webhooks:write`.

**Errors.** Error responses have the form `{"detail": [{"msg": "…", "type": "…"}]}`.

| Status | Why |
|---|---|
| `422` | A field is missing or invalid, the rate limit is above the maximum, or the event is a high-volume event and the URL's domain isn't approved (`High volume events can only be registered with approved webhook domains`). |
| `400`, `code` 2600 | `Webhook not found`: the id doesn't exist, or belongs to another account. |
| `400`, `code` 2604 | The new URL of a high-volume webhook isn't on an approved domain. |

See [Create a webhook](/api/webhook#create-a-webhook) in the API reference for every field.

## Managing webhooks

| Request | What it does |
|---|---|
| `GET /webhooks` | Lists the account's webhooks. Add `with_archived=true` to include archived ones. Paginate with `page`, `per_page` and `with_count`. |
| `GET /webhooks/{webhook_id}` | Shows one webhook, and the account's signing key. |
| `PATCH /webhooks/{webhook_id}` | Changes `url`, or `rate_limit` and `rate_limit_period` together. The event can't be changed: create another webhook. |
| `POST /webhooks/{webhook_id}/archive` | Stops deliveries. |
| `POST /webhooks/{webhook_id}/unarchive` | Starts them again. |

## Receiving events

Each delivery is a `POST` with a JSON body, a `Content-Type: application/json` header and an `x-signature`
header (see [Securing your webhooks](#securing-your-webhooks)).

### The payload envelope

Every event has the same envelope. This is a campaign bounce:

```json snippet=webhook-events:Email.Bounced:campaign.json
{
  "id": "570bfeff-8a80-5f41-bb57-f2f343e49381",
  "event": "Email.Bounced",
  "account_id": 1234,
  "account_lineage": null,
  "ip": "192.0.2.20",
  "user": {
    "id": null,
    "email": null,
    "account_id": null
  },
  "created_on": 1790431393,
  "data": {
    "bounce": {
      "id": null,
      "dsn_message": "smtp; 550 5.1.1 <jane@example.com>: Recipient address rejected: User unknown",
      "source_ip": "192.0.2.10",
      "category": "bounce_hb"
    },
    "campaign": {
      "type": "campaign",
      "id": 4321,
      "name": "September newsletter"
    },
    "context": {
      "timestamp": null,
      "user_agent": null,
      "ip": null
    },
    "contact": {
      "id": 5678,
      "email": "jane@example.com",
      "status": null
    },
    "list": {
      "id": 111,
      "name": "Newsletter subscribers"
    },
    "segment": {
      "id": null,
      "name": null,
      "query": null
    }
  }
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | The event's unique id. It stays the same when an event is delivered again, so deduplicate on it. |
| `event` | string | The event name. |
| `account_id` | integer | The account the event belongs to. |
| `account_lineage` | null | Always `null`. |
| `ip` | string | Where the event came from. For an event caused by an API request (an account, campaign, sender, user or export event), it's the address that made the request. For an event the platform records itself, such as a bounce, a complaint or an unsubscribe, it's a platform server, never the recipient: when the recipient's address is known, it's in `data.context.ip`. |
| `user` | object | The `id`, `email` and `account_id` of the user whose action caused the event, when the platform knows it. They're `null` when no user acted, as for a bounce, a complaint or an unsubscribe, and can be `null` for an event caused by an API request. |
| `created_on` | integer | When this delivery was created, in Unix seconds. It isn't when the event happened, and it changes when a delivery is repeated. Email API events have the event time in `data.event_time`. |
| `data` | object | The event's details. Its shape depends on the event and on how the email was sent: see [Compliance events](/guides/webhook-compliance-events). |

### Handling deliveries

- Answer with a `2xx` status quickly, and do the work afterwards.
- The same event can arrive more than once. Deduplicate on `id`.
- Events can arrive in any order. When `data.event_time` or `data.context.timestamp` is present, use it to order
  them.
- A webhook's `rate_limit` caps how fast deliveries reach your URL.

## Securing your webhooks

The `x-signature` header of each delivery is the HMAC-SHA256 of the raw request body, keyed with your account's
signing key, written as lowercase hexadecimal. Compute it and compare: a match proves that the platform sent this
exact body.

1. Get the signing key. `GET /webhooks/{webhook_id}` returns it in `signature.key`. An account has one key,
   shared by all its webhooks.

   ```bash
   curl --request GET \
     --url 'https://api.cakemail.dev/webhooks/<webhook_id>' \
     --header 'Authorization: Bearer <access token>'
   ```

   ```json
   {
     "id": "<webhook_id>",
     "signature": { "key": "<signing key>", "hash_function": "sha256" },
     "data": {
       "id": "<webhook_id>",
       "status": "active",
       "event": "Email.Bounced",
       "url": "https://hooks.example.com/platform-events",
       "archived_at": null,
       "rate_limit": 10,
       "rate_limit_period": "second"
     }
   }
   ```

2. Verify the body exactly as received, before parsing it. Parsing and re-serializing the JSON changes the bytes,
   and the signature no longer matches.

Node.js:

```js snippet=webhook-verify:verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (a Buffer or a string), before any JSON parsing.
// signature: the x-signature header. signingKey: signature.key from GET /webhooks/{webhook_id}.
export function isValidSignature(rawBody, signature, signingKey) {
  const expected = Buffer.from(createHmac("sha256", signingKey).update(rawBody).digest("hex"));
  const received = Buffer.from(String(signature ?? ""));
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

With Express, read the raw body:

```js
import express from "express";
import { isValidSignature } from "./verify.mjs";

const app = express();

app.post("/platform-events", express.raw({ type: "application/json" }), (req, res) => {
  if (!isValidSignature(req.body, req.get("x-signature"), process.env.WEBHOOK_SIGNING_KEY)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  // Skip event.id if you've seen it, queue the work, and answer right away.
  res.sendStatus(204);
});
```

Python:

```python snippet=webhook-verify:verify.py
import hashlib
import hmac


def is_valid_signature(raw_body: bytes, signature: str, signing_key: str) -> bool:
    """raw_body: the request body exactly as received, before any JSON parsing.
    signature: the x-signature header. signing_key: signature.key from GET /webhooks/{webhook_id}."""
    expected = hmac.new(signing_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode("utf-8"), (signature or "").encode("utf-8"))
```

With Flask:

```python
import os

from flask import Flask, abort, request

from verify import is_valid_signature

app = Flask(__name__)


@app.post("/platform-events")
def platform_events():
    signature = request.headers.get("x-signature", "")
    if not is_valid_signature(request.get_data(), signature, os.environ["WEBHOOK_SIGNING_KEY"]):
        abort(401)
    event = request.get_json()
    # Skip event["id"] if you've seen it, queue the work, and answer right away.
    return "", 204
```

## Event classes

| Class | Events | What it needs |
|---|---|---|
| Standard | Account, campaign, export, list, segment, sender, suppression and user events | Any URL. |
| Compliance | `Email.Bounced`, `Email.ReportedAsSpam`, `Email.Unsubscribed`, `Email.GlobalUnsubscribed`, `Email.Rejected`, `Email.Error`, `Contact.Removed` | Any URL. Their payloads are in [Compliance events](/guides/webhook-compliance-events). |
| High-volume | `Contact.Added`, `Contact.Updated`, `Email.Sent`, `Email.Opened`, `Email.Clicked`, `Email.Submitted`, `Email.Queued`, `Email.Processing`, `Email.Delivered` | A URL on an approved domain. Contact support to have a domain approved. |

Which events fire also depends on how you send: see [the event catalog](/guides/webhook-event-catalog#which-events-fire-depends-on-how-you-send).

## Limitations

- A webhook subscribes to one event, and its event can't be changed.
- A failed delivery isn't guaranteed to be retried. If your URL was unavailable, reconcile from the API.
- Payloads are documented for compliance events and `SuppressedEmail.Added` only.
