# Webhooks

We POST a signed JSON payload to your URL when something happens on your team: an email is sent, delivered, bounces or arrives, a contact, domain or suppression changes, or an automation runs. This page is the payload, the signature, and what we do when your endpoint is down.

## The payload

Every event has the same envelope: a `type`, the `created_at` of the event itself, and a `data` object describing the email. Types that carry more detail add one extra key inside `data`.

A bounce event:

```text
{
  "type": "email.bounced",
  "created_at": "2026-09-09T10:16:44.902Z",
  "data": {
    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    "from": "Acme <orders@send.acme.example>",
    "to": ["ronald.williams@example.com"],
    "subject": "Your order has shipped",
    "message_id": "<01000199a3c4d5e6-7f8a9b0c@send.acme.example>",
    "created_at": "2026-09-09T10:14:02.118Z",
    "tags": { "order": "1042" },
    "bounce": {
      "type": "Permanent",
      "subType": "General",
      "message": "smtp; 550 5.1.1 The email account that you tried to reach does not exist.",
      "diagnosticCode": ["smtp; 550 5.1.1 user unknown"]
    }
  }
}
```

`data` always carries `email_id`, `from`, `to`, `subject`, `message_id` and the email's own `created_at`. `tags` and `headers` appear when the email had them. For per-recipient events — bounce, complaint, delivery — `to` lists the recipients that event is about, not everyone the message went to.

| Type | Extra key | Fields |
| --- | --- | --- |
| `email.bounced` | `bounce` | type, subType, message, diagnosticCode[] |
| `email.opened` | `open` | ipAddress, timestamp, userAgent |
| `email.clicked` | `click` | ipAddress, link, timestamp, userAgent |
| `email.failed` | `failed` | reason |
| `email.suppressed` | `suppressed` | reason, type, message, diagnosticCode[] |
| `email.delivery_delayed` | `delay` | type, expirationTime, delayedRecipients[] |
| `email.sent`, `email.scheduled`, `email.delivered`, `email.complained` | — | The envelope, with no extra object. |

## Event types

An endpoint subscribes to one or more of these. Any other type is refused when you create or update the endpoint.

- email.sent
- email.scheduled
- email.delivered
- email.delivery_delayed
- email.bounced
- email.complained
- email.opened
- email.clicked
- email.failed
- email.suppressed
- email.canceled
- domain.created
- domain.updated
- domain.deleted
- suppression.added
- suppression.removed
- contact.created
- contact.updated
- contact.deleted
- email.received
- automation.run.started
- automation.run.completed
- automation.run.failed

## Verifying a signature

Every delivery carries three headers. Reject anything that does not verify — the URL is public, and the signature is the only thing that says the request came from us.

| Header | Meaning |
| --- | --- |
| `svix-id` | The event's ID. Stable across replays — dedupe on this. |
| `svix-timestamp` | Unix seconds. Reject anything more than five minutes old. |
| `svix-signature` | One or more `v1,&lt;base64>` signatures, space separated. Accept the payload if any of them verifies. |
| `user-agent` | Rasket-Webhooks/1.0 |

> The signature covers the **raw request body**. Any framework that parses JSON before your handler runs has already destroyed the bytes we signed — key order and whitespace both change when a parsed object is serialized again, and the signature will never match. Read the body as text or bytes first, verify, then parse.

### The contract

Five steps, identical in every language:

The verification contract:

```text
verify(rawBody, headers, secret) -> payload

  1. require svix-id, svix-timestamp, svix-signature
  2. reject if |now - timestamp| > 300 s
  3. expected = base64(hmac_sha256(
       base64decode(secret without the whsec_ prefix),
       `${id}.${timestamp}.${rawBody}`
     ))
  4. for each "v1,<sig>" in svix-signature (space separated):
       constant-time compare against expected
  5. any match -> JSON.parse(rawBody); none -> throw
```

All seven recipes use the official Svix libraries today: the scheme is Svix-compatible, so they verify our signatures as they are. `@rasket/webhook-verify`, our own zero-dependency verifier, follows when it is published. Every recipe reads the raw body first; that is the line that matters.

### Node

Verifying a signature in Node:

```text
// @rasket/webhook-verify, our own zero-dependency verifier, is not on npm yet. The
// scheme is Svix-compatible, so the official svix package verifies it today.
// npm install svix
import { createServer } from "node:http";
import { Webhook } from "svix";

const wh = new Webhook(process.env.RASKET_WEBHOOK_SECRET);

createServer((req, res) => {
  // Collect the raw bytes. Nothing parses them: req.body does not exist here, which is
  // the one thing a bare Node server has going for it.
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks);

    try {
      const event = wh.verify(rawBody, req.headers);
      handle(event);
      res.writeHead(200).end();
    } catch {
      // A missing header, a timestamp more than five minutes off, or no matching signature.
      res.writeHead(400).end();
    }
  });
}).listen(3000);
```

### Next.js

Verifying a signature in Next.js:

```text
// app/api/hooks/rasket/route.ts
// @rasket/webhook-verify, our own zero-dependency verifier, is not on npm yet. The
// scheme is Svix-compatible, so the official svix package verifies it today.
// npm install svix
import { Webhook } from "svix";

const wh = new Webhook(process.env.RASKET_WEBHOOK_SECRET);

export async function POST(request: Request) {
  // await request.text(), NOT request.json(). Parsing to an object and serializing it
  // again is not the identity function — key order and whitespace both change, and the
  // signature is over the bytes we sent.
  const rawBody = await request.text();

  try {
    // request.headers is a Headers; the verifier wants a plain object.
    const event = wh.verify(rawBody, Object.fromEntries(request.headers));
    await handle(event);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

  return new Response("ok", { status: 200 });
}
```

### Express

Verifying a signature in Express:

```text
// @rasket/webhook-verify, our own zero-dependency verifier, is not on npm yet. The
// scheme is Svix-compatible, so the official svix package verifies it today.
// npm install svix
import express from "express";
import { Webhook } from "svix";

const app = express();
const wh = new Webhook(process.env.RASKET_WEBHOOK_SECRET);

// express.raw(), NOT express.json(). Mount it on this route only and BEFORE any global
// body parser, so the rest of your application still gets parsed bodies.
app.post("/hooks/rasket", express.raw({ type: "application/json" }), (req, res) => {
  // req.body is a Buffer here — the exact bytes we signed.
  try {
    const event = wh.verify(req.body, req.headers);
    handle(event);
    res.sendStatus(200);
  } catch {
    res.status(400).send("invalid signature");
  }
});

app.use(express.json()); // everything else, after the webhook route
```

### Python

Verifying a signature in Python:

```text
# pip install svix
from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)


@app.post("/hooks/rasket")
def rasket_webhook():
    # request.get_data() is the raw body. Never request.get_json() first:
    # it parses, and the signature is over what arrived.
    raw_body = request.get_data()

    try:
        event = Webhook(RASKET_WEBHOOK_SECRET).verify(raw_body, dict(request.headers))
    except WebhookVerificationError:
        return "invalid signature", 400

    handle(event)
    return "", 200


# FastAPI is the same shape: raw_body = await request.body()
```

### Go

Verifying a signature in Go:

```text
// go get github.com/svix/svix-webhooks/go
package main

import (
	"io"
	"net/http"
	"os"

	svix "github.com/svix/svix-webhooks/go"
)

func rasketWebhook(w http.ResponseWriter, r *http.Request) {
	// io.ReadAll on r.Body, before anything decodes it. json.NewDecoder(r.Body)
	// consumes the reader, and the bytes are gone.
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	wh, err := svix.NewWebhook(os.Getenv("RASKET_WEBHOOK_SECRET"))
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	if err := wh.Verify(rawBody, r.Header); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	handle(rawBody) // decode it now, after the signature checked out
	w.WriteHeader(http.StatusOK)
}
```

### Ruby

Verifying a signature in Ruby:

```text
# gem install svix
require "sinatra"
require "svix"

post "/hooks/rasket" do
  # request.body.read is the raw body. Do not use params or a JSON middleware:
  # both parse, and the signature is over what arrived.
  raw_body = request.body.read
  request.body.rewind

  begin
    wh = Svix::Webhook.new(ENV["RASKET_WEBHOOK_SECRET"])
    event = wh.verify(raw_body, request.env)
  rescue Svix::WebhookVerificationError
    halt 400, "invalid signature"
  end

  handle(event)
  status 200
end
```

### PHP

Verifying a signature in PHP:

```text
<?php
// composer require svix/svix
use Svix\Webhook;
use Svix\Exception\WebhookVerificationException;

// file_get_contents("php://input") is the raw body. $_POST is a parsed form and
// json_decode of it is a different string from the one we signed.
$rawBody = file_get_contents("php://input");

$headers = [
    "svix-id" => $_SERVER["HTTP_SVIX_ID"] ?? "",
    "svix-timestamp" => $_SERVER["HTTP_SVIX_TIMESTAMP"] ?? "",
    "svix-signature" => $_SERVER["HTTP_SVIX_SIGNATURE"] ?? "",
];

try {
    $wh = new Webhook(getenv("RASKET_WEBHOOK_SECRET"));
    $event = $wh->verify($rawBody, $headers);
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit("invalid signature");
}

handle($event);
http_response_code(200);
```

### Rotation

`POST /webhooks/{id}/rotate-secret` issues a new secret and keeps the previous one signing for 24 hours. During the overlap `svix-signature` carries both signatures, space separated. A verifier that accepts any matching signature — as the code above does — needs no downtime and no coordinated deploy. Both secrets start with `whsec_` and are shown once.

## Ordering and duplicates

- Events are **not ordered**. A delivery can arrive before the sent event that logically precedes it. Key your handler on `email_id` plus `type` plus `created_at` rather than on arrival order.
- Events can arrive more than once. Dedupe on `svix-id`, which is stable for the life of an event and does not change when it is replayed.
- Answer with any `2xx` as soon as you have stored the event. Do your work afterwards; a handler that finishes its work before replying will eventually time out and be retried.

## Retries

Anything that is not a `2xx` within ten seconds is a failure: a timeout, a DNS or TLS error, a refused connection, or a `3xx` — we never follow redirects, so a redirect is a failed delivery, not a hop. We try up to ten times in all — the first delivery and nine retries — with ±10% jitter on every delay so a recovering outage does not get a thundering herd.

| Attempt | Delay before it |
| --- | --- |
| 1 | immediately |
| 2 | 5 s |
| 3 | 30 s |
| 4 | 2 m |
| 5 | 10 m |
| 6 | 30 m |
| 7 | 1 h |
| 8 | 2 h |
| 9 | 4 h |
| 10 | 8 h |

After the tenth attempt the event is marked failed and we stop. Nothing is lost — it stays readable, and you can replay it. An endpoint with no successful delivery for five consecutive days is disabled automatically and the team's admins are emailed; events keep being recorded for it while it is off.

## Endpoints

### `POST /webhooks`

Subscribe a URL to the events you care about.

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `endpoint` (required) | string | An absolute `https` URL. Redirects are never followed, so give the final one. |
| `events` (required) | string[] | At least one event type from the Events list. An unknown type is refused. |

Create an endpoint:

```sh
curl -X POST "https://api.rasket.com/webhooks" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "endpoint": "https://acme.example.com/hooks/rasket",
  "events": ["email.delivered", "email.bounced", "email.complained"]
}'
```

```ts
const response = await fetch("https://api.rasket.com/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    endpoint: "https://acme.example.com/hooks/rasket",
    events: ["email.delivered", "email.bounced", "email.complained"]
  }),
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/webhooks",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "endpoint": "https://acme.example.com/hooks/rasket",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  },
)

id = response.json()["id"]
```

#### Response `201`

```json
{
  "object": "webhook",
  "id": "3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
  "signing_secret": "whsec_5+7nYV7zXzDkm0guFcrnUAcgHhJhew/t"
}
```

- `signing_secret` is returned by this response and never again in full. Every later read shows it masked.
- The URL is checked against blocked (private and internal) address ranges when you create it, and again on every delivery.
- How many endpoints a team may have depends on its plan.

### `GET /webhooks`

Every endpoint on the team.

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `limit` | integer | How many items to return, 1–100. Defaults to 20. |
| `after` | string | Return the page that follows this item ID. Mutually exclusive with `before`. |
| `before` | string | Return the page that precedes this item ID. Mutually exclusive with `after`. |

List endpoints:

```sh
curl -X GET "https://api.rasket.com/webhooks" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const data = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/webhooks",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
      "endpoint": "https://acme.example.com/hooks/rasket",
      "events": ["email.delivered", "email.bounced", "email.complained"],
      "status": "enabled",
      "created_at": "2026-09-09T09:20:31.004Z"
    }
  ]
}
```

### `GET /webhooks/{webhook_id}`

One endpoint and its subscriptions.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

Retrieve an endpoint:

```sh
curl -X GET "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "webhook",
  "id": "3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
  "endpoint": "https://acme.example.com/hooks/rasket",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "enabled",
  "signing_secret": "whsec_••••••••Ab3d",
  "created_at": "2026-09-09T09:20:31.004Z"
}
```

- `signing_secret` comes back masked. Revealing it in full is a dashboard action and is audited.

### `PATCH /webhooks/{webhook_id}`

Change the URL, the subscriptions, or turn it off.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `endpoint` | string | A new absolute `https` URL. |
| `events` | string[] | Replaces the subscription list. |
| `status` | string | `enabled` or `disabled`. |

Update an endpoint:

```sh
curl -X PATCH "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0" \
  -H "Content-Type: application/json" \
  -d '{
  "events": ["email.delivered", "email.bounced", "email.complained", "email.failed"]
}'
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    events: ["email.delivered", "email.bounced", "email.complained", "email.failed"]
  }),
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.patch(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
    json={
    "events": ["email.delivered", "email.bounced", "email.complained", "email.failed"]
  },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "webhook",
  "id": "3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38"
}
```

- Re-enabling an endpoint resumes new events only. Older ones are replayed explicitly.

### `DELETE /webhooks/{webhook_id}`

Stop delivering to this URL.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

Delete an endpoint:

```sh
curl -X DELETE "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.delete(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "webhook",
  "id": "3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
  "deleted": true
}
```

### `GET /webhooks/{webhook_id}/events`

What we tried to deliver to this endpoint.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `limit` | integer | How many items to return, 1–100. Defaults to 20. |
| `after` | string | Return the page that follows this item ID. Mutually exclusive with `before`. |
| `before` | string | Return the page that precedes this item ID. Mutually exclusive with `after`. |

#### Query parameters, less common

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | Only events in this state: `pending`, `attempting`, `success` or `failed`. |
| `type` | string | Only events of this type, such as `email.bounced`. |
| `start_date` | string | ISO 8601 instant, inclusive. A calendar date alone is `422 invalid_parameter` — send `2026-09-09T00:00:00.000Z`. |
| `end_date` | string | ISO 8601 instant, inclusive. Must be at or after `start_date`, or the request is `422 invalid_parameter`. |

List events:

```sh
curl -X GET "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const data = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "id": "msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f",
      "type": "email.delivered",
      "created_at": "2026-09-09T10:14:09.331Z",
      "status": "success"
    }
  ]
}
```

- Event IDs start with `msg_` and are the value of the `svix-id` header we sent.

### `GET /webhooks/{webhook_id}/events/{event_id}`

One event, with the exact payload we signed.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |
| `event_id` (required) | string | The event's `msg_` ID. |

Retrieve an event:

```sh
curl -X GET "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "webhook_event",
  "id": "msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f",
  "type": "email.delivered",
  "status": "success",
  "attempt_count": 1,
  "last_http_status": 200,
  "next_attempt_at": null,
  "created_at": "2026-09-09T10:14:09.331Z",
  "payload": {
    "type": "email.delivered",
    "created_at": "2026-09-09T10:14:09.331Z",
    "data": {
      "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"
    }
  }
}
```

### `GET /webhooks/{webhook_id}/events/{event_id}/attempts`

Every delivery we made for one event, and what came back.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |
| `event_id` (required) | string | The event's `msg_` ID. |

List attempts:

```sh
curl -X GET "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f/attempts" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f/attempts", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const data = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f/attempts",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "atmpt_0198f4c2a1c07d4e8f9a0b1c2d3e4f50",
      "attempt_number": 1,
      "http_status_code": 200,
      "response": "ok",
      "duration_ms": 142,
      "error": null,
      "sent_at": "2026-09-09T10:14:09.480Z"
    }
  ]
}
```

- Every attempt, oldest first, in one response: the list is not paginated.
- Your response body is stored, truncated to 8 KB. Do not answer with anything secret.
- `error` names a failure other than your status code — `timeout` (we wait 10 seconds), `redirect_not_followed`, a blocked address — and is `null` when your endpoint simply answered.

### `POST /webhooks/{webhook_id}/events/{event_id}/replay`

Send the same bytes again, after you have fixed your handler.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |
| `event_id` (required) | string | The event's `msg_` ID. |

Replay an event:

```sh
curl -X POST "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f/replay" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f/replay", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/events/msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f/replay",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "webhook_event",
  "id": "msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f"
}
```

- A replay re-sends the identical payload with the same `svix-id` and a fresh timestamp and signature — so a handler that dedupes on `svix-id` will correctly ignore it.
- Ten replays per minute per team. Replaying to a disabled endpoint is refused.

### `POST /webhooks/{webhook_id}/rotate-secret`

Issue a new secret with a 24-hour overlap.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

Rotate the signing secret:

```sh
curl -X POST "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/rotate-secret" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/rotate-secret", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const { id } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/rotate-secret",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

id = response.json()["id"]
```

#### Response `200`

```json
{
  "object": "webhook",
  "id": "3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38",
  "signing_secret": "whsec_p+Fkd5b/AivqjtAqgqF1kw8jN803lMUi"
}
```

- For 24 hours both secrets sign, and `svix-signature` carries both signatures space-separated. Accept any one that verifies and the rotation needs no downtime.
- The new secret is shown once, exactly like the first one.

### `GET /webhooks/{webhook_id}/parked`

The events created while the endpoint was disabled, oldest first.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

#### Query parameters

| Field | Type | Description |
| --- | --- | --- |
| `limit` | integer | How many to return, 1–100. `has_more` says whether more are parked. |

List parked events:

```sh
curl -X GET "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/parked?limit=20" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/parked?limit=20", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const data = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/parked?limit=20",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "msg_0198f4c2a1b27c3e9d4f5a6b7c8d9e0f",
      "type": "email.delivered",
      "created_at": "2026-09-09T09:20:33.412Z",
      "status": "failed",
      "attempt_count": 6,
      "last_http_status": 503
    }
  ]
}
```

- A disabled endpoint keeps receiving events without delivering them. They are listed oldest first, the order they are delivered in.

### `POST /webhooks/{webhook_id}/parked/deliver`

Replay the oldest parked events, in order, within the replay budget.

#### Path parameters

| Field | Type | Description |
| --- | --- | --- |
| `webhook_id` (required) | string | The endpoint's ID. |

Deliver parked events:

```sh
curl -X POST "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/parked/deliver" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"
```

```ts
const response = await fetch("https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/parked/deliver", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RASKET_API_KEY}`,
    "User-Agent": "acme-billing/1.0",
  },
});

const data = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.rasket.com/webhooks/3b7f21c0-d9a4-4e8b-b6c2-7a1f5d0e9c38/parked/deliver",
    headers={
        "Authorization": f"Bearer {os.environ['RASKET_API_KEY']}",
        "User-Agent": "acme-billing/1.0",
    },
)

print(response.json())
```

#### Response `200`

```json
{
  "object": "parked_delivery",
  "delivered": 10,
  "remaining": 32,
  "stopped_reason": "rate_limit_exceeded"
}
```

- At most 10 events per call, inside the same budget as a single replay: 10 a minute per team. When it runs out, delivery stops with `stopped_reason: "rate_limit_exceeded"`.
- Call again while `remaining` is above zero. A disabled endpoint is `422 validation_error`: enable it first.
