---
title: Messages and Events
path: tutorials/messages-and-events
status: published
---

# Messages and Events

Once a message is accepted by `/v3/mail/send`, every delivery action against it — rendering, queueing, sending, bouncing, being opened — is recorded as an event on the message timeline. This page covers querying messages, reading their event history, and the management endpoints (cancel, retry, fail).

**Base path:** `/v3/messages/`
**Auth:** API key or JWT; `mail.send` (for retry/cancel/fail) or `stats.read` for plain reads — check your deployment.

## The message lifecycle

```
QUEUED → PROCESSING → RENDERED → SENDING → SENT → DELIVERED
                                    │
                                    ├─► BOUNCED
                                    └─► FAILED
(alternate terminal states: CANCELLED, SANDBOX)
```

| Status | Meaning |
|--------|---------|
| `QUEUED` | Accepted, waiting for the worker to pick up |
| `PROCESSING` | Worker is rendering the template and building MIME |
| `RENDERED` | MIME is built, waiting for SMTP service |
| `SENDING` | SMTP service is actively connecting to recipient MX |
| `SENT` | Accepted by recipient MX (awaiting async bounce confirmation if any) |
| `DELIVERED` | Confirmed delivered (no bounce received, or `250 OK` captured) |
| `BOUNCED` | Permanent failure — either 5xx at send time or an async DSN |
| `FAILED` | Non-delivery error (template render failure, unverified domain, rate-limited at SMTP layer after retries) |
| `CANCELLED` | User cancelled before sending |
| `SANDBOX` | Test send; validated but not delivered |

## Listing messages

```bash
curl "https://scaisend.scailabs.ai/v3/messages?page=1&page_size=25" \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

```python
import os, httpx

resp = httpx.get(
    "https://scaisend.scailabs.ai/v3/messages",
    headers={"Authorization": f"Bearer {os.environ['SCAISEND_API_KEY']}"},
    params={"page": 1, "page_size": 25, "status": "delivered"},
)
data = resp.json()
for msg in data["messages"]:
    print(msg["id"], msg["status"], msg["subject"], msg["to_emails"])
```

```typescript
const params = new URLSearchParams({ page: "1", page_size: "25", status: "delivered" });
const resp = await fetch(`https://scaisend.scailabs.ai/v3/messages?${params}`, {
  headers: { "Authorization": `Bearer ${process.env.SCAISEND_API_KEY}` },
});
const data = await resp.json();
```

### Filters

| Parameter | Notes |
|-----------|-------|
| `status` | Filter by lifecycle status (`queued`, `delivered`, `bounced`, etc.) |
| `to_email` | Exact match on a recipient address |
| `from_email` | Exact match on the sender address |
| `subject` | Substring search on subject |
| `batch_id` | Messages belonging to a batch |
| `template_id` | Messages sent using a template |
| `category` | Messages tagged with this category |
| `start_date` / `end_date` | ISO-8601 or `YYYY-MM-DD` |
| `page` | 1-indexed page number (default 1) |
| `page_size` | 1–100 (default 25) |

### Response

```json
{
  "messages": [
    {
      "id": "msg_01HXYZ",
      "status": "delivered",
      "from_email": "hello@mail.example.com",
      "from_name": "Acme",
      "subject": "Welcome to Acme",
      "to_emails": ["ada@example.com"],
      "template_id": "d-welcome",
      "batch_id": null,
      "categories": ["onboarding"],
      "created_at": "2026-04-23T10:00:00Z",
      "delivered_at": "2026-04-23T10:00:02Z"
    }
  ],
  "total": 4872,
  "page": 1,
  "page_size": 25,
  "total_pages": 195
}
```

The list view is a summary. To see the body, headers, and event timeline, get the individual message.

## Getting a single message

```bash
curl https://scaisend.scailabs.ai/v3/messages/msg_01HXYZ \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

Response:

```json
{
  "id": "msg_01HXYZ",
  "status": "delivered",
  "from_email": "hello@mail.example.com",
  "from_name": "Acme",
  "subject": "Welcome to Acme",
  "to_emails": ["ada@example.com"],
  "cc_emails": [],
  "bcc_emails": [],
  "reply_to": "support@example.com",
  "categories": ["onboarding"],
  "template_id": "d-welcome",
  "batch_id": null,
  "custom_args": {"user_id": "u_12345"},
  "custom_headers": {},
  "tracking_settings": {
    "open_tracking": true,
    "click_tracking": true,
    "subscription_tracking": false
  },
  "retry_count": 0,
  "next_retry_at": null,
  "html_content": "<html>...</html>",
  "plain_content": "...",
  "created_at": "2026-04-23T10:00:00Z",
  "queued_at": "2026-04-23T10:00:00Z",
  "sent_at": "2026-04-23T10:00:01Z",
  "delivered_at": "2026-04-23T10:00:02Z",
  "error_message": null,
  "events": [
    {
      "event_type": "processed",
      "recipient_email": "ada@example.com",
      "timestamp": "2026-04-23T10:00:00Z"
    },
    {
      "event_type": "delivered",
      "recipient_email": "ada@example.com",
      "timestamp": "2026-04-23T10:00:02Z",
      "smtp_response": "250 2.0.0 OK",
      "metadata": {"mx_host": "mx1.example.com", "tls_version": "TLSv1.3"}
    },
    {
      "event_type": "open",
      "recipient_email": "ada@example.com",
      "timestamp": "2026-04-23T10:15:30Z",
      "user_agent": "Mozilla/5.0 ...",
      "ip_address": "203.0.113.42"
    }
  ]
}
```

The `events[]` array is the full message timeline in chronological order. Every event ScaiSend has recorded for this message lands here — it's the same events fanned out to your webhooks, just pull-based.

## Event fields

Events on the timeline include common fields plus type-specific extras:

| Field | Always present? | Notes |
|-------|----------------|-------|
| `event_type` | Yes | `processed`, `deferred`, `delivered`, `bounce`, `blocked`, `dropped`, `open`, `click`, `spam_report`, `unsubscribe`, `group_unsubscribe`, `group_resubscribe` |
| `recipient_email` | Yes | The recipient this event pertains to |
| `timestamp` | Yes | ISO-8601 UTC |
| `url` | On `click` | The original URL that was clicked |
| `user_agent` | On `open`, `click` | The recipient's client identifier |
| `ip_address` | On `open`, `click` | The recipient's IP at time of interaction |
| `bounce_type` | On `bounce` | `hard`, `soft`, `block` |
| `bounce_reason` | On `bounce` | Human-readable reason string |
| `smtp_response` | On `delivered`, `bounce`, `deferred`, `blocked` | Raw SMTP response line |
| `metadata` | Sometimes | Arbitrary extra fields |

See [Events and Webhooks](../concepts/events-and-webhooks) for the full metadata reference per event type.

## Message management

Three actions on a single message: **cancel**, **retry**, and **fail**.

### Cancel

Stops a scheduled or in-progress message from being sent. Works on `QUEUED` and `PROCESSING`. Fails (by design) if the message is already in `SENDING` or later.

```bash
curl -X POST https://scaisend.scailabs.ai/v3/messages/msg_01HXYZ/cancel \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

```json
{"message_id": "msg_01HXYZ", "status": "cancelled", "message": "Cancelled before delivery"}
```

Requires `mail.cancel` scope.

### Retry

Force-requeues a message that's stuck or in a terminal failure state. Works on `PROCESSING`, `SENDING`, `FAILED`, and `BOUNCED`.

```bash
curl -X POST https://scaisend.scailabs.ai/v3/messages/msg_01HXYZ/retry \
  -H "Authorization: Bearer $SCAISEND_API_KEY"
```

```json
{"message_id": "msg_01HXYZ", "status": "queued", "message": "Re-queued for delivery"}
```

Use cases:

- **A soft bounce that you think is resolvable.** The recipient's mailbox was full at the time; you've waited; retry.
- **A stuck message.** Rare, but if a message sat in `PROCESSING` too long (worker crashed mid-render), retry kicks it back into the queue.
- **A post-mortem redelivery.** Bounced because of a policy issue that's since been fixed (SPF was wrong; you fixed SPF; retry).

Retrying a hard bounce (`bounce_type: hard`) does not remove the address from the suppression list — you'll just get another `bounce` event, unless you also manually remove the suppression first.

### Fail

Mark a stuck message as `FAILED` manually. Used to clean up orphans:

```bash
curl -X POST https://scaisend.scailabs.ai/v3/messages/msg_01HXYZ/fail \
  -H "Authorization: Bearer $SCAISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Stuck in processing for 2 hours; marking failed"}'
```

The `reason` is optional; it's recorded as the message's `error_message`. The message transitions to `FAILED` and no further delivery is attempted.

Prefer retry over fail when the state is ambiguous. Use fail only when you're certain the message should not be redelivered.

## Performance notes

- `GET /v3/messages/{id}` is cheap (< 10 ms typically) — the message, events, and body come from a single query.
- `GET /v3/messages` is indexed on `tenant_id`, `created_at`, `status`, and common filter fields. Filters on high-cardinality fields (`to_email`, `subject`) fall back to full table scans on very large datasets; if you need those often, add a downstream index or an Elasticsearch export.
- Pagination uses offset/limit under the hood. Deep pagination (page > ~100) gets slow on large tenants; prefer filtering by date range to narrow.

## What's next

- [Events and Webhooks](../concepts/events-and-webhooks) — the push model for the same events.
- [Messages Reference](../reference/messages) — exhaustive endpoint reference.
- [Statistics](../reference/stats) — aggregate counts instead of per-message.
