Skip to content
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.

Request
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"}'
Response
{
  "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:

Headers
Content-Type: application/json
X-Sandbox-Event: message.received
X-Sandbox-Delivery: 8412
X-Sandbox-Signature: t=1791799203,v1=5c1f…e07a
Body
{
  "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>:

  1. Read the raw request body, the exact bytes, before any JSON parsing.
  2. Build the string <t>.<raw body>: the timestamp, a dot, then the body.
  3. Compute its HMAC-SHA256 with your webhook secret as the key, as lowercase hex.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is more than a few minutes (five is typical) from your clock, so a captured request cannot be replayed later.
Node / Python / PHP / Go
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 2xx status 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 the X-Sandbox-Delivery header) identify it.
  • Order is not guaranteed between messages.

Debug a webhook

  • POST /v1/webhooks/{id}/test sends a webhook.test event so you can try your handler without sending mail.
  • GET /v1/webhooks/{id}/deliveries lists the last 50 deliveries with their status, attempts, last HTTP status and last error.
  • POST /v1/webhook-deliveries/{id}/retry sends a finished delivery again once you have fixed your endpoint.

See Webhooks in the API reference for every field.