Skip to content
On this site

Webhooks

We POST a signed JSON payload to your URL when something happens to an email. 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.

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

Event-specific objects
TypeExtra keyFields
email.bouncedbouncetype, subType, message, diagnosticCode[]
email.openedopenipAddress, timestamp, userAgent
email.clickedclickipAddress, link, timestamp, userAgent
email.failedfailedreason
email.suppressedsuppressedreason, type, message, diagnosticCode[]
email.delivery_delayeddelaytype, expirationTime, delayedRecipients[]
email.sent, email.scheduled, email.delivered, email.complainedThe envelope, with no extra object.

Event types

An endpoint subscribes to one or more of these. Subscribing to a type whose producer arrives in a later release is allowed and simply never fires, so a subscription list does not need revisiting when it does.

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

Headers we send
HeaderMeaning
svix-idThe event's ID. Stable across replays — dedupe on this.
svix-timestampUnix seconds. Reject anything more than five minutes old.
svix-signatureOne or more `v1,<base64>` signatures, space separated. Accept the payload if any of them verifies.
user-agentRasket-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:

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

Node, Next.js and Express use @rasket/webhook-verify — one npm install, and it has no dependencies of its own. The other four use the official Svix libraries, since the scheme is Svix-compatible and there is no reason to ship a second one. Every recipe reads the raw body first; that is the line that matters.

Node

// npm install @rasket/webhook-verify
import { createServer } from "node:http";
import { verify } from "@rasket/webhook-verify";

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 = verify(rawBody, req.headers, process.env.RASKET_WEBHOOK_SECRET);
      handle(event);
      res.writeHead(200).end();
    } catch (error) {
      // error.reason is one of the eight refusals: timestamp_too_old,
      // no_matching_signature, missing_signature, ...
      res.writeHead(400).end();
    }
  });
}).listen(3000);

Next.js

// app/api/hooks/rasket/route.ts
// npm install @rasket/webhook-verify
import { verify } from "@rasket/webhook-verify";

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; a plain object works too.
    const event = verify(rawBody, request.headers, process.env.RASKET_WEBHOOK_SECRET);
    await handle(event);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

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

Express

// npm install @rasket/webhook-verify
import express from "express";
import { verify } from "@rasket/webhook-verify";

const app = express();

// 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 = verify(req.body, req.headers, process.env.RASKET_WEBHOOK_SECRET);
    handle(event);
    res.sendStatus(200);
  } catch {
    res.status(400).send("invalid signature");
  }
});

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

Python

# 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

// 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

# 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

<?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 then retry up to ten times, with ±10% jitter on every delay so a recovering outage does not get a thundering herd.

The retry schedule
AttemptDelay before it
1immediately
25 s
330 s
42 m
510 m
630 m
71 h
82 h
94 h
108 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

These routes are specified and are what the dashboard will call. Until the webhooks release lands they do not answer — the payload, the signature and the retry behaviour above are already what the pipeline produces.

POST /webhooks

Subscribe a URL to the events you care about.

Body

Body
FieldTypeDescription
endpoint*stringAn absolute https URL. Redirects are never followed, so give the final one.
events*string[]At least one event type. Subscribing to a type whose producer lands later is allowed and simply never fires.
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"]
}'

Response 201

{
  "object": "webhook",
  "id": "wh_3b7f21c0d9",
  "endpoint": "https://acme.example.com/hooks/rasket",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "enabled",
  "signing_secret": "whsec_rq0Yc2m9Xn4bZ1sK7wLp3vTf",
  "created_at": "2026-09-09T09:20:31.004Z"
}
  • signing_secret is returned by this response and never again in full. Every later read shows it masked.
  • The URL is checked for reachability and against blocked address ranges when you create it, and again on every delivery.

GET /webhooks

Every endpoint on the team.

Query parameters

Query parameters
FieldTypeDescription
limitintegerHow many items to return, 1–100. Defaults to 20.
afterstringReturn the page that follows this item ID. Mutually exclusive with before.
beforestringReturn the page that precedes this item ID. Mutually exclusive with after.
curl -X GET "https://api.rasket.com/webhooks" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "webhook",
      "id": "wh_3b7f21c0d9",
      "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"
    }
  ]
}

GET /webhooks/{webhook_id}

One endpoint and its subscriptions.

Path parameters

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
curl -X GET "https://api.rasket.com/webhooks/wh_3b7f21c0d9" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "webhook",
  "id": "wh_3b7f21c0d9",
  "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

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.

Body

Body
FieldTypeDescription
endpointstringA new absolute https URL.
eventsstring[]Replaces the subscription list.
statusstringenabled or disabled.
curl -X PATCH "https://api.rasket.com/webhooks/wh_3b7f21c0d9" \
  -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"]
}'

Response 200

{
  "object": "webhook",
  "id": "wh_3b7f21c0d9"
}
  • Re-enabling an endpoint resumes new events only. Older ones are replayed explicitly.

DELETE /webhooks/{webhook_id}

Stop delivering to this URL.

Path parameters

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
curl -X DELETE "https://api.rasket.com/webhooks/wh_3b7f21c0d9" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "webhook",
  "id": "wh_3b7f21c0d9",
  "deleted": true
}

GET /webhooks/{webhook_id}/events

What we tried to deliver to this endpoint.

Path parameters

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.

Query parameters

Query parameters
FieldTypeDescription
limitintegerHow many items to return, 1–100. Defaults to 20.
afterstringReturn the page that follows this item ID. Mutually exclusive with before.
beforestringReturn the page that precedes this item ID. Mutually exclusive with after.
4 more fields (status, type, start_date, end_date)
Query parameters, less common
FieldTypeDescription
statusstringOnly events in this state: pending, attempting, success or failed.
typestringOnly events of this type, such as email.bounced.
start_datestringISO 8601 instant, inclusive. A calendar date alone is 422 invalid_parameter — send 2026-09-09T00:00:00.000Z.
end_datestringISO 8601 instant, inclusive. Must be at or after start_date, or the request is 422 invalid_parameter.
curl -X GET "https://api.rasket.com/webhooks/wh_3b7f21c0d9/events" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "object": "webhook_event",
      "id": "msg_2Yk1QpZ8s3XvL0nR",
      "type": "email.delivered",
      "status": "success",
      "attempt_count": 1,
      "last_http_status": 200,
      "created_at": "2026-09-09T10:14:09.331Z"
    }
  ]
}
  • 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

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
event_id*stringThe event's msg_ ID.
curl -X GET "https://api.rasket.com/webhooks/wh_3b7f21c0d9/events/msg_2Yk1QpZ8s3XvL0nR" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "webhook_event",
  "id": "msg_2Yk1QpZ8s3XvL0nR",
  "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

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
event_id*stringThe event's msg_ ID.

Query parameters

Query parameters
FieldTypeDescription
limitintegerHow many items to return, 1–100. Defaults to 20.
afterstringReturn the page that follows this item ID. Mutually exclusive with before.
beforestringReturn the page that precedes this item ID. Mutually exclusive with after.
curl -X GET "https://api.rasket.com/webhooks/wh_3b7f21c0d9/events/msg_2Yk1QpZ8s3XvL0nR/attempts" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "webhook_attempt",
      "id": "atmpt_6d0f9c2b71",
      "attempt_number": 1,
      "http_status_code": 200,
      "response": "ok",
      "duration_ms": 142,
      "sent_at": "2026-09-09T10:14:09.480Z"
    }
  ]
}
  • Your response body is stored, truncated to 8 KB. Do not answer with anything secret.

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

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

Path parameters

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
event_id*stringThe event's msg_ ID.
curl -X POST "https://api.rasket.com/webhooks/wh_3b7f21c0d9/events/msg_2Yk1QpZ8s3XvL0nR/replay" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "webhook_event",
  "id": "msg_2Yk1QpZ8s3XvL0nR"
}
  • 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

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
curl -X POST "https://api.rasket.com/webhooks/wh_3b7f21c0d9/rotate-secret" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "webhook",
  "id": "wh_3b7f21c0d9",
  "signing_secret": "whsec_8pQ2vLmT0yZ4cRn7bKw1sXf6"
}
  • 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

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.

Query parameters

Query parameters
FieldTypeDescription
limitintegerHow many to return, 1–100. has_more says whether more are parked.
curl -X GET "https://api.rasket.com/webhooks/wh_3b7f21c0d9/parked?limit=20" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "msg_2Yk1QpZ8s3XvL0nR",
      "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

Path parameters
FieldTypeDescription
webhook_id*stringThe endpoint's ID.
curl -X POST "https://api.rasket.com/webhooks/wh_3b7f21c0d9/parked/deliver" \
  -H "Authorization: Bearer $RASKET_API_KEY" \
  -H "User-Agent: acme-billing/1.0"

Response 200

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