Documentation menu
Guides
Webhooks
A webhook calls your server when a new email arrives, so your tests and tools can react without polling.
Create a webhook
Give us an HTTPS URL on a public address. Leave inbox_id out to receive every inbox in the workspace, or set it to limit the webhook to one. How many webhooks you can have depends on your plan.
curl -X POST https://app.mailbench.dev/v1/webhooks \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://ci.example.com/hooks/email"}'{
"success": true,
"data": {
"id": "wh_4mq7h2b9x1k3d8ra",
"inbox_id": null,
"url": "https://ci.example.com/hooks/email",
"created_at": "2026-10-12T10:00:00Z",
"secret": "whsec_…"
}
}What we send
One event type exists today: message.received, sent for every message captured in the inbox, whether it arrived over SMTP or through the API. Each delivery is an HTTPS POST with a JSON body and these headers:
Content-Type: application/json
X-Sandbox-Event: message.received
X-Sandbox-Delivery: 8412
X-Sandbox-Signature: t=1791799203,v1=5c1f…e07a{
"event": "message.received",
"created_at": "2026-10-12T10:00:03Z",
"data": {
"id": "msg_htumcicppd26jek6",
"inbox_id": "box_g3bekcev4z7i4psq",
"received_at": "2026-10-12T10:00:03Z",
"envelope_from": "noreply@app.test",
"recipients": ["sam@example.org"],
"from": "App <noreply@app.test>",
"to": "Sam <sam@example.org>",
"subject": "Welcome aboard",
"message_id": "<8f2c@app.test>",
"size": 1432,
"has_text": true,
"has_html": true,
"attachment_count": 0,
"link_count": 2,
"tls": true,
"read": false,
"tags": []
}
}data is the message without its text and HTML bodies, to keep deliveries small. Fetch the full message with GET /v1/messages/{id}.
Verify the signature
Anyone who knows your URL can send it a request. Check the X-Sandbox-Signature header before trusting a delivery. It looks like t=<unix seconds>,v1=<hex>:
- Read the raw request body, the exact bytes, before any JSON parsing.
- Build the string
<t>.<raw body>: the timestamp, a dot, then the body. - Compute its HMAC-SHA256 with your webhook secret as the key, as lowercase hex.
- Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than a few minutes (five is typical) from your clock, so a captured request cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
/** rawBody must be the exact bytes received, before any JSON parsing. */
export function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
const sig = parts.v1;
if (!t || !sig) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: read the raw body so the signature can be checked.
app.post("/hooks/email", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.body, req.header("X-Sandbox-Signature") ?? "", process.env.WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// handle event.data ...
res.sendStatus(200);
});import hashlib
import hmac
import time
def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""raw_body must be the exact bytes received, before any JSON parsing."""
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t, sig = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
# Flask
@app.post("/hooks/email")
def hook():
if not verify_signature(request.get_data(), request.headers.get("X-Sandbox-Signature", ""), WEBHOOK_SECRET):
return "", 400
event = request.get_json()
# handle event["data"] ...
return "", 200<?php
function verifySignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$key] = $value;
}
$t = (int) ($parts['t'] ?? 0);
$sig = $parts['v1'] ?? '';
if ($t === 0 || $sig === '' || abs(time() - $t) > $tolerance) {
return false;
}
return hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $sig);
}
// $rawBody must be the exact bytes received, before any JSON parsing.
$rawBody = file_get_contents('php://input');
if (!verifySignature($rawBody, $_SERVER['HTTP_X_SANDBOX_SIGNATURE'] ?? '', getenv('WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
// handle $event['data'] ...
http_response_code(200);package hooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
// VerifySignature checks X-Sandbox-Signature against the exact bytes received.
func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
var t int64
var sig string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
sig = v
}
}
if t == 0 || sig == "" || math.Abs(float64(time.Now().Unix()-t)) > tolerance.Seconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(sig), []byte(hex.EncodeToString(mac.Sum(nil))))
}Delivery and retries
- Answer with any
2xxstatus within 10 seconds. Anything else, or no answer, counts as a failure. - A failed delivery is tried up to eight times in all, with growing gaps between attempts (10 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, then 3 hours), about five hours from first to last. After that it is marked failed.
- A delivery can arrive more than once. Make your handler idempotent: the message
data.id(and theX-Sandbox-Deliveryheader) identify it. - Order is not guaranteed between messages.
Debug a webhook
POST /v1/webhooks/{id}/testsends awebhook.testevent so you can try your handler without sending mail.GET /v1/webhooks/{id}/deliverieslists the last 50 deliveries with their status, attempts, last HTTP status and last error.POST /v1/webhook-deliveries/{id}/retrysends a finished delivery again once you have fixed your endpoint.
See Webhooks in the API reference for every field.