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.
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 anidyou 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. |
| 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
- Open the Webhooks page in your workspace settings and choose Add endpoint.
- Choose where the events should go: your server, which gets the signed JSON described here, or a chat channel — Slack or Microsoft Teams.
- Paste your HTTPS URL and, optionally, a short description so you can tell endpoints apart later.
- Tick the events this endpoint should receive.
- Save. The signing secret is shown once, on the confirmation screen — copy it into your application's configuration before leaving the page.
- Use Send test event to check your receiver answers, then watch the delivery log as real events arrive.
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.
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.
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 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.
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 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
202the 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 “<the Workflow owner> 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:
- Split the header on the comma and read
tandv1. -
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. - Compute
HMAC-SHA256over that string using your endpoint's secret, hex-encoded (lower case). - Compare it with
v1using a constant-time comparison, so a timing measurement cannot recover the secret byte by byte. - Reject the request if the timestamp is more than five minutes away from your own clock.
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.internalor.localname — 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.
Stuck, or found a gap? Ask in the community — questions, tips and every release note, with this page as the source of truth.