Webhooks
Webhooks are how MiniMailer tells your application what happened — every email lifecycle event, campaign transition, and domain state change can push to an HTTPS endpoint you control, as it happens. The event model is webhooks-first by design: polling the API works, but webhooks are the intended integration.
Deliveries follow the Standard Webhooks specification, so you can verify signatures with an off-the-shelf library in any language instead of hand-rolling crypto.
Create a webhook
A webhook belongs to a domain and subscribes to one or more event types:
curl https://api.minimailer.app/webhooks \ -X POST \ -H "Authorization: Bearer $MINIMAILER_TOKEN" \ -H "Content-Type: application/vnd.api+json" \ -H "Accept: application/vnd.api+json" \ -d '{ "data": { "type": "webhooks", "attributes": { "name": "Production events", "url": "https://app.example.com/hooks/minimailer", "events": ["email.delivered", "email.bounced", "email.complained", "inbound_email.received"] }, "relationships": { "domain": { "data": { "type": "domains", "id": "{domain}" } } } } }'
Subscribe to exactly what you handle, or to everything with ["*"]. Event types:
| Group | Events |
|---|---|
| Email lifecycle | email.sent · email.delivered · email.delayed · email.bounced · email.complained · email.opened · email.clicked · email.rejected · email.unsubscribed |
| Inbound | inbound_email.received |
| Campaigns | campaign.sending · campaign.sent · campaign.paused · campaign.failed |
| Domain | domain.verified · domain.verification_failed · domain.suspended · domain.reputation_warning · domain.reputation_recovered |
| Account | usage.limit_approaching · usage.limit_reached · subscription.past_due · subscription.downgrade_scheduled · subscription.revoked |
| Agent reputation | agent.pool.promoted · agent.pool.demoted · agent.pool.recovered · agent.reputation.damaged |
The creation response includes the signing secret (prefixed whsec_) once. Store it next to the endpoint that will verify deliveries; afterwards the API exposes only a secret_hint (the last four characters) so you can tell keys apart.
The delivery
Each delivery is an HTTP POST with three Standard Webhooks headers and a JSON body:
webhook-id: msg_01k02w9k3f7m8n5p6q7r8s9t0v webhook-timestamp: 1752566400 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pojjQmB6Jla4tA= Content-Type: application/json
{ "type": "email.delivered", "timestamp": "2026-07-15T08:30:00+00:00", "data": { "...": "event-specific fields" } }
webhook-id is unique per logical delivery and stable across retries — it is your idempotency key. Process each webhook-id once, no matter how many times it arrives.
Verify the signature
Use a Standard Webhooks library — most languages have one; pass it the secret, the headers, and the raw request body. Under the hood verification is:
- Concatenate
{webhook-id}.{webhook-timestamp}.{raw body}. - Compute HMAC-SHA256 over that string, keyed with the base64-decoded secret (the part after
whsec_). - Base64-encode the result and compare against the header value after
v1,— constant-time. - Reject stale timestamps (the libraries default to a five-minute tolerance) to block replays.
The signature header can carry multiple space-separated signatures; accept the delivery if any one verifies. That is what makes secret rotation seamless.
Respond 2xx quickly — acknowledge, queue, process async. Anything slower than 15 seconds counts as a failed attempt.
Retries
A failed delivery (non-2xx, timeout, connection error) is retried up to 10 attempts total with increasing delays, each with ±10% jitter:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Delay after failure | — | 5s | 5m | 30m | 2h | 5h | 10h | 14h | 20h | 24h |
Three responses get special treatment:
429— theRetry-Afterheader, when present, overrides the schedule.410 Gone— the webhook is disabled immediately; you are telling us the endpoint no longer exists.- 10 consecutive failed deliveries — the webhook is disabled automatically and you are notified, so a dead endpoint doesn't retry forever.
Rotate the secret
Rotate by updating the webhook with rotate_secret: true. The response carries the new secret (again, shown once); the old secret keeps signing for 24 hours alongside the new one — deliveries in that window carry two signatures, so deploy the new secret to your endpoint at your own pace, then the old one expires on its own.
Inspect and replay deliveries
Every delivery attempt is recorded — status, response code, response body (truncated), attempt count:
GET /webhooks/{webhook}/deliveries— delivery history for a webhook.GET /deliveries/{outboundWebhook}— one delivery in detail.POST /deliveries/{outboundWebhook}/replay— redeliver. A replay is a new delivery with a freshwebhook-id, so your idempotency handling treats it as distinct — exactly what you want when the original was consumed by a buggy handler.
The dashboard's webhook activity log shows the same history with one-click replay.