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.deliveredsmsAn SMS is confirmed delivered.
message.failedsmsAn SMS delivery fails.
message.expiredsmsAn SMS expires before delivery.
message.rejectedsmsAn SMS is rejected by the carrier.
email.deliveredemailAn email is confirmed delivered.
email.bouncedemailAn email bounces.
email.complainedemailA recipient marks an email as spam.
email.failedemailAn email fails to send or deliver.
email.suppressedemailA recipient is on the suppression list.
[
"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.
{
"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"
}
}{
"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.
X-Sailup-Signature: sha256=<hex hmac>
X-Sailup-Timestamp: <unix timestamp>
X-Sailup-Event: <event type>
X-Sailup-Delivery-Id: <uuid>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.
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);
});