Webhooks
Real-time event delivery — outbound (delivered, bounced, opened, clicked, complained) and inbound (with AI classification fields). Resend-shape payloads so existing code keeps working; AI fields are additive.
Configure a webhook
In the dashboard at /webhooks:
- Click Create webhook
- Enter your endpoint URL (must be HTTPS in production)
- Select the events you care about
- Copy the signing secret — you'll use it to verify the HMAC signature
Event types
| Type | Fires when |
|---|---|
email.sent | Mail handed off to the SMTP relay |
email.delivered | Receiving server returned 250 OK |
email.bounced | Permanent (5xx) or temp-then-final (4xx → exhausted) failure |
email.complained | Recipient hit "spam" — from FBL or recipient ISP feedback |
email.opened | Tracking pixel loaded (only fires if tracking was enabled on send) |
email.clicked | Tracked link clicked |
email.received | Inbound email parsed (with AI fields, see below) |
Outbound payload shape
Same as Resend's wire shape so existing handlers keep working.
{
"type": "email.delivered",
"created_at": "2026-04-26T15:33:18Z",
"data": {
"email_id": "39b79255069f04bcb5f9c94643c74e8f",
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"subject": "Welcome"
}
}Inbound payload (with AI classification)
The differentiator. While Resend's email.received is metadata only and forces you to fetch the body via a second API call, ours ships the body and pre-computed AI fields in one payload.
{
"type": "email.received",
"created_at": "2026-04-26T18:00:00Z",
"data": {
"email_id": "abc...",
"from": "customer@theiremail.com",
"to": ["support@yourdomain.com"],
"subject": "Mi factura está mal",
"body_text": "Hola, mi factura del mes...",
"body_html": "<p>Hola, mi factura...</p>",
"attachments": [
{
"id": "...",
"filename": "invoice.pdf",
"url": "https://mailstorm.dev/attachments/...?sig=..."
}
],
"ai": {
"category": "billing",
"category_confidence": 0.93,
"urgency": "high",
"sentiment": "negative",
"language": "es",
"intent": "dispute_charge"
}
}
}v0.1 status: Inbound parsing is live; the AI classification fields are coming online during Phase 6 (Q2 2026). Until then, the ai object will be absent or sparse — your handler should read it as optional and fall back to your own logic.
HMAC signature verification
Every webhook POST includes an X-Mailstorm-Signature header — HMAC-SHA256 of the raw request body using your webhook's signing secret, hex-encoded.
Node.js / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto"; function verify(rawBody: string, signature: string, secret: string): boolean { const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); }
Python
import hmac, hashlib def verify(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, expected)
Go
func verify(body []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(signature), []byte(expected)) }
Verify on the raw body, not the parsed JSON. Re-serializing JSON re-orders keys and breaks the HMAC. Most frameworks (Express, Next.js Route Handlers, FastAPI) let you read the body as text first, verify, then JSON.parse for handling.
Retry semantics
If your endpoint returns a 2xx, the event is acknowledged. Anything else is retried with exponential backoff:
- Attempt 1: immediate
- Attempt 2: +5 seconds
- Attempt 3: +30 seconds
- Attempt 4: +2 minutes
After 4 failed attempts the event is logged as "failed" in the dashboard at /webhooks and not retried further. To replay manually: GET the failed event from the webhook history and re-deliver from the dashboard.
Idempotency in your handler
Webhooks can be delivered more than once (network blips, our retries, your downtime + late-success). Your handler should be idempotent. Three patterns:
- Use the email_id as a deduplication key. Track which event types you've processed per email; ignore duplicates.
- Database upserts. If your handler writes a row, use
INSERT ... ON CONFLICT DO NOTHINGwith a unique constraint on(email_id, event_type). - Idempotent operations. "Mark email as delivered" is idempotent — calling it twice is harmless. Prefer this shape over "increment counter" or "send another email."
Local development
To test webhooks locally without exposing your machine, use a tunnel like ngrok, cloudflared, or smee.io:
ngrok http 3000 # forwarding URL: https://abc123.ngrok-free.app # register https://abc123.ngrok-free.app/webhook in Mailstorm dashboard
Test mode (ms_test_* sends) does fire webhook events with last_event: "queued" — you can develop and test the full payload pipeline without sending real mail.
Recommended: end-to-end test
- Send a test email to a recipient you control
- Open the email — should fire
email.opened - Click any link — should fire
email.clicked - Reply from a "bounced" address (e.g. nonexistent local-part on a real domain) — fires
email.bounced
Get help
Webhook not firing? Check the webhook event log at /webhooks — every delivery attempt is recorded with the response status. If you see 4 failed attempts in a row with the same error, your endpoint is rejecting us; debug from your end first.