Webhooks
Receive signed real-time events from Sendly, verify them in any language, and forward workflow events to your own endpoint
Sendly can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. There are two ways to receive them, and they are different things:
- Webhook subscriptions (
POST /api/webhooks, or Settings → Webhooks in the dashboard) deliver a fixed set of ten events to your endpoint. Every delivery is signed, retried, and verifiable. Start with Webhook subscriptions below. - The workflow Webhook step forwards any event a workflow can trigger on, including your own custom events. Those requests are not signed; authenticate them with a custom header you configure on the step. The rest of this page, from How it works down, covers this path.
Webhook subscriptions and signature verification
Events you can subscribe to
eventTypes on POST /api/webhooks accepts exactly these ten names. Anything else is a 400. Each event name links to its API reference page, which shows the exact payload.
| Event | When it fires |
|---|---|
email.sent | The message was handed to the sending infrastructure |
email.delivered | The recipient's server accepted it |
email.opened | The message was opened |
email.clicked | A link in the message was clicked |
email.bounced | The message hard-bounced (a soft bounce does not fire this) |
email.complained | The recipient marked it as spam |
email.failed | The send failed before or at handoff |
contact.created | A contact was created, by any route including an import |
contact.unsubscribed | A contact's status changed to unsubscribed |
contacts.bulk_created | A bulk import finished creating contacts |
These are not the workflow trigger names further down this page (email.delivery, email.open, email.click, email.bounce, email.complaint, contact.subscribed). A subscription to a workflow name is rejected; a workflow trigger on a subscription name never fires. Event vocabularies sets the two lists side by side.
What a delivery looks like
Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Sendly-Webhook/1.0 |
X-Sendly-Event | The event name, for example email.sent |
X-Sendly-Timestamp | Unix time in milliseconds, as a decimal string |
X-Sendly-Signature | Lowercase hex HMAC-SHA256, 64 characters, with no sha256= prefix |
webhook-id | A message id, stable across every retry of the same event. Dedupe on it. |
webhook-timestamp | Unix time in seconds, as a decimal string |
webhook-signature | One or more space-separated v1,<base64> signatures (Standard Webhooks) |
The body is an envelope. timestamp repeats X-Sendly-Timestamp, and data depends on the event:
{
"event": "email.sent",
"data": { "emailId": "e1" },
"timestamp": "1700000000000"
}Every delivery carries two independent signatures, so use whichever your tooling supports. Both are computed over the raw request body, byte for byte. Never parse and re-serialise the JSON first, and never verify against a framework's re-formatted copy.
Scheme 1: X-Sendly-Signature (what the SDKs verify)
signed_payload = X-Sendly-Timestamp + "." + raw_body
signature = lowercase_hex( HMAC_SHA256( key = secret, message = signed_payload ) )- The separator is a single ASCII dot, with no spaces and no newline.
- The timestamp is the header's decimal string exactly as sent (milliseconds), not re-formatted.
- The key is the secret string as stored, UTF-8 encoded. For a secret of the form
whsec_...this means the whole string including thewhsec_prefix. Do not base64-decode it in this scheme. - Compare in constant time. Reject any
X-Sendly-Timestampthat is not all digits.
Scheme 2: webhook-signature (Standard Webhooks)
signed_payload = webhook-id + "." + webhook-timestamp + "." + raw_body
signature = "v1," + base64( HMAC_SHA256( key, signed_payload ) )webhook-timestampis in seconds here.keyis the base64-decoded remainder when the secret starts withwhsec_. A secret without that prefix (every secret minted before rotation shipped) is used as its raw UTF-8 bytes.- The header may hold several signatures separated by single spaces. Accept the request if any of them matches. This is how key rotation stays seamless; see below.
- Off-the-shelf Standard Webhooks libraries verify this header with no Sendly-specific code.
Timestamp tolerance
Reject a delivery whose timestamp is more than 5 minutes from your clock, in either direction. The timestamp is inside the signed payload, so an attacker cannot move a captured delivery forward in time; the tolerance is what makes a captured request useless a few minutes later (a replay). Five minutes absorbs ordinary clock skew. Keep your server clock on NTP. Every retry is re-signed with a fresh timestamp, so a late retry is never rejected for age.
Skip the freshness check only when replaying stored deliveries, for example in a test.
Rotating the secret
POST /api/webhooks/{id}/rotate-secret (or rotateSecret in the SDK) returns the new secret once. The previous secret keeps working for 24 hours, and only one previous secret is live at a time; rotating twice inside the window drops the oldest.
During those 24 hours:
webhook-signaturecarries two signatures, the new secret's first and the previous secret's second. A verifier holding either secret accepts the request.X-Sendly-Signatureis signed with the new secret only. A verifier still holding the old secret fails this header at once.
So for zero-downtime rotation, verify webhook-signature and try every secret you hold. If you verify only X-Sendly-Signature (as the SDKs do), deploy the new secret promptly after rotating.
When a signature check fails, answer with a 5xx (or let the error propagate) so Sendly retries the delivery. A 4xx is never retried: it counts toward the 20-failure auto-disable below, and the event can then only be recovered by replaying it (POST /api/webhooks/{id}/replay-failed, which covers the last 24 hours by default).
A test vector
Use this to check an implementation in any language without a live send. The expected values were produced by Sendly's own signing code.
| Input | Value |
|---|---|
| Secret | whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYX |
X-Sendly-Timestamp | 1700000000000 |
webhook-id | msg_2c9f8e1a4b7d4c3e |
webhook-timestamp | 1700000000 |
| Raw body | {"event":"email.sent","data":{"emailId":"e1"},"timestamp":"1700000000000"} |
| Expected header | Value |
|---|---|
X-Sendly-Signature | e8f3ca5c77d88269b09281a87b5fc48618b3be252093374345312315a92be620 |
webhook-signature | v1,KXrjybsdQ1Rrr93ZXptngyvnG2kWwlMzUKH7cmozDTM= |
The timestamp is in 2023, so a verifier with the 5-minute tolerance rejects it as stale. Run the vector with the freshness check disabled, or with your clock pinned to that instant.
Verifying a delivery
sendly-sdk verifies the X-Sendly-Signature scheme and throws on a bad signature or a stale timestamp. Pass the raw body, not the parsed one.
import express from 'express';
import { constructEvent } from 'sendly-sdk';
const app = express();
app.post('/webhooks/sendly', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = constructEvent(
req.body, // Buffer: the raw bytes
req.headers['x-sendly-signature'] as string,
req.headers['x-sendly-timestamp'] as string,
process.env.SENDLY_WEBHOOK_SECRET!,
);
// event.event, event.data
res.sendStatus(200);
} catch {
// A 5xx is retried; a 4xx never is. Answer 5xx on a failed check so a stale secret does not lose the event.
res.sendStatus(500);
}
});Standard library only. verify_legacy checks X-Sendly-Signature; verify_standard checks webhook-signature against every secret you hold, which is what you want during a rotation.
import base64
import hashlib
import hmac
import time
TOLERANCE_MS = 5 * 60 * 1000
def verify_legacy(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def verify_standard(raw_body: bytes, msg_id: str, timestamp_s: str, header: str, secrets: list[str]) -> bool:
if not (timestamp_s.isascii() and timestamp_s.isdigit()):
return False
if abs(time.time() - int(timestamp_s)) > TOLERANCE_MS / 1000:
return False
signed = f"{msg_id}.{timestamp_s}.".encode() + raw_body
candidates = [p.split(",", 1)[1] for p in header.split(" ") if p.startswith("v1,")]
for secret in secrets:
key = base64.b64decode(secret[len("whsec_"):]) if secret.startswith("whsec_") else secret.encode()
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
if any(hmac.compare_digest(expected, c) for c in candidates):
return True
return FalseIn a Flask handler, call request.get_data() for raw_body; do not use request.json.
Delivery, retries and failures
- A
2xxresponse is success. Any other status, a timeout (10 seconds) or a connection error is a failed attempt. - A
4xxis treated as a rejection and is not retried. Anything else is retried up to 8 more times, after roughly 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, each with up to 20% jitter. - After 20 consecutive failed deliveries the endpoint is switched off, with warnings to the project's members along the way. A single success resets the count.
- Delivery is at-least-once. Dedupe on
webhook-id, which does not change between retries.
How it works
Webhooks in Sendly are powered by the workflow system. The basic flow is:
- An event occurs in Sendly (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
- A workflow is triggered by that event
- The workflow executes a Webhook step, sending an HTTP request to your URL with relevant data
This means you can receive notifications for any event Sendly tracks, including both system events and your own custom events.
Internal events
Sendly automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
These are workflow trigger names, not subscription names
The names below are what a workflow triggers on. The eventTypes field on
POST /api/webhooks takes a different, shorter list — email.delivered rather than
email.delivery, email.bounced rather than email.bounce. Both lists are set out side by side
in Event vocabularies.
Email events
| Event | Description |
|---|---|
email.sent | An email was successfully sent |
email.delivery | An email was delivered to the recipient |
email.open | A contact opened an email for the first time |
email.click | A contact clicked a link in an email for the first time |
email.bounce | An email bounced (hard or soft bounce) |
email.complaint | A contact marked an email as spam |
email.received | An email was received at your verified domain (requires inbound email setup) |
Contact events
| Event | Description |
|---|---|
contact.subscribed | A contact's subscription status changed to subscribed |
contact.unsubscribed | A contact's subscription status changed to unsubscribed |
Segment events
| Event | Description |
|---|---|
segment.<name>.entry | A contact entered a segment |
segment.<name>.exit | A contact exited a segment |
Segment event names
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce
the events segment.vip-users.entry and segment.vip-users.exit.
Setting up a webhook
Create the workflow
Navigate to the Workflows section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use email.bounce as the trigger event.
Add a Webhook step
After the trigger, add a Webhook step and configure it:
- URL: The endpoint on your server that will receive the webhook (e.g.
https://api.example.com/webhooks/sendly) - Method: The HTTP method to use. Defaults to
POST, which is recommended for most use cases. - Headers (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
{
"Authorization": "Bearer your-secret-token"
}Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
Webhook payload
When using the default payload (no custom body configured), Sendly sends a JSON request with the following structure:
{
"contact": {
"email": "user@example.com",
"subscribed": true,
"data": {
"name": "John",
"plan": "pro"
}
},
"workflow": {
"id": "wf_abc123",
"name": "Bounce Notifications"
},
"execution": {
"id": "exec_xyz789",
"startedAt": "2025-01-15T10:30:00.000Z"
},
"event": {
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": "camp_abc123",
"sourceType": "CAMPAIGN",
"bounceType": "Permanent",
"bouncedAt": "2025-01-15T10:30:00.000Z"
}
}The event field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
Event data by type
Email events
Most email events share a common set of base fields:
| Field | Description |
|---|---|
subject | The email subject line |
from | The sender email address |
fromName | The sender display name |
sesMessageId | The AWS SES message ID (for correlating with SES events) |
emailId | The Sendly email record ID (returned from POST /api/emails, for correlating webhooks with API responses) |
templateId | The template ID, if the email was sent using a template (otherwise null) |
campaignId | The campaign ID, if the email was part of a campaign (otherwise null) |
sourceType | How the email was triggered: TRANSACTIONAL, CAMPAIGN, WORKFLOW, or INBOUND |
In addition to these base fields, each event includes the following event-specific fields:
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"sentAt": "2025-01-15T10:30:00.000Z"
}| Field | Description |
|---|---|
sentAt | When the email was sent |
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": "camp_abc123",
"sourceType": "CAMPAIGN",
"deliveredAt": "2025-01-15T10:30:05.000Z"
}| Field | Description |
|---|---|
deliveredAt | When the email was delivered |
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"openedAt": "2025-01-15T11:00:00.000Z",
"opens": 1,
"isFirstOpen": true
}| Field | Description |
|---|---|
openedAt | When the email was first opened |
opens | Total number of times this email has been opened |
isFirstOpen | true if this is the first time the contact opened the email |
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"link": "https://example.com/pricing",
"clickedAt": "2025-01-15T11:05:00.000Z",
"clicks": 1,
"isFirstClick": true
}| Field | Description |
|---|---|
link | The URL that was clicked |
clickedAt | When the first click occurred |
clicks | Total number of times links in this email have been clicked |
isFirstClick | true if this is the first click from this contact on this email |
Permanent bounce:
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"bounceType": "Permanent",
"bouncedAt": "2025-01-15T10:31:00.000Z"
}Transient (soft) bounce:
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"bounceType": "Transient",
"transientBounce": true
}| Field | Description |
|---|---|
bounceType | Permanent (hard bounce) or Transient (soft bounce, e.g. mailbox full or out-of-office) |
bouncedAt | When the bounce occurred (permanent bounces only) |
transientBounce | true for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
Bounce rate impact
Only Permanent bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
Transient bounces are tracked for visibility only.
{
"subject": "Welcome to Sendly",
"from": "hello@example.com",
"fromName": "Sendly Team",
"sesMessageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
"complainedAt": "2025-01-15T10:35:00.000Z"
}| Field | Description |
|---|---|
complainedAt | When the spam complaint was received |
This event fires when an email is received at your verified domain. See Receiving Emails for setup instructions.
{
"sesMessageId": "ses-message-id",
"from": "sender@example.com",
"fromHeader": "Jane Smith <sender@example.com>",
"to": "support@yourdomain.com",
"subject": "Re: Your question",
"timestamp": "2025-01-15T10:30:00.000Z",
"recipients": ["support@yourdomain.com"],
"hasContent": true,
"body": "<html><body>This is the email body content...</body></html>",
"spamVerdict": "PASS",
"virusVerdict": "PASS",
"spfVerdict": "PASS",
"dkimVerdict": "PASS",
"dmarcVerdict": "PASS",
"processingTimeMillis": 142
}| Field | Description |
|---|---|
sesMessageId | The AWS SES message ID |
from | The sender's email address |
fromHeader | The full From header, including display name if present |
to | The recipient address at your verified domain |
subject | The email subject line |
timestamp | When SES received the email |
recipients | All recipient addresses in the envelope |
hasContent | Whether the email body content is available |
body | HTML body of the email (or plain text if no HTML available) |
spamVerdict | SES spam check result: PASS, FAIL, GRAY, or PROCESSING_FAILED |
virusVerdict | SES virus check result |
spfVerdict | SPF authentication result |
dkimVerdict | DKIM authentication result |
dmarcVerdict | DMARC authentication result |
processingTimeMillis | Time SES took to process the inbound email |
Contact events
contact.subscribed and contact.unsubscribed carry no event data by default. The event field will be an empty object {}.
The exception is when an unsubscription is triggered automatically by an email bounce or complaint — in that case event includes a reason field:
{
"reason": "bounce"
}| Field | Value |
|---|---|
reason | "bounce" or "complaint" (when system-triggered) |
Segment events
Both segment.<name>.entry and segment.<name>.exit include:
{
"segmentId": "seg_abc123",
"segmentName": "VIP Users"
}| Field | Description |
|---|---|
segmentId | The ID of the segment |
segmentName | The display name of the segment |
Custom events
Custom events tracked via the API include whatever data you passed in the data field when calling track.
No event data
For events that carry no data, the event field will be an empty object {}.
Correlating webhooks with send requests
All email events include an emailId field that matches the Sendly email record ID returned when you send an email via POST /api/emails. This allows you to directly correlate webhook events with your API requests.
Example workflow:
- Send email via API:
POST /api/emails
{
"to": "user@example.com",
"subject": "Welcome",
"body": "Hello!"
}
Response:
{
"success": true,
"data": {
"emails": [
{
"contact": {"id": "cnt_abc", "email": "user@example.com"},
"email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
}
]
}
}-
Store the
emailID (ac32f08e-c6b9-45d3-9824-a73dff1e3bbf) in your database -
When webhook events fire (e.g.,
email.open,email.bounce), match them usingevent.emailId:
{
"event": {
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"sesMessageId": "ses-message-id",
"openedAt": "2025-01-15T11:00:00.000Z"
}
}This eliminates the need to match by contact email + timestamp or to listen for email.sent webhooks just to get the sesMessageId.
Common use cases
Bounce and complaint monitoring
Create a workflow triggered by email.bounce or email.complaint to forward these events to your application. This allows you to keep your own database in sync with Sendly's contact statuses.
You can use additional workflow steps before the webhook to add logic:
- Condition: Only send the webhook for hard bounces by checking the
bounceTypefield - Delay: Add a short delay to batch-process related events
- Update Contact: Mark the contact with metadata before sending the webhook
Syncing unsubscribes
Trigger a workflow on contact.unsubscribed to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
Custom event forwarding
If you track custom events in Sendly (e.g. user.signup, order.completed), you can forward those same events to other services via webhooks. This turns Sendly into an event router — track once, distribute to multiple endpoints.
Send events to Slack
Webhook endpoints you add under Settings → Webhooks can point straight at a Slack channel. When the URL is a Slack incoming webhook, Sendly posts a readable Slack message instead of the JSON payload, which Slack would reject.
- In Slack, create an app (or open an existing one), turn on Incoming Webhooks, and choose Add New Webhook to Workspace. Pick the channel and copy the URL. It looks like
https://hooks.slack.com/services/T000/B000/XXXX. - In Sendly, open Settings → Webhooks, choose New webhook, paste the URL, and select the events you want in the channel.
- Use Send test to post a sample message. It is marked
[Test]so nobody mistakes it for a real event.
Each message has a title for the event, the details that event carries (recipient, subject, sender, bounce type or failure reason, campaign and email IDs), and a footer with the project name, event type and time. Values from the email are shown as plain text, so a subject like <!channel> never notifies your channel.
Only URLs on hooks.slack.com under /services/ over HTTPS are treated as Slack. Every other URL keeps receiving the JSON payload. Sendly still sends its signature headers to Slack, and Slack ignores them. Failed deliveries are retried on the usual schedule.
Adding conditions and delays
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
- Use a Condition step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
- Use a Wait for Event step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
- Use a Delay step to add a time buffer before the webhook fires
Template Syntax
Variables, conditionals, loops and reusable snippets — and the one rule that makes all of them safe to point at a stranger's data
MCP Server
Connect Claude, or any MCP client, to your Sendly account so an AI agent can work in it — with the permissions you choose, and the irreversible ones only if you tick them