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:

  1. Click Create webhook
  2. Enter your endpoint URL (must be HTTPS in production)
  3. Select the events you care about
  4. Copy the signing secret — you'll use it to verify the HMAC signature

Event types

TypeFires when
email.sentMail handed off to the SMTP relay
email.deliveredReceiving server returned 250 OK
email.bouncedPermanent (5xx) or temp-then-final (4xx → exhausted) failure
email.complainedRecipient hit "spam" — from FBL or recipient ISP feedback
email.openedTracking pixel loaded (only fires if tracking was enabled on send)
email.clickedTracked link clicked
email.receivedInbound email parsed (with AI fields, see below)

Outbound payload shape

Same as Resend's wire shape so existing handlers keep working.

email.delivered
{
  "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.

email.received
{
  "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

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

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

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:

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:

Local development

To test webhooks locally without exposing your machine, use a tunnel like ngrok, cloudflared, or smee.io:

terminal
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

  1. Send a test email to a recipient you control
  2. Open the email — should fire email.opened
  3. Click any link — should fire email.clicked
  4. 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.