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

Events and Webhooks

ScaiSend emits events for everything that happens to a message after it's queued. Events land on the message timeline (pull), and they also fan out to any webhook endpoints you've configured (push). This page describes the event types, the webhook delivery model, and the signing scheme.

Event types#

The full vocabulary of events ScaiSend emits:

Event When it fires
processed Message accepted by the API and queued for delivery
deferred Recipient MX returned a temporary failure (4xx); ScaiSend will retry
delivered Recipient MX returned 250 OK
bounce Recipient MX returned a permanent failure (5xx), or an async DSN arrived
blocked Recipient's ISP blocked the address (typically 550 with blocking message)
dropped Message dropped before sending — invalid recipient, suppression hit, or bypass refused
open Recipient's email client loaded the tracking pixel
click Recipient clicked a tracked link
spam_report ISP feedback loop reported this message as spam
unsubscribe Recipient unsubscribed (via link, List-Unsubscribe header, or one-click)
group_unsubscribe Recipient unsubscribed from a specific suppression group
group_resubscribe Recipient re-subscribed to a suppression group

Branch on the event_type string. The event names are stable; we won't rename them.

Event shape#

Every event has the same envelope:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "event_id": "evt_01HXYZ...",
  "event_type": "delivered",
  "timestamp": 1713888000,
  "message_id": "msg_01ABC...",
  "recipient_email": "user@example.com",
  "tenant_id": "tnt_acme",
  "metadata": {
    "smtp_response": "250 2.0.0 OK"
  }
}

Common fields:

Field Type Description
event_id string Globally unique. Use for idempotency on your side.
event_type string One of the values in the table above.
timestamp integer Unix time in seconds when the event occurred.
message_id string The ScaiSend message this event belongs to.
recipient_email string The specific recipient this event relates to.
tenant_id string Tenant that owns the message.
metadata object Event-specific fields — vary by type.

Event-specific metadata#

Event Key metadata fields
processed queue_time_ms
deferred smtp_response, attempt, next_retry_at
delivered smtp_response, tls_version, mx_host
bounce bounce_type (hard/soft/block), reason, smtp_response, diagnostic_code
blocked smtp_response, reason
dropped reason (suppressed_bounce/suppressed_spam/suppressed_unsubscribe/invalid_recipient)
open user_agent, ip_address, is_first_open
click url, user_agent, ip_address
spam_report report_source, feedback_id
unsubscribe source (list_header/one_click/link)
group_unsubscribe group_id, source
group_resubscribe group_id

The metadata object is flat — it's not nested inside other keys. Read it as event.metadata.smtp_response.

Two ways to consume events#

1. Pull: message timeline#

Every event for a message is attached to it. Get the message and read events[]:

bash
1
2
curl https://scaisend.scailabs.ai/v3/messages/msg_01ABC \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
json
1
2
3
4
5
6
7
8
9
{
  "id": "msg_01ABC",
  "status": "delivered",
  "events": [
    {"event_type": "processed", "timestamp": "2026-04-23T10:00:00Z"},
    {"event_type": "delivered", "timestamp": "2026-04-23T10:00:02Z", "metadata": {"smtp_response": "250 OK"}},
    {"event_type": "open", "timestamp": "2026-04-23T10:15:30Z", "metadata": {"user_agent": "..."}}
  ]
}

Good for: debugging a specific message, support tickets, UI displays showing delivery state.

2. Push: webhooks#

Subscribe a URL to receive every event as it happens:

bash
1
2
3
4
5
6
7
curl -X POST https://scaisend.scailabs.ai/v3/user/webhooks \
  -H "Authorization: Bearer $SCAISEND_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/scaisend",
    "enabled_events": ["delivered", "bounce", "spam_report", "unsubscribe"]
  }'

Good for: triggering downstream automation, updating your system of record, alerting on anomalies.

Webhook delivery#

Request format#

ScaiSend POSTs to your endpoint:

http
1
2
3
4
5
6
7
8
9
POST /your/webhook/path HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ScaiSend-Signature: a3b2c1...
X-ScaiSend-Timestamp: 1713888000
X-ScaiSend-Event: delivered
User-Agent: ScaiSend-Webhook/1.0

{"event_id": "evt_01HXYZ", "event_type": "delivered", ...}

Signing#

Every request is signed with HMAC-SHA256. The input is {timestamp}.{body}, the key is the endpoint's signing_secret.

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import hmac, hashlib, time

def verify(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    # Reject old signatures to prevent replay
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import crypto from "node:crypto";

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

Always verify. Webhook URLs leak — in logs, in support tickets, in repo history. Signature verification is the difference between an attacker being able to forge events at you and not.

Always check the timestamp. A stale signature from a past legitimate request is still a valid HMAC. Reject anything older than ~5 minutes.

Retries#

Return 2xx within 30 seconds and the delivery is done. Any other response (4xx, 5xx, timeout, network error) triggers retries with exponential backoff:

Attempt Delay after previous failure
1 (original send)
2 1 minute
3 5 minutes
4 15 minutes
5 1 hour
6 2 hours

After 6 failed attempts, the delivery is marked FAILED and not retried. If an endpoint fails 10 times in a row overall (across different events), it's automatically disabled — you'll need to re-enable it.

Delivery guarantees#

  • At-least-once. A delivery may fire more than once if a response is lost mid-retry. Use event_id for idempotency.
  • Not ordered. Events for the same message can arrive out of order, especially after retries. Don't rely on arrival order; use the timestamp field on the payload.
  • No guaranteed latency. Typical delivery is sub-second, but under load or during retries it can be minutes.

Dead-letter visibility#

Every delivery attempt is logged. Query a specific endpoint's delivery history via the admin UI, or ask the API for failure counts:

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

Response includes last_success_at, last_failure_at, failure_count, disabled_at.

Event settings#

Rather than configuring events per-endpoint, you can set a tenant-wide event subscription with a single webhook URL. This matches SendGrid's "Event Webhook" concept.

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",
    "bounce": true,
    "click": true,
    "delivered": true,
    "deferred": false,
    "dropped": true,
    "group_resubscribe": true,
    "group_unsubscribe": true,
    "open": true,
    "processed": false,
    "spam_report": true,
    "unsubscribe": true
  }'

Every boolean flag controls whether that event type fans out to the configured URL.

This and /v3/user/webhooks coexist. Event Settings is a single URL with per-type opt-ins. /v3/user/webhooks lets you have multiple URLs each subscribed to different event sets. Use whichever model fits — they don't interfere.

Rotating a signing secret#

If a secret might be compromised:

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

Response returns the new secret. The old secret immediately stops validating — updates from that moment on use the new key. Update your verifier before rotating, or briefly trust both values during the rollout.

Scale considerations#

  • Events are emitted by the Worker service (for processed) and the SMTP service (for delivered, bounce, etc.). Under normal conditions, your webhook endpoint sees roughly 2–4 requests per sent message (processed + delivered + maybe open + maybe click).
  • Under high send volume, webhook delivery can batch into bursts. Design your endpoint to handle 10× peak sustained QPS without falling over.
  • If your endpoint is slow, retries pile up. A consistently 10-second webhook endpoint is much worse than a consistently 100ms endpoint — the retry tail is bigger.

See Webhooks Deep Dive for the full delivery-flow details.

What's next#

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