Webhooks and events
Every change, from a received email to a bounce, is an event. Get events pushed to your URL, signed with Standard Webhooks, or stream them over a WebSocket.
Create a webhook
POST/v1/webhooks
| Field | Type | Description |
|---|---|---|
| url | string | A public https:// URL. Private, loopback and reserved addresses are refused, also when a hostname resolves to one at delivery time. |
| event_types | string[] | The events to send, such as ["message.received"]. Empty, missing or ["*"] means every event. |
| inbox_id | string | null | Only events of this inbox. Null means every inbox in the org. An inbox scoped key always gets its own inbox. |
The response is 201 with the webhook and its signing secret, whsec_ followed by base64. The secret is shown only here, so store it now.
curl https://api.mailbay.dev/v1/webhooks \
-H "Authorization: Bearer $MAILBAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/mailbay", "event_types": ["message.received"]}'
# {"id": "whk_01jbz...", "url": "https://example.com/hooks/mailbay",
# "event_types": ["message.received"], "inbox_id": null, "secret": "whsec_..."}List and delete
GET/v1/webhooks
DELETE/v1/webhooks/{id}
Listing never returns secrets. Deleting stops deliveries at once, including pending retries; the delivery history is kept.
Event types
| Type | Data | When |
|---|---|---|
| message.received | received | An inbox received an email. |
| message.queued | queued | A message you sent was stored and handed to the mail server. |
| message.delivered | delivery | A recipient's server accepted it. |
| message.deferred | delivery | A temporary failure; delivery is retried. |
| message.bounced | delivery | A recipient's server refused it. permanent is true for a hard bounce. |
| message.complained | delivery | A recipient reported it as spam. |
| message.failed | delivery | The mail server gave up retrying, or did not accept the message (then data has reason instead of the delivery fields). |
| sending.paused | workspace | Mailbay paused sending for the workspace. |
| sending.resumed | workspace | Mailbay support resumed sending for the workspace. |
The data of each type: received has message_id, inbox_id, thread_id, from and subject. queued has message_id, inbox_id, thread_id, to, cc and subject. delivery has message_id, inbox_id, thread_id, recipient, response (the remote server's reply, such as 550 5.1.1 no such user), bounce_classification, permanent and status, the message's new status. workspace has workspace_id and status (paused or active); sending.paused adds reason, paused_at and stats. Each project of the workspace gets the event once, and webhooks limited to one inbox do not receive it.
Payload
Each delivery is a POST with this JSON body, and the WebSocket sends the same body. GET /v1/events returns the same events with org_id and created_at in place of timestamp.
{
"id": "evt_01jbz9c4m7r2k8t6w3x5y0z1a2",
"type": "message.received",
"timestamp": "2026-10-13T09:41:07.512Z",
"data": {
"message_id": "msg_01jbz9c3…",
"inbox_id": "ibx_01jbz8w4k2q9",
"thread_id": "thr_01jbz9c3…",
"from": "noreply@github.com",
"subject": "Your GitHub launch code"
}
}Answer with any 2xx within 10 seconds. Redirects are not followed and count as failures.
Verify the signature
Deliveries are signed with the Standard Webhooks scheme, so any Standard Webhooks library works. Three headers come with each request:
| Header | Example | Meaning |
|---|---|---|
| webhook-id | evt_01jbz9c4… | The event id. The same on every retry, so use it to ignore duplicates. |
| webhook-timestamp | 1760348467 | Unix seconds when this attempt was signed. |
| webhook-signature | v1,g0hM9SsE… | Base64 HMAC SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64 decoded part of the secret after whsec_. |
Verify against the raw body, before parsing it, then check the timestamp is recent.
import crypto from "node:crypto";
// secret: the whsec_... value returned when you created the webhook.
// body: the raw request body as a string, before any JSON parsing.
export function verifyWebhook(secret, headers, body) {
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");
// Reject timestamps more than 5 minutes away, so a captured request cannot be replayed.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) throw new Error("timestamp too old");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
// The header holds one or more space separated "v1,<base64>" signatures.
const ok = signatures.split(" ").some((s) => {
const [version, sig] = s.split(",");
if (version !== "v1" || !sig) return false;
const given = Buffer.from(sig, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!ok) throw new Error("invalid signature");
return JSON.parse(body);
}import base64, hashlib, hmac, json, time
def verify_webhook(secret: str, headers, body: str) -> dict:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signatures = headers["webhook-signature"]
# Reject timestamps more than 5 minutes away, so a captured request cannot be replayed.
if abs(int(time.time()) - int(timestamp)) > 300:
raise ValueError("timestamp too old")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.{body}".encode()
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for s in signatures.split(" "):
version, _, sig = s.partition(",")
if version == "v1" and hmac.compare_digest(sig, expected):
return json.loads(body)
raise ValueError("invalid signature")Check your code with this test vector from the Standard Webhooks spec; both samples above pass it.
secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek
timestamp 1614265330
body {"test": 2432232314}
signature v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=express.raw({ type: "application/json" }) so it reaches your handler unparsed. Re-serialized JSON will not match the signature.Retries
A delivery that does not get a 2xx is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours: seven attempts over about 18 hours. After the last one the delivery is marked failed. A URL that resolves to a private address fails at once with blocked_private_address, since retrying would not help.
Deliveries and replay
GET/v1/webhooks/{id}/deliveries?limit=
POST/v1/webhooks/{id}/deliveries/{delivery_id}/replay
Deliveries are listed newest first, 50 by default and at most 100. Each shows its status (pending, succeeded or failed), attempt, response_code, the first 500 characters of your response in response_body_snippet, and next_attempt_at. Replay sends the same event again now as a new delivery, with replay_of set to the original, and answers 202.
WebSocket stream
GET/v1/events/ws?token=&after=
Streams events as JSON text frames, the same shape as the webhook body. Authenticate with an Authorization header, or with ?token= where the client cannot set headers, as in a browser. Without after the stream starts from now; pass the id of the last event you handled to resume after a disconnect without missing any.
const ws = new WebSocket(`wss://api.mailbay.dev/v1/events/ws?token=${key}&after=${lastEventId}`);
ws.onmessage = (m) => {
const event = JSON.parse(m.data);
lastEventId = event.id;
if (event.type === "message.received") console.log(event.data.subject);
};Polling
GET/v1/events?after=&limit=
Events oldest first, 50 by default and at most 100, with an id greater than after. Pass the returned next_after on the next call. Leave out after to start from the oldest event. Inbox scoped keys only see events of their inbox, here and on the WebSocket.
curl "https://api.mailbay.dev/v1/events?after=evt_01jbz9c4m7r2k8t6w3x5y0z1a2" \
-H "Authorization: Bearer $MAILBAY_API_KEY"
# {"data": [ ...events ], "next_after": "evt_01jbz9..."}