---
title: Events and Webhooks
path: concepts/events-and-webhooks
status: published
---

# 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
{
  "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
curl https://scaisend.scailabs.ai/v3/messages/msg_01ABC \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

```json
{
  "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
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:

```
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
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
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
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
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
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](../tutorials/webhooks-deep-dive) for the full delivery-flow details.

## What's next

- [Tracking](tracking) — what triggers `open`, `click`, and `unsubscribe`.
- [Webhooks API Guide](../tutorials/webhooks) — endpoint management with code examples.
- [Webhooks Deep Dive](../tutorials/webhooks-deep-dive) — retry internals, dead-letter handling, scaling.
