# Receive events by webhook

> Endpoints, event payloads, svix-compatible signatures with verification samples, retries, auto-disable, rotation, replay and test events.

Source: https://docs.minket.com/webhooks/
Updated: 2026-10-29

Webhooks push every event of a project to your endpoints as it happens. The same events can be pulled from [the feed](https://docs.minket.com/events/#the-feed), and [Track events on the timeline](https://docs.minket.com/events/) 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.

```sh tab=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"]
  }'
```

```ts tab
const endpoint = await minket.webhooks.create({
  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](https://docs.minket.com/inbound/#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](https://docs.minket.com/inbound/#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

| 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`:

```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](https://docs.minket.com/delivery/#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](https://docs.minket.com/inbound/#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.

```ts tab
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 });
}
```

```ts tab=Node
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);
  });
}
```

```python tab
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 False
```

## 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.
