Webhooks push email events to your application as they happen, so you don't have to poll the API. Each delivery is signed so you can verify it really came from Mailloop.
Webhooks are available on the Basic, Pro, and Enterprise plans.
Setting up a webhook
- Open a sandbox in the dashboard.
- Scroll to the Webhooks section and click Add Webhook.
- Paste your endpoint URL. It must be HTTPS and reachable from the public internet.
- We'll show you a signing secret. Store it like a password — you'll need it to verify deliveries. You can view it again later from the webhook detail page.
That's it. The next time an email arrives in this sandbox, your endpoint receives a POST.
What we send
Every delivery is a POST with Content-Type: application/json and these headers:
| Header | Description |
|---|---|
X-Mailloop-Event | Event name. Today only email.received. |
X-Mailloop-Delivery | UUID unique to this delivery. Use it for idempotency. |
X-Mailloop-Timestamp | Unix seconds. Reject deliveries older than ~5 minutes. |
X-Mailloop-Signature | v0=<hex> — see below. |
X-Mailloop-Attempt | Retry attempt number, starting at 1. |
The body for email.received looks like:
{
"event": "email.received",
"deliveryId": "8f3e1a4d-...",
"timestamp": "2026-04-28T20:14:32.117Z",
"attempt": 1,
"data": {
"id": "ck...",
"sandboxId": "ck...",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome",
"receivedAt": "2026-04-28T20:14:31.984Z",
"hasAttachments": false,
"messageId": "<[email protected]>"
}
} Body content is intentionally not included — fetch it from GET /v1/emails/:id if you need it.
Verifying the signature
Compute HMAC-SHA256(secret, "v0:" + timestamp + ":" + rawBody) and compare against the hex after v0= in X-Mailloop-Signature. Use a constant-time compare. Reject anything where the timestamp is more than 5 minutes off your clock — that defends against replay.
Node.js
import crypto from 'node:crypto';
function verify(req, rawBody, secret) {
const sig = req.headers['x-mailloop-signature'] ?? '';
const ts = req.headers['x-mailloop-timestamp'] ?? '';
if (!sig.startsWith('v0=')) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`v0:${ts}:${rawBody}`)
.digest('hex');
const a = Buffer.from(sig.slice(3), 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
} Python
import hmac, hashlib, time
def verify(headers, raw_body, secret):
sig = headers.get('X-Mailloop-Signature', '')
ts = headers.get('X-Mailloop-Timestamp', '')
if not sig.startswith('v0='):
return False
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(
secret.encode(), f'v0:{ts}:{raw_body.decode()}'.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(sig[3:], expected) Retries
If your endpoint returns a non-2xx status or doesn't respond within 10 seconds, we'll retry — 3 total attempts with delays of 1 s and then 5 s between them. The X-Mailloop-Delivery UUID stays the same across retries; use it as your idempotency key.
If your endpoint fails 10 times in a row across separate events, the webhook is auto-paused. You'll see it in the dashboard as auto-paused. Click Resume once your endpoint is healthy again.
Plan limits
| Plan | Per sandbox | Per organization |
|---|---|---|
| Basic | 1 | 5 |
| Pro | 3 | 20 |
| Enterprise | 5 | 200 |
Security
- URLs must be HTTPS in production. We reject URLs that resolve to private IPs (RFC 1918, loopback, link-local, cloud metadata) at registration and at every delivery, so DNS rebinding can't reach internal services.
- Redirects are not followed. If your endpoint moves, update the URL in the dashboard.
- Response bodies are not stored. Only the status code, latency, and a normalized error class show up in the delivery log.
- Rotate a compromised secret by deleting the webhook and creating a new one.
Troubleshooting
Deliveries failing with ssrf_blocked — your URL resolves to a private IP. If you're testing locally, use a tunnel like ngrok or Cloudflare Tunnel.
Deliveries failing with tls_error — your endpoint's TLS certificate is invalid or expired.
Signature doesn't match — almost always because the body was reparsed somewhere along the way. Sign the raw bytes you received before any JSON parsing.
Timestamps drifting — make sure your server clock is in sync (NTP). We allow ±5 minutes.