MillionSend Docs

Webhooks

Signed event deliveries for the email lifecycle, following the Standard Webhooks spec.

Webhooks push email lifecycle events to your endpoints as they happen. Create endpoints in the dashboard, choose which event types each one receives, and inspect every delivery (payload, response, attempts) in the per-endpoint delivery log.

Event types

EventFired when
email.sentSES accepted the message for delivery.
email.deliveredThe recipient server accepted it.
email.delivery_delayedDelivery is being retried (e.g. mailbox full, greylisting).
email.bouncedThe message hard-bounced. The address is also suppressed.
email.complainedThe recipient marked it as spam. Also suppressed.
email.openedA person loaded the tracking pixel (requires open tracking on the domain). data.open carries the fetch's ipAddress, userAgent and timestamp. A click on a message with no open yet also records one, with data.open.reason: "click": a person cannot click what they never rendered, and for Apple Mail readers it is the only open that can ever be seen.
email.clickedA person clicked a rewritten link (requires click tracking). data.click carries link, ipAddress, userAgent and timestamp, as Resend's does. A link a machine followed — a security gateway, a link preview, a fetch seconds after delivery — is recorded as email.prefetched instead.
email.prefetchedThe pixel was fetched, or a link followed, by a machine — Apple Mail Privacy Protection, Gmail's prefetch, a security scanner, a browser identity no real browser sends, a fetch within seconds of delivery, or every link of the message within a second; data.open.reason or data.click.reason says which (see open-rate accuracy). A click recorded before the rest of its burst arrived is re-recorded here with the same data.click.timestamp its email.clicked carried: treat that as the retraction of the click and of any email.opened with data.open.reason: "click" stamped one millisecond before it. Opt-in: delivered only to endpoints that name it, never to "all events".

Team-level events carry no email; data describes the team's standing instead:

EventFired whendata
deliverability.warningThe team's hard-bounce or complaint rate crossed the risk line (once per episode).{ metric, rate, limit, window_days, dashboard_url }
deliverability.pausedThe rate crossed the pause line; new sends are refused until it recovers.same as above
quota.warning80% of the cap is used: today's daily cap on Free and Starter, the billing period's included volume on Pro and Scale (once per UTC day or per period, cloud only).{ used, limit, period, resets_at, dashboard_url } — period is "day" or "month"; resets_at is the next UTC midnight or the period's end. On a day, ceiling is where parking begins; on a month, overage says whether sends past limit bill or are refused.
quota.reachedThe cap is used. Daily plans pass up to 50% more, then park until midnight UTC; monthly plans bill overage when it is on, otherwise refuse API sends and park broadcasts until the period renews.same as above
quota.pausedDaily plans only: 50% past the cap, new sends are parked until midnight UTC, or until a plan upgrade releases them (once per UTC day, cloud only).same as above, always period: "day"

Audience events fire when a contact or the suppression list changes, whoever changed it. data carries the contact in Resend's shape — id, email, first_name, last_name, unsubscribed, created_at, updated_at — plus source: api, dashboard, hosted_page (the preference center) or one_click (an RFC 8058 header post). Resend emits only contact.created, contact.updated and contact.deleted; the rest are MillionSend extensions.

EventFired whenExtra data
contact.createdA contact was added: API, batch, CSV import or dashboard.
contact.updatedName, properties or the unsubscribed flag changed through the API or the dashboard. A write that restates the stored values (a full re-import, say) emits nothing and leaves updated_at untouched.
contact.deletedA contact was deleted. After an erasure (erase=true on the API, or the dashboard's erase action) the stored email reads [erased]; key on id.
contact.unsubscribedThe contact opted out of all marketing email.
contact.resubscribedAn explicit re-subscribe (unsubscribed: false).
contact.topic_opt_in / contact.topic_opt_outThe contact's effective subscription to a topic flipped.topic_id, topic_name
suppression.added / suppression.removedAn address joined or left the suppression list. Bounce and complaint rows come from SES, with source: null.data is { id, email, origin, source, created_at }

Signatures (Standard Webhooks)

Deliveries are signed following the Standard Webhooks spec — the same scheme Resend and Svix use, so existing verification code works unchanged.

Each endpoint has a whsec_... secret, shown once at creation. Every request carries:

webhook-id: <message id>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64 HMAC-SHA256>

The same three values are also sent as svix-id, svix-timestamp and svix-signature — the names Resend's docs tell receivers to read. One signature, two header names: a handler written for either family verifies without changes.

The signed content is {webhook-id}.{webhook-timestamp}.{raw body}. During a secret rotation the header carries two space-separated v1,… candidates, new secret first; a verifier accepts if any of them matches, which every Standard Webhooks library does. Verify with one, e.g. in Node:

import { Webhook } from "standardwebhooks";

const wh = new Webhook("whsec_...");
const event = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
});

Always verify against the raw request body, and reject stale timestamps.

Bringing your own secret

POST /webhooks accepts an optional signing_secret: whsec_ followed by standard base64 of 24–64 bytes — the format Resend and Svix issue. Pass the secret your receiver already verifies with and the endpoint keeps working without a redeploy; omit it and MillionSend generates one. Anything else is rejected with 422 signing_secret must be whsec_ followed by base64 of 24-64 bytes.

To carry a secret over from another provider, read it from their API or dashboard (Resend returns it on GET /webhooks/{id}) and create the endpoint here with the same value:

curl -X POST "https://api.millionsend.com/webhooks" \
  -H "Authorization: Bearer ms_..." \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://example.com/webhooks/email",
    "events": ["email.delivered", "email.bounced"],
    "signing_secret": "whsec_..."
  }'

The secret is returned on create and on GET /webhooks/{id}, never in list rows.

Rotating the secret

POST /webhooks/{id}/rotate (or Rotate secret in the dashboard) mints a new secret, or takes the one in signing_secret, and returns it. For overlap_hours (default 24, up to 72) the previous secret keeps signing too: every delivery in that window carries both signatures, so a receiver holding either one verifies. Switch the receiver at any point in the window; after it only the new secret signs. 0 drops the old secret at once, for a leaked one. GET /webhooks/{id} reports the window's end as previous_secret_expires_at, and a second rotation inside the window replaces the previous secret.

curl -X POST "https://api.millionsend.com/webhooks/{id}/rotate" \
  -H "Authorization: Bearer ms_..." \
  -H "Content-Type: application/json" \
  -d '{ "overlap_hours": 24 }'
# → { "object": "webhook", "id": "...", "signing_secret": "whsec_...", "previous_secret_expires_at": "..." }

Delivery

A delivery is successful on any 2xx response. Each endpoint has its own queue, started in due order with up to eight requests in flight at up to 50 requests per second; a burst of events waits in the queue rather than hitting the receiver all at once.

A failed attempt (non-2xx, timeout, connection error) is retried on a fixed schedule: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h — six attempts over about 18 hours. A 429 with a Retry-After header is honoured (up to an hour) and does not count as an attempt: it is the receiver asking for room, not failing. An event still undelivered 24 hours after it was queued is dropped as exhausted without another attempt.

After 20 consecutive exhausted deliveries the endpoint is disabled automatically and receives nothing further until you re-enable it from its page; events that happen in the meantime are not replayed. Team owners are emailed when an endpoint's deliveries start failing (the last ten settled deliveries all exhausted), when it is disabled, and — at most once a day — when its backlog is more than six hours old. The dashboard shows each endpoint's queue depth and how long its oldest delivery has been waiting.

A reconcile job re-arms queues lost to crashes, so delivery is at-least-once — make handlers idempotent, keyed on webhook-id. Delivery rows (payload, response, attempts) are kept for WEBHOOK_DELIVERY_RETENTION_DAYS (default 30) and then purged.

Subscribe each endpoint only to the events it needs. A full contact re-import emits nothing for contacts that did not change, but every new contact is one contact.created delivery — an endpoint subscribed to "all events" receives all of them.

On this page