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

# Webhooks Overview

> Receive real-time HTTP callbacks when emails are sent, replied to, bounced, and when leads enter subsequence branches.

Webhooks push events to an HTTPS endpoint you control, so you can react to a reply the moment it lands instead of polling for it.

<Info>
  Webhook subscriptions are **workspace-scoped**, unlike the contact directory which is team-scoped. A subscription only receives events from the workspace of the API key that created it.
</Info>

## Quick Start

```python theme={null}
response = requests.post(
    f"{base_url}/api/public/v1/webhooks",
    headers={"X-API-Key": api_key},
    json={
        "name": "Production reply handler",
        "target_url": "https://example.com/hooks/coldsend",
        "event_types": ["reply_received", "email_bounced"]
    }
)

created = response.json()
secret = created["secret"]   # returned exactly once -- store it now
```

<Warning>
  The signing `secret` appears **only** in the create response. It is not returned by `GET /webhooks`, `GET /webhooks/{id}` or `PATCH`, and there is no rotation endpoint. Store it in your secret manager immediately — the only way to get a new one is to delete the subscription and create another.
</Warning>

## Creating a Subscription

**POST** `/api/public/v1/webhooks` — scope `webhooks:create`, returns **201**

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | 1–255 characters |
| `target_url` | string | Yes | Must start with `https://`. Max 2048 characters. Alias: `url`. |
| `event_types` | array | Yes | At least one. See [Event Types](/webhooks/event-types). |
| `event_categories` | array | No | Subscribe to whole groups instead of naming every type |
| `campaign_id` | UUID | No | Restrict to one campaign |

```python theme={null}
requests.post(
    f"{base_url}/api/public/v1/webhooks",
    headers={"X-API-Key": api_key},
    json={
        "name": "Everything about one campaign",
        "target_url": "https://example.com/hooks/coldsend",
        "event_types": ["reply_received", "email_sent", "email_delivered", "email_bounced",
                        "email_opened", "link_clicked", "lead_status_changed",
                        "lead_unsubscribed",
                        "subsequence_entered", "subsequence_completed"],
        "campaign_id": "550e8400-e29b-41d4-a716-446655440000"
    }
)
```

That is the complete list of event types that currently deliver. The API accepts six more, and none of them fire — see the status table in [Event Types](/webhooks/event-types).

<Warning>
  **Not every accepted event type is delivered.** The subscription validator is more permissive than the pipeline behind it: `campaign_completed` and `campaign_started` are never emitted, and `email_queued`, `email_send_failed`, `inbox_paused` and `domain_limit_reached` are emitted internally but dropped before delivery. A subscription naming them is created with a `201` and then stays silent. In particular, `event_categories: ["system"]` matches **only** non-deliverable types, so it receives nothing at all.
</Warning>

An HTTP `target_url` is rejected with a **422**, as is an unknown event type or category. Changes take effect immediately — the API drops the dispatcher's subscription cache on every create, update and delete.

### How filters combine

The three filters are applied in order, and **all** must pass:

1. **Workspace** — the event's workspace must equal the subscription's. This is not configurable.
2. **`event_types`** — the event type must be listed. An empty array delivers **nothing**, not everything.
3. **`campaign_id`** — if set, events with a different campaign ID (or none at all) are dropped.
4. **`event_categories`** — if set, the event type must belong to at least one listed category.

`event_types` and `event_categories` are an **AND**, not an OR. Listing `event_types: ["reply_received"]` alongside `event_categories: ["delivery"]` delivers nothing, because `reply_received` is not in the `delivery` category. Use one or the other.

<Warning>
  `PATCH` with `{"event_types": []}` narrows the subscription to zero events — it does not clear the filter. `event_types` is non-nullable, so there is no "all types" setting: name the types you want.
</Warning>

## Payload Format

Every delivery is a `POST` with a JSON body:

```json theme={null}
{
  "event_type": "reply_received",
  "event_id": "9f8e7d6c-5b4a-3928-1706-f5e4d3c2b1a0",
  "occurred_at": "2026-01-14T09:12:44.512000+00:00",
  "team_id": "11111111-2222-3333-4444-555555555555",
  "campaign_id": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "lead_email": "alice@prospect.com",
    "subject": "Re: Quick question",
    "reply_text": "Sure, send over the case study.",
    "is_auto_reply": false,
    "inbox_email": "john@yourdomain.com"
  }
}
```

The five envelope fields are the same for every event; only `data` varies. Field-by-field shapes are in [Event Types](/webhooks/event-types).

`campaign_id` is `null` for events that are not about a campaign.

### Headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-ColdSend-Event` | The event type, e.g. `reply_received` |
| `X-ColdSend-Delivery-Id` | Stable across retries of the same event |
| `X-ColdSend-Signature` | `sha256=<hex>` — HMAC-SHA256 of the raw body, keyed by your secret |
| `X-ColdSend-Attempt` | `1` on the first try, incrementing on each retry |
| `User-Agent` | `ColdSend-Webhooks/1` |

## Verifying Signatures

Verify the signature on every delivery. Compare against the **raw request body** — parsing and re-serialising the JSON will not match.

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import hashlib
    import hmac

    def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
        if not signature_header.startswith("sha256="):
            return False
        expected = hmac.new(
            secret.encode("utf-8"), raw_body, hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(expected, signature_header.removeprefix("sha256="))
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    const crypto = require("crypto");

    function verify(rawBody, signatureHeader, secret) {
      if (!signatureHeader.startsWith("sha256=")) return false;
      const expected = crypto
        .createHmac("sha256", secret)
        .update(rawBody)
        .digest("hex");
      return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signatureHeader.slice("sha256=".length))
      );
    }
    ```
  </Tab>
</Tabs>

<Warning>
  Use a constant-time comparison — `hmac.compare_digest` or `crypto.timingSafeEqual`. A plain `==` leaks the signature byte by byte through timing.
</Warning>

## Delivery Semantics

| Your response | Meaning | Retried? |
| - | - | - |
| **2xx** | Accepted | No |
| **4xx** | Permanent client error | **No** — recorded as `failed` and abandoned |
| **5xx** | Server error | Yes |
| Timeout / connection error | Unreachable | Yes |

Each attempt has a **10 second** timeout. Retryable failures are re-attempted up to **3** times, at **1 minute**, **5 minutes** and **30 minutes** — 4 attempts in total. After that the delivery is marked `dead` and nothing further happens automatically.

<Warning>
  A `404` from your endpoint — a wrong path, a service not yet deployed — is a **4xx** and is treated as permanent. It will never be retried. Test with `POST /webhooks/{id}/test` before relying on a subscription.
</Warning>

### Respond fast, process later

Deliveries are synchronous HTTP calls with a 10 second budget. Do your verification, persist the payload, return `2xx`, and process asynchronously. A handler that blocks on a downstream API will time out and be retried, which duplicates work.

### Idempotency

Retries reuse the same `X-ColdSend-Delivery-Id` and the same `event_id`. Deduplicate on either — but note that a **redelivery** (below) is a genuinely new attempt at the same event, so keying on `event_id` alone is the safer choice if you must process exactly once.

## Testing a Subscription

**POST** `/api/public/v1/webhooks/{webhook_id}/test` — scope `webhooks:update`

Sends a `ping` event straight to your endpoint, synchronously, and reports what came back:

```python theme={null}
requests.post(
    f"{base_url}/api/public/v1/webhooks/{webhook_id}/test",
    headers={"X-API-Key": api_key}
).json()
# {"success": true, "message": "Received HTTP 200", "response_code": 200}
```

The ping payload carries `event_type: "ping"` and `data: {"message": "Webhook test from ColdSend"}`. It is signed exactly like a real delivery, so it exercises your verification path too.

<Info>
  The test endpoint returns **200 even when the ping fails** — `success: false` with `response_code: null` on a timeout or connection error. Check the body, not the status code. Test deliveries are not written to the delivery log.
</Info>

## Delivery Log

**GET** `/api/public/v1/webhooks/{webhook_id}/deliveries` — scope `webhooks:read`

```python theme={null}
deliveries = requests.get(
    f"{base_url}/api/public/v1/webhooks/{webhook_id}/deliveries",
    headers={"X-API-Key": api_key},
    params={"page": 1, "page_size": 50}
).json()
```

`page_size` is 1–100, default 50. Each entry carries `event_type`, `attempt_number`, `status`, `response_code`, `error`, `attempted_at` and `delivered_at`.

| `status` | Meaning |
| - | - |
| `pending` | In flight |
| `succeeded` | Your endpoint returned 2xx |
| `failed` | 4xx, 5xx, timeout or connection error |
| `dead` | Retries exhausted |

## Redelivering

**POST** `/api/public/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver` — scope `webhooks:update`

Re-queues a delivery for an immediate attempt, bypassing the backoff schedule:

```python theme={null}
requests.post(
    f"{base_url}/api/public/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver",
    headers={"X-API-Key": api_key}
).json()
# {"success": true, "message": "Redelivery queued successfully",
#  "delivery_attempt_id": "...", "attempt_number": 5, "status": "pending"}
```

<Warning>
  Only deliveries in status `failed` or `dead` can be redelivered. Anything else returns **400** naming the current status — including a delivery that `succeeded`, which you cannot force through again this way.
</Warning>

## Statistics

**GET** `/api/public/v1/webhooks/{webhook_id}/stats` — scope `webhooks:read`

```json theme={null}
{
  "subscription_id": "550e8400-e29b-41d4-a716-446655440000",
  "total_deliveries": 1284,
  "succeeded": 1270,
  "failed": 11,
  "dead": 3,
  "success_rate": 98.9,
  "failure_rate": 1.1,
  "avg_response_time_ms": 142.5,
  "last_delivery_at": "2026-01-14T09:12:44Z",
  "active": true
}
```

Watch `dead` — those events are gone unless you redeliver them by hand.

## Managing Subscriptions

| Endpoint | Scope | Notes |
| - | - | - |
| `GET /webhooks` | `webhooks:read` | Every subscription in the workspace |
| `POST /webhooks` | `webhooks:create` | Returns the `secret`, once |
| `GET /webhooks/{id}` | `webhooks:read` | No `secret` |
| `PATCH /webhooks/{id}` | `webhooks:update` | Partial; also toggles `active` |
| `DELETE /webhooks/{id}` | `webhooks:delete` | Returns **204**, no body |

`PATCH` accepts any subset of `name`, `target_url`, `event_types`, `campaign_id`, `event_categories` and `active`. Setting `active: false` pauses delivery without losing the subscription or its secret — the right move while you redeploy an endpoint.

A subscription in another workspace, or one that does not exist, is a `404`.

## Next Steps

<CardGroup cols={2}>
  <Card title="Event Types" icon="zap" href="/webhooks/event-types">
    Every event and its exact `data` shape.
  </Card>

  <Card title="Subsequences" icon="git-branch" href="/campaigns/subsequences">
    What triggers `subsequence_entered`.
  </Card>

  <Card title="API Scopes" icon="key" href="/authentication/scopes">
    The `webhooks:*` family.
  </Card>

  <Card title="Error Codes" icon="alert-triangle" href="/reference/errors">
    Status codes across the whole API.
  </Card>
</CardGroup>


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