Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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; for reliability and retry internals see 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
1
2
3
4
5
6
7
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "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
1
{"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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 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
1
2
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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
1
2
3
# 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
1
2
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
1
2
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
1
2
3
4
5
6
7
8
9
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#

Updated 2026-05-17 01:33:26 View source (.md) rev 1