---
title: Webhooks
path: tutorials/webhooks
status: published
---

# Webhooks

Webhooks push delivery events to your service in real time. Register a URL, subscribe to event types, handle signed requests. This guide covers endpoint management and the signature-verification recipe. For event semantics see [Events and Webhooks](../concepts/events-and-webhooks); for reliability and retry internals see [Webhooks Deep Dive](webhooks-deep-dive).

**Endpoints:** `/v3/user/webhooks*`, `/v3/user/webhooks/event/settings`
**Auth:** `webhooks.read` for reads, `webhooks.write` for writes.

## The two configuration models

ScaiSend offers two overlapping ways to configure webhooks:

1. **Event Webhook settings** (`/v3/user/webhooks/event/settings`) — a single URL with boolean flags per event type. Matches SendGrid's "Event Webhook" feature.
2. **Webhook endpoints** (`/v3/user/webhooks`) — multiple URLs, each subscribed to its own list of event types. More flexible; better when different services need different events.

Both models can coexist. Events fire to the Event Webhook URL (if enabled) AND to every matching webhook endpoint.

## Creating a webhook endpoint

```bash
curl -X POST https://scaisend.scailabs.ai/v3/user/webhooks \
  -H "Authorization: Bearer $SCAISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/scaisend",
    "enabled_events": ["processed", "delivered", "bounce", "spam_report", "unsubscribe"]
  }'
```

```python
import os
import httpx

resp = httpx.post(
    "https://scaisend.scailabs.ai/v3/user/webhooks",
    headers={"Authorization": f"Bearer {os.environ['SCAISEND_API_KEY']}"},
    json={
        "url": "https://api.example.com/webhooks/scaisend",
        "enabled_events": ["processed", "delivered", "bounce", "spam_report", "unsubscribe"],
    },
)
endpoint = resp.json()
print(endpoint["signing_secret"])  # save this!
```

```typescript
const resp = await fetch("https://scaisend.scailabs.ai/v3/user/webhooks", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SCAISEND_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://api.example.com/webhooks/scaisend",
    enabled_events: ["processed", "delivered", "bounce", "spam_report", "unsubscribe"],
  }),
});
const endpoint = await resp.json();
console.log(endpoint.signing_secret); // save this
```

Response:

```json
{
  "id": "wh_01HXYZ",
  "url": "https://api.example.com/webhooks/scaisend",
  "enabled_events": ["processed", "delivered", "bounce", "spam_report", "unsubscribe"],
  "signing_secret": "whsec_abc123...",
  "enabled": true,
  "created_at": "2026-04-23T10:00:00Z",
  "last_success_at": null,
  "last_failure_at": null,
  "failure_count": 0
}
```

**The `signing_secret` is returned once.** Save it to your secrets manager. You'll need it to verify every incoming request.

Use a wildcard to subscribe to every event type:

```json
{"enabled_events": ["*"]}
```

Wildcard subscriptions receive new event types added in future ScaiSend versions, so you should default-case them.

## Handling an incoming webhook

Every request from ScaiSend has three headers:

| Header | Value |
|--------|-------|
| `X-ScaiSend-Signature` | HMAC-SHA256 of `{timestamp}.{body}` using the signing secret |
| `X-ScaiSend-Timestamp` | Unix timestamp when the signature was computed |
| `X-ScaiSend-Event` | Event type (e.g., `delivered`) |
| `Content-Type` | `application/json` |
| `User-Agent` | `ScaiSend-Webhook/1.0` |

Verify the signature, then act on the event:

```python
import hmac
import hashlib
import os
import time

from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SIGNING_SECRET = os.environ["SCAISEND_WEBHOOK_SECRET"]


def verify(body: bytes, timestamp: str, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        f"{timestamp}.{body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/scaisend")
async def handle(
    request: Request,
    x_scaisend_signature: str = Header(...),
    x_scaisend_timestamp: str = Header(...),
    x_scaisend_event: str = Header(...),
):
    body = await request.body()
    if not verify(body, x_scaisend_timestamp, x_scaisend_signature):
        raise HTTPException(401, "Invalid signature")

    event = await request.json()
    match x_scaisend_event:
        case "delivered":
            # ...
            pass
        case "bounce":
            # event["metadata"]["bounce_type"]: "hard" | "soft" | "block"
            pass
        case "spam_report" | "unsubscribe":
            # ScaiSend has already added the address to the suppression list.
            # This is for your own bookkeeping.
            pass
        case _:
            # Default case — unknown event type
            pass
    return {"ok": True}
```

```typescript
import express from "express";
import crypto from "node:crypto";

const app = express();
const SIGNING_SECRET = process.env.SCAISEND_WEBHOOK_SECRET!;

// Use raw body parsing for signature verification
app.use("/webhooks/scaisend", express.raw({ type: "application/json" }));

function verify(body: Buffer, timestamp: string, signature: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.${body.toString()}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

app.post("/webhooks/scaisend", (req, res) => {
  const timestamp = req.header("X-ScaiSend-Timestamp")!;
  const signature = req.header("X-ScaiSend-Signature")!;
  const event = req.header("X-ScaiSend-Event")!;
  if (!verify(req.body as Buffer, timestamp, signature)) {
    return res.status(401).json({ error: "bad signature" });
  }

  const payload = JSON.parse(req.body.toString());
  switch (event) {
    case "delivered":
      break;
    case "bounce":
      // payload.metadata.bounce_type: "hard" | "soft" | "block"
      break;
    case "spam_report":
    case "unsubscribe":
      break;
  }
  res.json({ ok: true });
});
```

Return `2xx` within 30 seconds or ScaiSend retries.

## Updating an endpoint

```bash
# Enable more events, or disable the endpoint entirely
curl -X PATCH https://scaisend.scailabs.ai/v3/user/webhooks/wh_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled_events": ["delivered", "bounce", "spam_report", "unsubscribe", "open", "click"]
  }'

# Pause by disabling
curl -X PATCH https://scaisend.scailabs.ai/v3/user/webhooks/wh_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'
```

Disabled endpoints stop receiving events immediately. Any events enqueued before the disable are still attempted.

## Rotating a signing secret

```bash
curl -X POST https://scaisend.scailabs.ai/v3/user/webhooks/wh_01HXYZ/signing_secret \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Response: `{"webhook_id": "wh_01HXYZ", "signing_secret": "whsec_..."}`.

The old secret stops validating immediately. To avoid a gap:

1. Call the rotate endpoint.
2. Store the new secret alongside the old one in your secrets manager.
3. Update your verifier to accept either secret briefly (10–30 minutes) while in-flight events drain.
4. Remove the old secret after the grace period.

## Event Webhook settings

The single-URL model:

```bash
curl -X PATCH https://scaisend.scailabs.ai/v3/user/webhooks/event/settings \
  -H "Authorization: Bearer $SCAISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "url": "https://api.example.com/webhooks/scaisend-events",
    "processed": false,
    "deferred": false,
    "delivered": true,
    "bounce": true,
    "dropped": true,
    "open": true,
    "click": true,
    "spam_report": true,
    "unsubscribe": true,
    "group_unsubscribe": true,
    "group_resubscribe": true
  }'
```

```bash
# Get current settings
curl https://scaisend.scailabs.ai/v3/user/webhooks/event/settings \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Use this when you have a single webhook consumer that wants selective event filtering. The `enabled: false` flag pauses all event delivery while preserving configuration.

## Deleting an endpoint

```bash
curl -X DELETE https://scaisend.scailabs.ai/v3/user/webhooks/wh_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Deletes the endpoint and its delivery history. If you just want to pause delivery, set `enabled: false` instead.

## Inspecting delivery health

```bash
curl https://scaisend.scailabs.ai/v3/user/webhooks/wh_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Response includes:

| Field | Meaning |
|-------|---------|
| `last_success_at` | Last time this endpoint accepted a 2xx |
| `last_failure_at` | Last time a delivery attempt failed |
| `failure_count` | Consecutive failures since the last success |
| `disabled_at` | Set if ScaiSend auto-disabled after 10 consecutive failures |

If `disabled_at` is set, re-enable by PATCHing `{"enabled": true}` after fixing whatever broke.

## Recipes

### A minimal Express server for webhooks

```typescript
import express from "express";
import crypto from "node:crypto";

const SIGNING_SECRET = process.env.SCAISEND_WEBHOOK_SECRET!;
const app = express();
app.use(express.raw({ type: "application/json" }));

app.post("/webhooks/scaisend", async (req, res) => {
  const ts = req.header("X-ScaiSend-Timestamp") ?? "0";
  const sig = req.header("X-ScaiSend-Signature") ?? "";
  const evt = req.header("X-ScaiSend-Event") ?? "";
  const body = req.body as Buffer;

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${ts}.${body.toString()}`)
    .digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(401).end();
  }

  const payload = JSON.parse(body.toString());
  console.log(`[${evt}] message=${payload.message_id} to=${payload.recipient_email}`);
  res.status(200).end();
});

app.listen(3000);
```

### Idempotent consumption

Events can be delivered more than once (a response was lost; ScaiSend retries). Use `event_id` to dedupe:

```python
import redis

r = redis.Redis.from_url(os.environ["REDIS_URL"])

async def handle_event(event: dict):
    key = f"scaisend:seen:{event['event_id']}"
    if not r.set(key, "1", nx=True, ex=86400 * 7):
        return  # seen before, ignore
    # ... process event
```

Cache the event_id for at least a day (ScaiSend retries over ~4 hours max; a day provides headroom).

## What's next

- [Events and Webhooks](../concepts/events-and-webhooks) — the event types and their semantics.
- [Webhooks Deep Dive](webhooks-deep-dive) — retry policy, dead-letter, scaling.
- [Webhooks Reference](../reference/webhooks) — full endpoint reference.
