Webhooks

Register a webhook from your project settings in the dashboard to receive a signed POST whenever a message reaches a terminal delivery outcome. Webhook subscriptions are managed in the dashboard, not through the API.

Event types

Only terminal outcomes fire a webhook. Intermediate states — queued, sent, dispatched — are never delivered. Subscribe to a subset when you register the endpoint, or to all nine by leaving the event list empty.

message.deliveredsms

An SMS is confirmed delivered.

message.failedsms

An SMS delivery fails.

message.expiredsms

An SMS expires before delivery.

message.rejectedsms

An SMS is rejected by the carrier.

email.deliveredemail

An email is confirmed delivered.

email.bouncedemail

An email bounces.

email.complainedemail

A recipient marks an email as spam.

email.failedemail

An email fails to send or deliver.

email.suppressedemail

A recipient is on the suppression list.

All Event Types
[
  "message.delivered",
  "message.failed",
  "message.expired",
  "message.rejected",
  "email.delivered",
  "email.bounced",
  "email.complained",
  "email.failed",
  "email.suppressed"
]

Payload

Every payload carries the event that fired, the created_at timestamp, and a data object whose contents differ by channel.

delivery_status on SMS events and status on email events always match the outcome named in event. Check them for the specific reason, since message.failed and email.failed alone cannot distinguish a failure from an expiry, a rejection, a bounce or a complaint.

SMS Event
{
  "event": "message.delivered",
  "created_at": "2026-08-30T18:05:00Z",
  "data": {
    "message_id": "58cf292d-417e-4f61-9687-662b845574cf",
    "project_id": "a7bd4b07-7e58-4785-933c-424d728a2790",
    "recipient": "+233241234567",
    "sender": "Sail",
    "delivery_status": "delivered"
  }
}
Email Event
{
  "event": "email.bounced",
  "created_at": "2026-08-30T18:05:00Z",
  "data": {
    "message_id": "1597951f-e00f-42c3-9924-26a0044ba820",
    "project_id": "a7bd4b07-7e58-4785-933c-424d728a2790",
    "from_address": "[email protected]",
    "to": "[email protected]",
    "status": "bounced",
    "bounce_reason": "mailbox does not exist"
  }
}

Verifying a payload

Every request we send is signed. Recompute the signature by hashing {timestamp}.{raw_body} with HMAC-SHA256 and your webhook secret, then compare it to X-Sailup-Signature after the sha256= prefix. Reject the payload if it does not match.

Sign the raw request body, before any JSON parsing — re-serializing changes the bytes and the signature will not match. Your webhook secret is shown once, when you register the webhook in your dashboard.

Request Headers
X-Sailup-Signature: sha256=<hex hmac>
X-Sailup-Timestamp: <unix timestamp>
X-Sailup-Event: <event type>
X-Sailup-Delivery-Id: <uuid>
Verify — Node.js
import crypto from "node:crypto";

function isValid(rawBody, headers, secret) {
  const timestamp = headers["x-sailup-timestamp"];
  const received = headers["x-sailup-signature"].replace("sha256=", "");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");

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

Retries

If your endpoint does not return a 2xx response, we retry up to five times with exponential backoff before giving up on that event. Each attempt is recorded separately in the delivery log in your dashboard.

After ten consecutive delivery cycles fail, we disable the webhook and notify the project owner by email. You can re-enable it from the dashboard once the endpoint is fixed, and retry any past delivery from the log.

Handler — Node.js
app.post("/webhooks/sailup", (req, res) => {
  if (!isValid(req.rawBody, req.headers, process.env.SAILUP_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  // Acknowledge first, process afterwards. Anything other than a 2xx counts
  // as a failed delivery and is scheduled for retry.
  res.sendStatus(200);
  queue.push(req.body);
});