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:
1 2 3 4 5 6 7 8 9 10 11 | |
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[]:
1 2 | |
1 2 3 4 5 6 7 8 9 | |
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:
1 2 3 4 5 6 7 | |
Good for: triggering downstream automation, updating your system of record, alerting on anomalies.
Webhook delivery#
Request format#
ScaiSend POSTs to your endpoint:
1 2 3 4 5 6 7 8 9 | |
Signing#
Every request is signed with HMAC-SHA256. The input is {timestamp}.{body}, the key is the endpoint's signing_secret.
1 2 3 4 5 6 7 8 9 10 11 12 | |
1 2 3 4 5 6 7 8 9 10 | |
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_idfor idempotency. - Not ordered. Events for the same message can arrive out of order, especially after retries. Don't rely on arrival order; use the
timestampfield 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:
1 2 | |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
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:
1 2 | |
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 (fordelivered,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#
- Tracking — what triggers
open,click, andunsubscribe. - Webhooks API Guide — endpoint management with code examples.
- Webhooks Deep Dive — retry internals, dead-letter handling, scaling.