# Webhooks

Get a signed HTTP POST from SendBeam the moment a contact, email, campaign, form or domain event happens in your workspace.

A webhook turns SendBeam into a source of live events for your own systems. You register an
HTTPS URL, choose the events you care about, and SendBeam sends that URL a signed JSON
`POST` the moment one of them happens — a contact joining, an email bouncing, a
campaign finishing, a form being submitted, a sending domain verifying.

## What a webhook is

Instead of polling the API asking "has anything changed?", you give SendBeam an address and it
tells you. Each registered address is an **endpoint**. An endpoint has:

- a **URL** — where the request goes;
- a set of **events** — an endpoint receives only the events it subscribes to;
- a **signing secret** — proves a request really came from SendBeam;
- an **enabled** switch, and a delivery log you can read for debugging.

You can register several endpoints and split events between them: one for your CRM, one for your
data warehouse, one for an internal alerting bot. How many you may have depends on your plan —
Free 1, Starter 5, Pro and Business unlimited — counted per workspace, so each site you run gets its own allowance.

> Every plan receives the full event catalog. The plan changes how many endpoints you can register, never which events you can hear about.
> 

## The payload

Every delivery is a `POST` with a JSON body of the same four fields, whatever the
event:

```
{
  "id": "b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "event": "contact.created",
  "created_at": "2026-09-03T09:41:12.204Z",
  "data": {
    "contact": {
      "id": "0f8c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
      "email": "jane@example.com",
      "status": "subscribed",
      "first_name": "Jane",
      "last_name": "Doe",
      "source": "form",
      "custom_fields": {},
      "created_at": "2026-09-03T09:41:12.011Z",
      "subscribed_at": "2026-09-03T09:41:12.011Z",
      "unsubscribed_at": null,
      "tags": []
    }
  }
}
```

- `id` — the delivery's own identifier. It stays the same across retries, so use it as an idempotency key and ignore an `id` you have already processed.
- `event` — the event name, one of the catalog below.
- `created_at` — when the event happened, not when this attempt was made.
- `data` — the object the event is about. Its fields depend on the event: a contact event carries the contact, an email event carries the message and recipient, a domain event carries the domain.

The request also carries these headers:

```
POST /hooks/sendbeam HTTP/1.1
Content-Type: application/json
User-Agent: SendBeam-Webhooks/1.0 (+https://sendbeam.io/docs/api/webhooks)
X-SendBeam-Event: contact.created
X-SendBeam-Delivery: b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e
X-SendBeam-Signature: t=1788500472,v1=1f8b0c9d…
```

Answer quickly with any `2xx` status — that is the whole contract. Anything else, or
no answer within a few seconds, counts as a failure and the delivery is retried. If your handler
has slow work to do, acknowledge first and do the work afterwards. Redirects are never followed:
a `3xx` counts as a failure, so register the final URL.

## Event catalog

There are 26 events. Subscribe to as few or as many as you like on
each endpoint.

### Contacts

| Event | Sent when |
| --- | --- |
| `contact.created` | A new contact was added, by any route (form, import, API, dashboard). |
| `contact.updated` | A contact’s fields, tags membership aside, changed. |
| `contact.unsubscribed` | A contact unsubscribed, or was set to unsubscribed. |
| `contact.resubscribed` | A previously unsubscribed contact subscribed again with fresh consent. |
| `contact.bounced` | A contact’s address hard-bounced and is now suppressed. |
| `contact.complained` | A contact marked a message as spam and is now suppressed. |
| `contact.deleted` | A contact was deleted (an erasure or manual delete). |
| `contact.tag_added` | A tag was added to a contact. |
| `contact.tag_removed` | A tag was removed from a contact. |
| `contact.list_joined` | A contact joined a list (immediately, or on double opt-in confirmation). |
| `contact.list_left` | A contact left or was removed from a list. |

### Email

| Event | Sent when |
| --- | --- |
| `email.sent` | An email was accepted by SendBeam’s managed delivery for sending. |
| `email.delivered` | An email was delivered to the recipient’s mail server. |
| `email.opened` | A recipient opened an email (first open only). |
| `email.clicked` | A recipient clicked a link in an email (first click only). |
| `email.bounced` | An email bounced. |
| `email.complained` | A recipient marked an email as spam. |

### Campaigns

| Event | Sent when |
| --- | --- |
| `campaign.sent` | A campaign finished sending to its whole audience. |

### Forms

| Event | Sent when |
| --- | --- |
| `form.submitted` | A public form (signup or contact) was submitted and accepted. |

### Domains

| Event | Sent when |
| --- | --- |
| `domain.verified` | A sending domain finished DNS verification successfully. |
| `domain.failed` | A sending domain’s verification failed or lapsed. |

### Workspace

| Event | Sent when |
| --- | --- |
| `workspace.paused` | Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working. |
| `workspace.resumed` | Sending started again for this workspace. |
| `workspace.health_warning` | Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause. |

### Automations

| Event | Sent when |
| --- | --- |
| `automation.failed` | An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day. |
| `automation.step_reached` | A contact reached a "Call a webhook" step in one of your automations. Unlike every other event here, this one is sent because an automation asked for it — add the step to an automation and choose which of your endpoints should hear about it. Carries the contact, the automation and the step's own label. |

## Creating an endpoint

### From the dashboard

1. Open the **Webhooks** page in your workspace settings and choose **Add endpoint**.
2. Choose where the events should go: **your server**, which gets the signed JSON described here, or a chat channel — [Slack](#slack) or [Microsoft Teams](#teams).
3. Paste your HTTPS URL and, optionally, a short description so you can tell endpoints apart later.
4. Tick the events this endpoint should receive.
5. Save. The signing secret is shown **once**, on the confirmation screen — copy it into your application's configuration before leaving the page.
6. Use **Send test event** to check your receiver answers, then watch the delivery log as real events arrive.

> The secret is stored so that it can be used for signing but never shown again. If you lose it, rotate it — that issues a new one and invalidates the old.
> 

### Through the API

Endpoint management lives under `/api/v1/webhooks` and uses the
`webhooks:read` and `webhooks:write` permissions on your API key. Creating,
updating and deleting endpoints count as API writes, like any other, towards the plan's hourly allowance.

```
curl -X POST https://sendbeam.io/api/v1/webhooks \
  -H "x-api-key: $SENDBEAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/sendbeam",
    "description": "Sync new contacts into the CRM",
    "event_types": ["contact.created", "contact.unsubscribed"]
  }'
```

The response — the only one that ever contains the secret:

```
{
  "id": "7c2f1e90-3a4b-4c5d-8e9f-1a2b3c4d5e6f",
  "url": "https://example.com/hooks/sendbeam",
  "description": "Sync new contacts into the CRM",
  "event_types": ["contact.created", "contact.unsubscribed"],
  "enabled": true,
  "secret": "9f2c1a0b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
  "created_at": "2026-09-03T09:40:00.000Z"
}
```

The rest of the surface:

| Request | Does |
| --- | --- |
| `GET /api/v1/webhooks` | Lists your endpoints and their health (failure count, last success, last failure). Never returns secrets. |
| `PATCH /api/v1/webhooks/{id}` | Changes the URL, description, events, `filters` or enabled state. |
| `DELETE /api/v1/webhooks/{id}` | Removes the endpoint and its delivery log. |
| `POST /api/v1/webhooks/{id}/rotate-secret` | Issues a new signing secret and returns it. The old one stops working at once. |
| `GET /api/v1/webhooks/{id}/deliveries` | The recent delivery log, newest first. |
| `POST /api/v1/webhooks/{id}/test` | Sends one synthetic event immediately and reports the result. |

Full request and response schemas are in the [API reference](https://sendbeam.io/docs/api#tag-webhooks).

## Scoping an endpoint

By default an endpoint receives every event of the types it subscribes to, from anywhere in the
workspace. An optional `filters` object on create and update narrows that to particular
resources. Recognised keys are `list_ids`, `form_ids`, `tag_ids`,
`campaign_ids` and `domain_ids`; each takes an array of up to 100 ids that
belong to your own workspace, and an id that does not is a `400` rather than a filter
that quietly matches nothing. Omitting `filters`, or sending `{}`,
means unscoped — the behaviour every endpoint has today. Every endpoint response includes the
field, so `GET /api/v1/webhooks` tells you what each one is scoped to.

```
{
  "list_ids": ["b0d1e2f3-4a5b-4c6d-8e9f-0a1b2c3d4e5f"],
  "form_ids": ["3c4d5e6f-7a8b-49c0-8d1e-2f3a4b5c6d7e"]
}
```

A dimension you scope has to be **present on the event** or it will not match, and
dimensions you scope are combined with AND. The example above delivers only events that carry both
that list and that form — a submission of that form joining that list. Scope on one dimension at a
time unless you mean that. Events carry the dimensions that are part of what happened:
`contact.list_joined` and `contact.list_left` carry a list;
`form.submitted` and the contact events a public submission creates carry a form;
`contact.tag_added` and `contact.tag_removed` carry a tag;
`campaign.sent` and the `email.*` events of a campaign send carry a campaign;
`domain.verified` and `domain.failed` carry a sending domain. Anything else
carries none, so a `contact.updated` will never reach an endpoint scoped to a list.

The `workspace.*` events carry no dimension at all, deliberately: they are about the
workspace itself, not about a contact, a list or a campaign. An endpoint scoped to a list will
therefore never receive one — if you want to know that sending stopped, subscribe an
endpoint that is not scoped.

In the dashboard this is the **Only for specific lists, forms or tags** choice on the
Webhooks page, and **Edit scope** on an endpoint you already have.

## Sending to Slack

An endpoint can deliver a readable Slack message instead of JSON. In Slack, add an
**Incoming Webhook** for the channel you want and copy the URL it gives you
(hooks.slack.com/services/…). In SendBeam, add an endpoint, choose
**A Slack channel**, and paste it. There is no Slack app to install and no
automation tool in between.

A Slack channel carries the **operational** events only — the ones that mean
something has stopped or changed and a person should look:

- `workspace.paused` — Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working.
- `workspace.resumed` — Sending started again for this workspace.
- `workspace.health_warning` — Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause.
- `automation.failed` — An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day.
- `campaign.sent` — A campaign finished sending to its whole audience.
- `domain.verified` — A sending domain finished DNS verification successfully.
- `domain.failed` — A sending domain’s verification failed or lapsed.

The contact and email events are deliberately not offered. A channel that receives a message
for every subscriber is a channel people mute, and then the one that mattered — sending
paused — is missed too. Route those through n8n, Make or Zapier, where they can be filtered
and batched: see the [Slack integration page](https://sendbeam.io/integrations/slack).

Every message names its workspace, so several workspaces can post into one channel and still
be told apart, and carries a link to the page that answers it. Delivery works exactly as it
does for a JSON endpoint: the same retries, the same delivery log, and the same auto-disable
if the URL stops working — which is what happens when a Slack webhook is revoked. Re-enable it
after pasting a new URL.

Slack does not verify our signature, so the [signature header](#verifying-the-signature) is irrelevant to a Slack channel; the secrecy of the incoming webhook URL is what
makes it trustworthy. Treat that URL as a credential — anyone holding it can post into the
channel.

## Sending to Microsoft Teams

An endpoint can deliver a card into a Microsoft Teams channel. In Teams, add the
**Workflows** app, create *Post to a channel when a webhook request is received*, and copy the URL it gives you. In SendBeam, add an endpoint, choose
**A Microsoft Teams channel**, and paste it.

> If a guide tells you to add an **Incoming Webhook connector** and gives you an
> outlook.office.com URL, it is out of date. Microsoft retired Office 365
> connectors in May 2026 and those URLs no longer deliver anything. Workflows is the replacement.
> 

A Teams channel carries the same **operational** events as Slack — the ones that mean
something has stopped or changed and a person should look:

- `workspace.paused` — Sending stopped for this workspace — automatically because delivery results deteriorated, or because we paused it. Nothing goes out until it resumes; everything else keeps working.
- `workspace.resumed` — Sending started again for this workspace.
- `workspace.health_warning` — Delivery results for this workspace are deteriorating. Sending continues, but this is the warning before a pause.
- `automation.failed` — An automation could not complete a step — a deleted template, a tag that no longer exists, a send that failed. The automation stays active; the run that hit it stopped. At most one per automation per day.
- `campaign.sent` — A campaign finished sending to its whole audience.
- `domain.verified` — A sending domain finished DNS verification successfully.
- `domain.failed` — A sending domain’s verification failed or lapsed.

Each message is an **Adaptive Card**: a headline coloured by severity, the workspace
it belongs to, the few facts that matter, and a button to the page that answers it. Workflows will
accept the older MessageCard format too, but it does not render buttons on one — which is why the
card is what SendBeam sends.

Delivery works exactly as it does for a JSON endpoint: the same retries, the same delivery log,
and the same auto-disable if the URL stops working. Teams does not verify our signature, so the
[signature header](#verifying-the-signature) is irrelevant here; the secrecy of the
Workflow URL is what makes it trustworthy. Treat it as a credential — anyone holding it can post
into the channel.

### What to expect

Two things about Teams are worth knowing before you rely on it for anything urgent.

- **A successful send means “accepted”, not “posted”.** Microsoft answers
  `202` the moment it receives the card, then runs your Workflow separately. If the
  Workflow itself fails, we never hear about it. “Send test event” says so plainly — check the
  channel to confirm the card arrived.
- **Set the trigger to “Anyone”.** If the Workflow is set to require a signed-in
  user, it rejects our request and every delivery fails.

Two things to expect that are Microsoft's design, not ours: the card posts as
*“ via Workflows”* and cannot carry your own name or icon, and a
Workflow whose owner leaves — or that sits unused for 90 days, or errors for 14 — is turned off
by Microsoft. Adding a co-owner to the Workflow avoids the first of those.

## Verifying the signature

Your endpoint is a public URL, so anyone could POST to it. Verify the signature on every request
and reject anything that does not match — that, and nothing else, is what tells you the request
came from SendBeam.

Each delivery carries a header of the form:

```
X-SendBeam-Signature: t=<unix seconds>,v1=<hex hmac>
```

To check it:

1. Split the header on the comma and read `t` and `v1`.
2. Build the signed string by joining the timestamp and the **exact raw request body**
  with a full stop: `{t}.{raw body}`. The timestamp is part of what is
  signed, so an old genuine payload cannot be replayed against you at a different time.
3. Compute `HMAC-SHA256` over that string using your endpoint's secret, hex-encoded (lower case).
4. Compare it with `v1` using a constant-time comparison, so a timing measurement cannot recover the secret byte by byte.
5. Reject the request if the timestamp is more than five minutes away from your own clock.

> Sign the **raw** body exactly as received. Parsing the JSON and re-serialising it changes whitespace and key order, and the signature will not match.
> 

### Node.js

```
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.SENDBEAM_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes

function verify(rawBody, header, secret) {
  // Header looks like: t=1788500472,v1=<hex hmac>
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  const received = parts.v1;
  if (!Number.isFinite(timestamp) || !received) return false;

  // Reject a signature that has drifted too far, so an old but genuine
  // payload cannot be replayed at you later.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;

  // The signed material is the timestamp, a full stop, then the exact raw body.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(received, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Keep the RAW body: re-serialising parsed JSON will not match the signature.
app.post('/hooks/sendbeam', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verify(rawBody, req.get('X-SendBeam-Signature') || '', SECRET)) {
    return res.status(400).send('bad signature');
  }

  const event = JSON.parse(rawBody);
  // event.id is stable across retries — use it to ignore a repeat.
  console.log(event.event, event.data);

  res.status(200).send('ok'); // answer fast; do the slow work afterwards
});

app.listen(3000);
```

### Python

```
import hashlib
import hmac
import json
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = "your endpoint secret"
TOLERANCE_SECONDS = 300  # 5 minutes

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    # Header looks like: t=1788500472,v1=<hex hmac>
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        timestamp = int(parts["t"])
        received = parts["v1"]
    except (ValueError, KeyError):
        return False

    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

@app.post("/hooks/sendbeam")
def hook():
    raw_body = request.get_data()  # raw bytes, not request.json
    if not verify(raw_body, request.headers.get("X-SendBeam-Signature", ""), SECRET):
        return "bad signature", 400

    event = json.loads(raw_body)
    print(event["event"], event["data"])
    return "ok", 200
```

## Retries and auto-disabling

If a delivery fails — a non-`2xx` status, a redirect, a timeout, a connection that
will not open — SendBeam tries again. Retries start within about half a minute and back off from
there, so an endpoint that is briefly down catches up on its own. In all there are five attempts
spread over roughly six hours; after the last one the delivery is marked **failed**
and no further attempt is made for that event.

Retries are per event, so a busy workspace can have many deliveries retrying at once. Because
the same delivery `id` is sent on every attempt, a receiver that dedupes on it will
never double-process an event it already accepted but failed to acknowledge in time.

Separately, SendBeam watches the endpoint as a whole. After 20 failed deliveries in a row it is
**auto-disabled**: deliveries stop, queued ones are dropped, and the endpoint shows
a reason explaining what happened. This exists so a URL that has been decommissioned does not
get retried forever. Fix the receiver, then re-enable the endpoint from the dashboard or with
`PATCH … {"enabled": true}` — re-enabling clears the reason and resets the
failure count, so it starts again with a clean record. Events that happened while it was
disabled are not replayed.

## Testing and debugging

**Send test event** in the dashboard — or `POST /api/v1/webhooks/{id}/test`
— sends one synthetic, fully signed payload straight away and tells you what came back, so you
can confirm a new receiver works without waiting for a real event. A test has the same shape as a
real event of that type, with `"test": true` in `data`, an `id`
starting `test_` and obviously fake values, so anything you map from it keeps working on
real events. A test is never added to the delivery log.

```
curl -X POST "https://sendbeam.io/api/v1/webhooks/$WEBHOOK_ID/test" \
  -H "x-api-key: $SENDBEAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event": "contact.created"}'
# → {"ok":true,"status":200}
```

When something is not arriving, read the delivery log: it records every event queued for the
endpoint, how many attempts it took, the last status code and the last error.

```
curl "https://sendbeam.io/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20" \
  -H "x-api-key: $SENDBEAM_API_KEY"
```

```
{
  "deliveries": [
    {
      "id": "b1a4c0de-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
      "event_type": "contact.created",
      "status": "delivered",
      "attempts": 1,
      "last_status_code": 200,
      "last_error": null,
      "delivered_at": "2026-09-03T09:41:12.902Z",
      "created_at": "2026-09-03T09:41:12.204Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 1, "total_pages": 1 }
}
```

The status of a row is `pending` (queued or waiting to retry), `delivered`
(a 2xx came back), `failed` (attempts used up) or `abandoned` (the
endpoint was disabled or deleted before it could be sent). The JSON body of each delivery is
left out by default, because it can contain your contacts' personal data; add
`&include=payload` when you actually need to see it.

## Requirements and limits

- **HTTPS only.** A plain `http://` URL is refused.
- **The URL must be reachable from the public internet.** Anything that resolves to
  a private, loopback or link-local address — `localhost`, `127.0.0.1`,
  `10.x`, `192.168.x`, a `.internal` or `.local`
  name — is refused when you register it and checked again before every delivery. To develop
  locally, put a tunnelling service in front of your machine and register the public URL it gives you.
- **No credentials in the URL.** Use a secret path segment or verify the signature (better) instead of `https://user:pass@…`.
- **Answer within a few seconds.** A delivery that has not been answered in about eight seconds is treated as failed and retried.
- **Answer with a 2xx.** Redirects are not followed and count as failures.
- **Your response body is ignored** and only the first few kilobytes of it are read at all.

> Deliveries come from SendBeam's edge network rather than a fixed address, so allow-listing by source IP is not something we can support. Verify the signature instead — it is the stronger check.
>

---
Source: https://sendbeam.io/docs/api/webhooks
