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:
- Event Webhook settings (
/v3/user/webhooks/event/settings) — a single URL with boolean flags per event type. Matches SendGrid's "Event Webhook" feature.
- 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
| 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"]
}'
|
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!
|
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:
| {
"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:
| {"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:
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}
|
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
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
| 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:
- Call the rotate endpoint.
- Store the new secret alongside the old one in your secrets manager.
- Update your verifier to accept either secret briefly (10–30 minutes) while in-flight events drain.
- Remove the old secret after the grace period.
Event Webhook settings
The single-URL model:
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
}'
|
| # 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
| 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
| 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
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:
| 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