Webhooks

Signed webhooks: the envelope, event types, signature verification, retries and disabling.

Webhooks push events to an HTTPS endpoint you own as they happen - delivery status for messages sent through this API, inbound SMS on your virtual number, contact opt-outs, campaign completion and low-credit alerts - so you don't have to poll GET /messages/{id}. Polling keeps working; webhooks are additive.

One endpoint per workspace, configured in the app under Developers → Webhooks (workspace owners only - there is no REST endpoint for managing it). The URL can just as well be an n8n, Make or Zapier "catch webhook" for no-code automations.

A step-by-step walkthrough of the same material, in Hebrew: המדריך המלא ל-Webhooks בעברית.

Setup

  1. Add your HTTPS URL and pick the event types you want to receive.
  2. Copy the signing secret (whsec_…) into your server's configuration.
  3. Click Send test event and confirm a ping shows as succeeded in the delivery log.
  4. Verify every request's signature before trusting it (below).

Event types

TypeFires whendata
message.sentAn API message was handed to the carrier.The message object of GET /messages/{id}.
message.deliveredThe carrier confirmed delivery (terminal).Message object.
message.undeliveredThe carrier could not deliver (terminal), or the recipient had opted out by dispatch time (terminal, refunded).Message object.
message.failedRejected or failed before reaching the carrier, or cancelled (terminal, refunded).Message object.
inbound.receivedAn SMS arrived on your virtual number (one event per SMS part).One item of GET /inbound.
contact.opted_outA contact unsubscribed - SMS reply, unsubscribe link, in the app, through the API, or a carrier receipt.The contact object of GET /contacts/{id}; context.reason says how.
contact.resubscribedA contact was re-subscribed in the app or through a QR join.Contact object; context.reason.
campaign.completedA composer campaign finished sending.Campaign summary (below).
campaign.cancelledA composer campaign was cancelled before it went out.Campaign summary; context.reason.
credits.lowThe balance dropped below the workspace's alert threshold.{ balance, threshold }
pingYou clicked “Send test event”.{ message, sent_at }

message.* events cover API sends only; campaigns sent from the composer are reported by campaign.*. Bulk contact actions in the app emit one contact.* event per contact. A failure that never reached the carrier is reported together with its refund (normally within 5 minutes), so refunded in the event is final; carrier outcomes arrive as soon as the receipt does.

Envelope

Every request body is one event: id (stable across retries and resends - deduplicate on it), type, created_at, workspace_id / workspace_name (so one receiver can serve several workspaces), data - exactly the object the matching REST endpoint returns - and, for some types, a context with event-specific extras.

message.delivered
{
  "id": "cmg3k2p8x0001q1a9d4z7w6e5",
  "type": "message.delivered",
  "created_at": "2026-09-22T09:41:38.579Z",
  "workspace_id": "cmc0org00000000000000001",
  "workspace_name": "Acme",
  "data": {
    "id": "cmqzkdkul0007p20mf0r4tz21",
    "status": "delivered",
    "to": "+972501234567",
    "from": "Sendy",
    "credits_charged": 1,
    "credits_refunded": 0,
    "refunded": false,
    "error_code": null,
    "created_at": "2026-09-22T09:41:32.877Z",
    "sent_at": "2026-09-22T09:41:36.932Z",
    "delivered_at": "2026-09-22T09:41:38.579Z",
    "idempotency_key": "order-1001"
  }
}
inbound.received
{
  "id": "cmg3k5r1d0002q1a9h8j2k4m6",
  "type": "inbound.received",
  "created_at": "2026-09-22T10:15:00.412Z",
  "workspace_id": "cmc0org00000000000000001",
  "workspace_name": "Acme",
  "data": {
    "id": "cmc0inbound0001",
    "from": "+972501234567",
    "to": "+972531234567",
    "text": "אשמח לקבל פרטים נוספים",
    "opt_out": "none",
    "opt_out_applied_at": null,
    "received_at": "2026-09-22T10:15:00.000Z"
  }
}
contact.opted_out
{
  "id": "cmg3k6t4f0003q1a9n1p3r5t7",
  "type": "contact.opted_out",
  "created_at": "2026-09-22T10:16:02.312Z",
  "workspace_id": "cmc0org00000000000000001",
  "workspace_name": "Acme",
  "data": {
    "id": "cmc0contact0001",
    "phone": "+972529876543",
    "first_name": "Dana",
    "last_name": "Levi",
    "email": null,
    "birthday": null,
    "custom_field_1": null,
    "custom_field_2": null,
    "custom_field_3": null,
    "subscribed": false,
    "source": "MANUAL",
    "segments": [{ "id": "cmc0seg0001", "name": "Newsletter" }],
    "created_at": "2026-07-15T10:00:00.000Z",
    "updated_at": "2026-09-22T10:16:02.312Z"
  },
  "context": { "reason": "sms_reply", "campaign_id": "cmc0campaign0001", "inbound_id": "cmc0inbound0002" }
}

context.reason for contact.opted_out is one of sms_reply (a reply to your virtual number; campaign_id names the campaign it was attributed to, when one was), unsubscribe_link, one_click, manual, bulk, carrier or qr; for contact.resubscribed: manual, bulk, qr or blocklist_release; for campaign.cancelled: customer, review_rejected, review_released, review_expired or paused_expired.

campaign.completed · data
{
  "id": "cmc0campaign0001",
  "name": "מבצע סוף שנה",
  "status": "completed",
  "sender": "MyShop",
  "recipients": 1508,
  "messages_sent": 1500,
  "messages_delivered": 1437,
  "messages_failed": 50,
  "messages_blocked": 8,
  "delivery_rate": 95.29,
  "unsubscribe_rate": 0.53,
  "credits_used": 3016,
  "credits_refunded": 16,
  "created_at": "2026-09-20T08:00:00.000Z",
  "scheduled_at": "2026-09-21T07:00:00.000Z",
  "started_at": "2026-09-21T07:00:02.000Z",
  "completed_at": "2026-09-21T07:01:17.000Z"
}

Headers

Request headers
POST /webhooks/sendy HTTP/1.1
Host: hooks.example.com
Content-Type: application/json; charset=utf-8
User-Agent: Sendy-Webhooks/1.0
Sendy-Event-Id: cmg3k2p8x0001q1a9d4z7w6e5
Sendy-Event-Type: message.delivered
Sendy-Delivery-Id: cmg3k2p8x0001q1a9d4z7w6e5.1
Sendy-Signature: t=1758535298,v1=5f1c0e2b9a7d4c6e8f0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6

Sendy-Event-Id equals the envelope's id. Sendy-Delivery-Id is <event id>.<attempt>; a manual resend from the delivery log restarts the attempt numbering, so deduplicate on the event id, never on the delivery id. For 24 hours after you rotate the secret the signature header carries a second v1= value (the previous secret) - accept the request if either verifies.

Verifying the signature

The signed string is "<t>." + raw request body; the signature is its HMAC-SHA256 with your secret, hex-encoded. Verify against the raw bytes you received (never a re-serialized JSON object), compare in constant time, and reject a timestamp more than 5 minutes from your clock.

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySendySignature(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
  // Never throw on a malformed header: an invalid request is rejected with `false`, not a 500.
  // Only a bounded decimal timestamp and 64-hex-char signatures reach the comparison.
  const pairs = header.split(",").map((p) => p.trim().split("=")).filter((p): p is [string, string] => p.length === 2);
  const t = pairs.find(([k]) => k === "t")?.[1];
  const sigs = pairs.filter(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/i.test(v)).map(([, v]) => v.toLowerCase());
  if (!t || !/^\d{1,12}$/.test(t) || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some((s) => timingSafeEqual(Buffer.from(s, "ascii"), Buffer.from(expected, "ascii")));
}

// Express: read the RAW body — app.post("/webhooks/sendy", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifySendySignature(req.body.toString("utf8"), req.get("Sendy-Signature") ?? "", process.env.SENDY_WEBHOOK_SECRET!)) return res.sendStatus(401);
//   res.sendStatus(200);           // answer first …
//   handle(JSON.parse(req.body));  // … then process
// });
Python
import hmac, hashlib, re, time

def verify_sendy_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    # Never raise on a malformed header: an invalid request is rejected with False, not a 500.
    # Only a bounded decimal timestamp and 64-hex-char signatures reach the comparison.
    pairs = [p.strip().split("=", 1) for p in header.split(",")]
    pairs = [p for p in pairs if len(p) == 2]
    t = next((v for k, v in pairs if k == "t"), None)
    sigs = [v.lower() for k, v in pairs if k == "v1" and re.fullmatch(r"[0-9a-fA-F]{64}", v)]
    if t is None or not re.fullmatch(r"[0-9]{1,12}", t) or not sigs or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in sigs)

# Flask: verify_sendy_signature(request.get_data(), request.headers.get("Sendy-Signature", ""), SECRET)

Delivery, retries and disabling

Answer 2xx within 10 seconds; anything else, or no answer, is a failure. A failed event is retried after 1 minute, then 5 and 15 minutes, then 1, 3, 6 and 12 hours - 8 attempts in total (about 22 hours) - and each event retries on its own schedule, so one event your handler rejects never holds up the others. When your host does not answer at all (timeout, DNS, TLS, connection refused) Sendy pauses the whole endpoint for that same backoff instead of timing out on every event. Redirects are never followed - a 301/302 counts as a failure. A 410 Gone disables the endpoint immediately. An endpoint that keeps failing for 3 days - no successful delivery since its first failure - is disabled automatically and the workspace owners are told in the app and by email; re-enable it from Developers → Webhooks. An idle endpoint is never disabled. While an endpoint is disabled no events are recorded for it. Delivery records are kept for 30 days; a suspended workspace's deliveries are held, not dropped.

An event is recorded in the same database transaction as the change it describes. In the rare case that recording itself fails, the change is kept and the event is dropped, so treat the API (GET /messages/{id}, GET /contacts/{id}, GET /inbound) as the source of truth and reconcile against it for anything that must not be missed.

Best practices

  • Respond first, process afterwards - a slow handler that times out will receive the same event again.
  • Delivery is at-least-once: deduplicate on Sendy-Event-Id; ordering is not guaranteed, so trust the object's own timestamps and treat delivered as final.
  • Ignore event types you don't know - new types are added without notice; the data of an existing type only ever gains fields.
  • Allow User-Agent: Sendy-Webhooks/1.0 through bot protection. Sendy has no fixed egress IP - the signature is the proof of origin. Custom headers and basic auth on the URL are not supported.
  • Use the delivery log's response snippet and Resend when debugging; rotate the secret from the app and honour both v1= values during the overlap.
  • Pointing the endpoint at a no-code tool? Mind its task quota - a bulk unsubscribe of thousands of contacts emits thousands of events.

אתר זה עושה שימוש בעוגיות (Cookies) ובכלים טכנולוגיים נוספים כדי לשפר את חוויית המשתמש, לנתח תנועה ולהתאים פרסומות. המשך הגלישה באתר מהווה את הסכמתך לכך ולמדיניות הפרטיות שלנו.