SendlySendly
Guides

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.

EventWhen it fires
email.sentThe message was handed to the sending infrastructure
email.deliveredThe recipient's server accepted it
email.openedThe message was opened
email.clickedA link in the message was clicked
email.bouncedThe message hard-bounced (a soft bounce does not fire this)
email.complainedThe recipient marked it as spam
email.failedThe send failed before or at handoff
contact.createdA contact was created, by any route including an import
contact.unsubscribedA contact's status changed to unsubscribed
contacts.bulk_createdA 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:

HeaderValue
Content-Typeapplication/json
User-AgentSendly-Webhook/1.0
X-Sendly-EventThe event name, for example email.sent
X-Sendly-TimestampUnix time in milliseconds, as a decimal string
X-Sendly-SignatureLowercase hex HMAC-SHA256, 64 characters, with no sha256= prefix
webhook-idA message id, stable across every retry of the same event. Dedupe on it.
webhook-timestampUnix time in seconds, as a decimal string
webhook-signatureOne 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 the whsec_ prefix. Do not base64-decode it in this scheme.
  • Compare in constant time. Reject any X-Sendly-Timestamp that 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-timestamp is in seconds here.
  • key is the base64-decoded remainder when the secret starts with whsec_. 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-signature carries two signatures, the new secret's first and the previous secret's second. A verifier holding either secret accepts the request.
  • X-Sendly-Signature is 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.

InputValue
Secretwhsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYX
X-Sendly-Timestamp1700000000000
webhook-idmsg_2c9f8e1a4b7d4c3e
webhook-timestamp1700000000
Raw body{"event":"email.sent","data":{"emailId":"e1"},"timestamp":"1700000000000"}
Expected headerValue
X-Sendly-Signaturee8f3ca5c77d88269b09281a87b5fc48618b3be252093374345312315a92be620
webhook-signaturev1,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 False

In a Flask handler, call request.get_data() for raw_body; do not use request.json.

Delivery, retries and failures

  • A 2xx response is success. Any other status, a timeout (10 seconds) or a connection error is a failed attempt.
  • A 4xx is 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:

  1. An event occurs in Sendly (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
  2. A workflow is triggered by that event
  3. 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

EventDescription
email.sentAn email was successfully sent
email.deliveryAn email was delivered to the recipient
email.openA contact opened an email for the first time
email.clickA contact clicked a link in an email for the first time
email.bounceAn email bounced (hard or soft bounce)
email.complaintA contact marked an email as spam
email.receivedAn email was received at your verified domain (requires inbound email setup)

Contact events

EventDescription
contact.subscribedA contact's subscription status changed to subscribed
contact.unsubscribedA contact's subscription status changed to unsubscribed

Segment events

EventDescription
segment.<name>.entryA contact entered a segment
segment.<name>.exitA 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:

FieldDescription
subjectThe email subject line
fromThe sender email address
fromNameThe sender display name
sesMessageIdThe AWS SES message ID (for correlating with SES events)
emailIdThe Sendly email record ID (returned from POST /api/emails, for correlating webhooks with API responses)
templateIdThe template ID, if the email was sent using a template (otherwise null)
campaignIdThe campaign ID, if the email was part of a campaign (otherwise null)
sourceTypeHow 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"
}
FieldDescription
sentAtWhen 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"
}
FieldDescription
deliveredAtWhen 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
}
FieldDescription
openedAtWhen the email was first opened
opensTotal number of times this email has been opened
isFirstOpentrue 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
}
FieldDescription
linkThe URL that was clicked
clickedAtWhen the first click occurred
clicksTotal number of times links in this email have been clicked
isFirstClicktrue 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
}
FieldDescription
bounceTypePermanent (hard bounce) or Transient (soft bounce, e.g. mailbox full or out-of-office)
bouncedAtWhen the bounce occurred (permanent bounces only)
transientBouncetrue 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"
}
FieldDescription
complainedAtWhen 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
}
FieldDescription
sesMessageIdThe AWS SES message ID
fromThe sender's email address
fromHeaderThe full From header, including display name if present
toThe recipient address at your verified domain
subjectThe email subject line
timestampWhen SES received the email
recipientsAll recipient addresses in the envelope
hasContentWhether the email body content is available
bodyHTML body of the email (or plain text if no HTML available)
spamVerdictSES spam check result: PASS, FAIL, GRAY, or PROCESSING_FAILED
virusVerdictSES virus check result
spfVerdictSPF authentication result
dkimVerdictDKIM authentication result
dmarcVerdictDMARC authentication result
processingTimeMillisTime 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"
}
FieldValue
reason"bounce" or "complaint" (when system-triggered)

Segment events

Both segment.<name>.entry and segment.<name>.exit include:

{
  "segmentId": "seg_abc123",
  "segmentName": "VIP Users"
}
FieldDescription
segmentIdThe ID of the segment
segmentNameThe 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:

  1. 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"
      }
    ]
  }
}
  1. Store the email ID (ac32f08e-c6b9-45d3-9824-a73dff1e3bbf) in your database

  2. When webhook events fire (e.g., email.open, email.bounce), match them using event.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 bounceType field
  • 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.

  1. 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.
  2. In Sendly, open Settings → Webhooks, choose New webhook, paste the URL, and select the events you want in the channel.
  3. 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

On this page