Receive events by webhook
Endpoints, event payloads, svix-compatible signatures with verification samples, retries, auto-disable, rotation, replay and test events.
Webhooks push every event of a project to your endpoints as it happens. The same events can be pulled from the feed, and Track events on the timeline explains where each one comes from.
Endpoints
An endpoint belongs to an environment and a mode: production and preview deployments get their own endpoints and secrets, and test endpoints receive only the events test keys simulate. Create one with a full-access key; a test key always creates a test endpoint.
curl https://api.minket.com/v1/webhooks \
-H "Authorization: Bearer $MINKET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.acme.example/webhooks/minket",
"description": "Production app",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'const endpoint = await minket.webhooks.create({
url: "https://app.acme.example/webhooks/minket",
description: "Production app",
events: ["email.delivered", "email.bounced", "email.complained"],
});{
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"object": "webhook",
"environment_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5a",
"mode": "live",
"url": "https://app.acme.example/webhooks/minket",
"description": "Production app",
"events": ["email.delivered", "email.bounced", "email.complained"],
"status": "enabled",
"secret": "whsec_…",
"stats": { "delivered_24h": 0, "failed_24h": 0, "last_success_at": null, "last_failure_at": null },
"created_at": "2026-10-24T18:00:00.000Z",
"updated_at": "2026-10-24T18:00:00.000Z"
}urlmust be https without credentials, and its host must resolve to public addresses only. Private, loopback, link-local and cloud metadata addresses are refused when the endpoint is created and again before every delivery, so a host that later resolves somewhere private is never reached. Deliveries never follow redirects.eventsfilters by type; omit it (orPATCHit tonull) to receive every type, including types added later (outside production,email.receivedmust still be listed: Routing rules).secretis returned on creation and on rotation only.- Up to 20 endpoints per environment.
PATCH /v1/webhooks/{id}changesurl,description,eventsandstatus(enabledordisabled);DELETE /v1/webhooks/{id}removes one with its delivery history. - An endpoint receives the events of its own environment and mode. Project-level events (contacts, domains, broadcasts, automations) have no environment and reach the production environment's live endpoints. Received mail belongs to the project, so
email.receivedfollows its own rule (Routing rules). - Every change is written to the audit log, without the secret and without the URL's query string.
In the console, Webhooks lists the project's endpoints with their deliveries, and the setup step Webhooks adds the first one and sends it a test event.
The delivery
Each delivery is one POST with one event. The body is { "type", "created_at", "data" }, serialised once when the event is recorded and sent unchanged on every attempt. The headers are svix's, plus the Standard Webhooks names with the same values:
POST /webhooks/minket HTTP/1.1
Content-Type: application/json
User-Agent: Minket-Webhooks/1
svix-id: msg_019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a60
svix-timestamp: 1761328800
svix-signature: v1,K1lQb2w3YnE1Y0F0ZE5hV1J2VHdxaXZlSHk0dz0=
webhook-id: msg_019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a60
webhook-timestamp: 1761328800
webhook-signature: v1,K1lQb2w3YnE1Y0F0ZE5hV1J2VHdxaXZlSHk0dz0=
{"type":"email.delivered","created_at":"2026-10-24T18:00:00.000Z","data":{…}}svix-id identifies the event for this endpoint and stays the same across retries and replays, so a receiver that stores the ids it has seen drops duplicates. Answer with any 2xx within 15 seconds and do the work afterwards.
Event types
| Event | Email status it sets |
|---|---|
email.sent | sent |
email.scheduled | scheduled |
email.delivered | delivered |
email.delivery_delayed | delivery_delayed |
email.complained | complained |
email.bounced | bounced |
email.opened | opened |
email.clicked | clicked |
email.received | |
email.failed | failed |
email.suppressed | suppressed |
email.canceled | canceled |
contact.created | |
contact.updated | |
contact.deleted | |
domain.created | |
domain.updated | |
domain.deleted | |
broadcast.started | |
broadcast.completed | |
automation.completed | |
automation.failed |
The second column is the status the event moves the email to.
Payloads
Every email.* event except email.received shares the same base data:
{
"email_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c",
"created_at": "2026-10-24T18:00:00.000Z",
"message_id": "<0100019a2b3c4d5e-6f708192-a3b4-c5d6-e7f8-091a2b3c4d5e-000000@email.amazonses.com>",
"from": "Acme <billing@mail.acme.example>",
"to": ["jane@example.com"],
"subject": "Your receipt for order 10452",
"broadcast_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a70",
"template_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a71",
"tags": { "type": "receipt" }
}broadcast_id, template_id and tags are present only when they apply. message_id is the Message-ID the email left with (The Message-ID), so events before sending (email.scheduled, email.canceled, an early email.failed or email.suppressed) have none. Types add their own fields:
| Type | Added to data |
|---|---|
email.scheduled |
scheduled_at |
email.delivery_delayed |
delay: { type, message } |
email.bounced |
bounce: { type,, where type is Permanent, Transient or Undetermined. Only permanent bounces suppress the address. |
email.complained |
complaint: { type } (the feedback type, or null) |
email.opened |
open: { ipAddress, |
email.clicked |
click: { ipAddress, |
email.failed |
failed: { reason } |
email.suppressed |
suppressed: { type, |
email.sent, email.delivered, email.canceled |
nothing (email.delivered may add smtp_response) |
A bounce, for example:
{
"type": "email.bounced",
"created_at": "2026-10-24T18:00:04.000Z",
"data": {
"email_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5c",
"created_at": "2026-10-24T18:00:00.000Z",
"message_id": "<0100019a2b3c4d5e-6f708192-a3b4-c5d6-e7f8-091a2b3c4d5e-000000@email.amazonses.com>",
"from": "Acme <billing@mail.acme.example>",
"to": ["jane@example.com"],
"subject": "Your receipt for order 10452",
"tags": { "type": "receipt" },
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "smtp; 550 5.1.1 user unknown"
}
}
}email.received adds the parsed content and the verdicts:
{
"type": "email.received",
"created_at": "2026-10-24T18:00:00.000Z",
"data": {
"email_id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a80",
"created_at": "2026-10-24T18:00:00.000Z",
"from": "Jane Doe <jane@example.com>",
"to": ["support@inbound.acme.example"],
"bcc": [],
"cc": [],
"received_for": ["support@inbound.acme.example"],
"message_id": "<CAE1b2c3@mail.example.com>",
"subject": "Question about my order",
"attachments": [
{
"id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a81",
"filename": "screenshot.png",
"content_type": "image/png",
"content_disposition": "attachment",
"content_id": null
}
],
"content": { "text": "Hi, …", "html": "<p>Hi, …</p>", "truncated": false },
"verdicts": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "pass", "virus": "pass" }
}
}content holds text and html while they fit in 256 KB together; past that html is left out, text is cut to fit and truncated is true, and the whole message is at GET /v1/inbound/{id}.
The other families:
| Type | data |
|---|---|
contact.created, contact.updated, contact.deleted |
id, audience_id, segment_ids, created_at, updated_at, email, first_name, last_name, unsubscribed |
domain.created, domain.updated, domain.deleted |
id, name, status, created_at, region, records[] with record, name, type, ttl, status, value, priority |
broadcast.started, broadcast.completed |
broadcast_id, name, recipients, sent, created_at |
automation.completed, automation.failed |
automation_id, run_id, contact_id, version, error (failed only), created_at |
Verify signatures
The secret is whsec_ followed by base64; the key is the decoded bytes. The signed content is the id, the timestamp and the raw body joined with dots, and the signature is v1, followed by the base64 HMAC-SHA256. Several signatures can be sent separated by spaces (during rotation); accept the delivery if any matches and the timestamp is within 5 minutes, and compare in constant time. Verify the raw body bytes, not a re-serialized copy. The svix library's Webhook.verify works as is.
import { verifyWebhook } from "@minket/sdk";
export async function handle(request: Request): Promise<Response> {
const payload = await request.text();
const result = await verifyWebhook({
payload,
headers: Object.fromEntries(request.headers),
secret: process.env.MINKET_WEBHOOK_SECRET ?? "",
});
if (!result.ok) return new Response(result.reason, { status: 400 });
const event = JSON.parse(payload);
// result.msgId is the svix-id: store it to drop duplicates.
return new Response(null, { status: 200 });
}import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(payload: string, headers: Record<string, string | undefined>, secret: string): boolean {
const id = headers["svix-id"];
const timestamp = headers["svix-timestamp"];
const signatures = headers["svix-signature"];
if (!id || !timestamp || !signatures) return false;
const seconds = Number(timestamp);
if (!Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${payload}`).digest();
return signatures.split(" ").some((part) => {
const [version, value] = part.split(",", 2);
if (version !== "v1" || !value) return false;
const candidate = Buffer.from(value, "base64");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}import base64
import hashlib
import hmac
import time
def verify(payload: bytes, headers: dict[str, str], secret: str, tolerance: int = 300) -> bool:
msg_id = headers.get("svix-id")
timestamp = headers.get("svix-timestamp")
signatures = headers.get("svix-signature")
if not (msg_id and timestamp and signatures):
return False
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + payload
expected = hmac.new(key, signed, hashlib.sha256).digest()
for part in signatures.split(" "):
version, _, value = part.partition(",")
if version == "v1" and value and hmac.compare_digest(base64.b64decode(value), expected):
return True
return FalseRetries and auto-disable
A delivery succeeds on any 2xx within 15 seconds. Anything else, including a timeout, a redirect or a refused address, is retried on a fixed schedule: 5 seconds after the first failure, then 5 minutes, 30 minutes, 2 hours, 5 hours, and every 10 hours after that, as long as the attempt falls within 3 days of the delivery's creation (12 attempts in all).
GET /v1/webhooks/{id}/deliveries?status=failed&event_type=email.bounced lists deliveries, newest first, with their status, attempts, next_attempt_at, last_status_code and last_error; GET /v1/webhooks/{id}/deliveries/{deliveryId} returns one. Each carries the attempt_log: one { "attempt", "at", "status_code", "error", "response", "duration_ms" } per attempt, response being the endpoint's body cut at 64 KB.
status |
Meaning |
|---|---|
pending |
Not attempted yet. |
succeeded |
The endpoint answered 2xx. |
failed |
The last attempt failed. next_attempt_at says when the next one runs, or is null when none will (a test event, or the endpoint was disabled). |
exhausted |
Every attempt within the 3 days failed. |
While an endpoint's last 5 attempts failed, one delivery a minute is sent to it and the others are recorded as failed attempts without a request ("Not sent: the endpoint's last 5 attempts failed…") and retried on their schedule, so a receiver that hangs does not hold up other endpoints' deliveries. The first one that succeeds puts the endpoint back to normal. Test events are always sent.
When every attempt to an endpoint has failed for 3 days, it is set to auto_disabled: it receives no new events, its pending retries stop, an alert opens in the console and the change is in the audit log. A success at any point resets the clock. Fix the receiver, set status back to enabled with PATCH /v1/webhooks/{id} (which clears the failure history), then replay what it missed.
Rotate the secret
POST /v1/webhooks/{id}/rotate returns a new secret and previous_secret_expires_at. For the next 24 hours every delivery is signed with both secrets, two space-separated signatures in svix-signature, so you can switch the receiver at any point in the window without dropping a delivery. Rotating again within the window drops the oldest secret.
Replay and test events
POST /v1/webhooks/{id}/replaywith{ "delivery_ids": ["…"] }sends those deliveries again, or with{ "since": "2026-10-20T00:00:00.000Z" }everyfailedorexhausteddelivery created since then, up to 100 per call. It answers202 { "object": "replay", "queued": 12 }. A replay is a new delivery with the original's body andsvix-id, so a receiver that deduplicates treats it as the same message; a delivery can be replayed any number of times. The endpoint must be enabled.POST /v1/webhooks/{id}/testwith{ "type": "email.delivered" }sends one example event of that type,202 { "object": "delivery", "id": "…" }. It is signed like any other, attempted once, has noevent_id, never appears in the events feed and does not count towards the endpoint's health.