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
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"]
  }'
JSON
{
  "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"
}
  • url must 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.
  • events filters by type; omit it (or PATCH it to null) to receive every type, including types added later (outside production, email.received must still be listed: Routing rules). secret is returned on creation and on rotation only.
  • Up to 20 endpoints per environment. PATCH /v1/webhooks/{id} changes url, description, events and status (enabled or disabled); 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.received follows 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:

HTTP
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

EventEmail status it sets
email.sentsent
email.scheduledscheduled
email.delivereddelivered
email.delivery_delayeddelivery_delayed
email.complainedcomplained
email.bouncedbounced
email.openedopened
email.clickedclicked
email.received
email.failedfailed
email.suppressedsuppressed
email.canceledcanceled
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:

JSON
{
  "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, subType, message }, 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, timestamp, userAgent }
email.clicked click: { ipAddress, link, timestamp, userAgent }
email.failed failed: { reason }
email.suppressed suppressed: { type, message }
email.sent, email.delivered, email.canceled nothing (email.delivered may add smtp_response)

A bounce, for example:

JSON
{
  "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:

JSON
{
  "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.

TypeScript
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 });
}

Retries 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}/replay with { "delivery_ids": ["…"] } sends those deliveries again, or with { "since": "2026-10-20T00:00:00.000Z" } every failed or exhausted delivery created since then, up to 100 per call. It answers 202 { "object": "replay", "queued": 12 }. A replay is a new delivery with the original's body and svix-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}/test with { "type": "email.delivered" } sends one example event of that type, 202 { "object": "delivery", "id": "…" }. It is signed like any other, attempted once, has no event_id, never appears in the events feed and does not count towards the endpoint's health.

Updated