Domains and events
Webhooks
Chebu posts an event to your endpoint whenever something happens to an email.
Create a webhook
endpoint must be an https URL. events lists what to send. The signing_secret is shown once. Store it to verify requests.
/webhookscurl -X POST https://api.chebu.io/webhooks \
-H "Authorization: Bearer $CHEBU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://northwind.studio/hooks/chebu",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'{
"object": "webhook",
"id": "7c1f1e4a-3b2d-4f8e-9a61-2d0c5b8e4f13",
"signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}Events
| Event | When |
|---|---|
email.sent | Chebu handed the email to the sending network. |
email.delivered | The recipient's mail server accepted the email. |
email.delivery_delayed | Delivery failed for now and will be retried, for example because the inbox is full. |
email.bounced | The recipient's server rejected the email for good. The address is suppressed. |
email.complained | The recipient marked the email as spam. The address is suppressed. |
email.opened | The recipient opened the email. Needs open tracking on the domain. |
email.clicked | The recipient clicked a link. Needs click tracking on the domain. |
email.failed | The email couldn't be sent, for example an invalid attachment. |
email.scheduled | The email was scheduled for later. |
email.suppressed | The email wasn't sent because the address is on your suppression list. |
Payloads
Every event is a JSON POST with type, created_at, and data. data describes the email, with bounce details on bounces and click details on clicks.
{
"type": "email.delivered",
"created_at": "2026-09-27T10:04:15.902Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"from": "Northwind <[email protected]>",
"to": ["[email protected]"],
"subject": "Your receipt #1042",
"created_at": "2026-09-27T10:04:12.418Z",
"tags": [
{ "name": "category", "value": "receipt" }
]
}
}{
"type": "email.bounced",
"created_at": "2026-09-27T10:04:16.120Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"from": "Northwind <[email protected]>",
"to": ["[email protected]"],
"subject": "Your receipt #1042",
"created_at": "2026-09-27T10:04:12.418Z",
"tags": [],
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "550 5.1.1 The email account does not exist."
}
}
}{
"type": "email.clicked",
"created_at": "2026-09-27T11:20:03.004Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"from": "Northwind <[email protected]>",
"to": ["[email protected]"],
"subject": "Your receipt #1042",
"created_at": "2026-09-27T10:04:12.418Z",
"tags": [],
"click": {
"link": "https://northwind.studio/orders/1042",
"ipAddress": "203.0.113.24",
"userAgent": "Mozilla/5.0",
"timestamp": "2026-09-27T11:20:02.871Z"
}
}
}Verify signatures
Chebu signs every request with the Standard Webhooks scheme. Each request has three headers, also sent as svix-id, svix-timestamp, and svix-signature for tools that expect those:
| Header | Contents |
|---|---|
webhook-id | A unique ID for the message. The same on every retry, so you can drop duplicates. |
webhook-timestamp | Unix seconds when it was sent. |
webhook-signature | One or more space-separated v1,<base64 signature> entries. |
- Reject the request if
webhook-timestampis more than 5 minutes from your clock. - Take the signing secret, drop the
whsec_prefix, and base64-decode the rest to get the key. - Compute HMAC-SHA256 with that key over
webhook-id+.+webhook-timestamp+.+ the raw request body. - Base64-encode the result and compare it, in constant time, with each
v1,entry. Accept if any matches.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// rawBody must be the exact text Chebu sent, before any JSON parsing.
export function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) {
throw new Error("Missing webhook headers");
}
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= TOLERANCE_SECONDS)) {
throw new Error("Webhook timestamp is too old or too new");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(id + "." + timestamp + "." + rawBody)
.digest();
const valid = signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return (
given.length === expected.length &&
crypto.timingSafeEqual(given, expected)
);
});
if (!valid) throw new Error("Invalid webhook signature");
return JSON.parse(rawBody);
}Because the scheme is standard, the standardwebhooks libraries can verify Chebu webhooks too.
Retries
Reply with a status from 200 to 299 within a few seconds to acknowledge an event, and do slow work afterwards. Any other response, or a timeout, is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours, and 24 hours.
- An endpoint that keeps failing for 5 days is disabled.
- Replying
410 Gonedisables the endpoint straight away. - Events can arrive more than once or out of order. Use
webhook-idto skip duplicates andcreated_atto order them.
Manage webhooks
| Request | Does |
|---|---|
GET /webhooks | List webhooks. |
GET /webhooks/{id} | Retrieve one. |
PATCH /webhooks/{id} | Change endpoint, events, or status (enabled or disabled). |
DELETE /webhooks/{id} | Delete it. |
GET /webhooks/{id}/attempts | Recent delivery attempts and responses. |
POST /webhooks/{id}/messages/{message_id}/replay | Send a past event again. |
POST /webhooks/{id}/rotate | Issue a new signing secret. |
/webhooks/7c1f1e4a-3b2d-4f8e-9a61-2d0c5b8e4f13curl -X PATCH https://api.chebu.io/webhooks/7c1f1e4a-3b2d-4f8e-9a61-2d0c5b8e4f13 \
-H "Authorization: Bearer $CHEBU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": ["email.delivered", "email.bounced", "email.opened"],
"status": "enabled"
}'{
"object": "webhook",
"id": "7c1f1e4a-3b2d-4f8e-9a61-2d0c5b8e4f13"
}