Mail · Guides
Webhooks
Webhooks tell your app what happens to your emails, as it happens: one signed HTTPS request per event.
Create a webhook
A webhook is a URL in your app and a list of events to send to it. The URL must be public and use https://. You can have up to 5 webhooks.
curl -X POST https://services.my-beast.com/mail/api/email/v1/webhooks \
-H "Authorization: Bearer $MYBEAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.com/webhooks/my-beast",
"events": [
"email.delivered",
"email.bounced",
"email.complained",
"email.failed"
]
}'{
"id": "wh_01JA2T4H8MC6YP",
"url": "https://acme.com/webhooks/my-beast",
"events": [
"email.delivered",
"email.bounced",
"email.complained",
"email.failed"
],
"created_at": "2026-10-09T09:20:15Z",
"secret": "whsec_5Kq8vR2xN7mT4bW9cJ3hL6pD1fG0sY"
}The response includes the webhook's signing secret, which is only shown here. Store it with your other secrets. Webhooks can't be changed: to use a new URL, different events or a new secret, create a new webhook and then delete the old one.
Events
| Event | Meaning |
|---|---|
email.sent | We started delivering an email to a recipient. |
email.delivered | The recipient's mail server accepted the email. |
email.bounced | The email bounced for a recipient. The address is now on your suppression list. |
email.complained | A recipient marked the email as spam. The address is now on your suppression list. |
email.failed | We didn't deliver the email to a recipient. reason says why. |
Events are per recipient: an email to three addresses sends up to three of each event. Each event happens at most once for each recipient of an email. One email can have several events over time: a recipient's email can be delivered first and bounced later, when a server accepts it and sends a bounce message afterwards.
Payload
We send each event as a POST request with a JSON body and these headers: Content-Type: application/json and MyBeast-Signature.
{
"type": "email.bounced",
"created_at": "2026-10-09T09:14:06Z",
"data": {
"email_id": "em_01JA2Q8ZK4T9W6",
"recipient": "old-address@example.com",
"from": "Acme <hello@acme.com>",
"subject": "Your order has shipped",
"tags": [{ "name": "category", "value": "shipping" }],
"reason": null,
"message": "550 5.1.1 User unknown"
}
}| Field | Type | Description |
|---|---|---|
type | string | The event, such as email.bounced. |
created_at | string | When it happened (ISO 8601, UTC). |
data.email_id | string | The email's ID. |
data.recipient | string | The address this event is about. |
data.from | string | The sender, as you sent it. |
data.subject | string | The subject line. |
data.tags | array of objects | The email's tags, each with a name and value. |
data.reason | string or null | On email.failed: suppressed or expired. Otherwise null. |
data.message | string or null | On email.bounced and email.failed: a short explanation, often the receiving server's reply. Otherwise null. |
The same email's events are also listed by Retrieve an email, so you can always look up what happened.
Verify signatures
Anyone can send a request to your webhook URL, so check that each one comes from us before you trust it. Every request has a MyBeast-Signature header like this:
MyBeast-Signature: t=1791544446,v1=5f2b8c0e6d1a4f9b3c7e2a8d6f0b4c1e9a7d3f5b2c8e6a0d4f1b9c7e3a5d2f8bt is when we sent the request, in Unix seconds. v1 is the signature. To check it:
- Take the raw request body, exactly as it arrived. Parsing the JSON and encoding it again changes the bytes, and the check fails.
- Join
t, a full stop and the raw body:<t>.<raw body>. - Compute an HMAC-SHA256 of that, using the whole webhook secret (including
whsec_) as the key, as lowercase hex. - Compare it with
v1using a constant-time comparison. - Check that
tis no more than 5 minutes away from your server's clock, so an old request can't be replayed.
If any step fails, answer with an error such as 400 and ignore the request.
// npm install express
import crypto from "node:crypto";
import express from "express";
const app = express();
function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((part) => {
const [key, ...rest] = part.trim().split("=");
return [key, rest.join("=")];
}),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body exactly as it was sent: the signature covers those bytes.
app.post("/webhooks/my-beast", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("MyBeast-Signature") ?? "";
if (!verifySignature(req.body, header, process.env.MYBEAST_WEBHOOK_SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Handle event.type here, then answer quickly.
res.sendStatus(200);
});
app.listen(3000);Keep your server's clock right
Responses and retries
Answer with any 2xx status within 10 seconds to confirm you received the event. Anything else counts as a failure: another status, a redirect (we don't follow them), a connection error or no answer in time.
We retry failed deliveries with increasing delays for up to 24 hours: after 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours, each counted from the previous attempt. If the last attempt fails too, we drop that event. Deleting a webhook also stops its retries.
Good practice
- Answer first, work later. Store the event or put it on a queue, answer
200, then process it. - Expect duplicates. A retry can deliver an event you already handled. The combination of
data.email_id,data.recipientandtypeidentifies an event. - Don't rely on order. Events can arrive out of order, especially after retries. Use
created_atto see which came first. - Test with a real email. There are no test events: send an email to an address you can read and watch the events arrive.