API Reference
API Reference
GitHub ↗
Email Sending

Email Log

List and filter a project's sent email

Every message a project accepts becomes a row here, whatever route submitted it — the API, a template send, a batch, a campaign, or SMTP submission . The log is the record of what was sent and what happened to it, and it backs the Emails page in the console.

GET /api/v1/emails
curl "http://localhost:3000/api/v1/emails?limit=50&status=failed" \
  -H "Authorization: Bearer myk_..."
{
    "emails": [
        {
            "id": "...",
            "sender": "...",
            "recipients": [
                "..."
            ],
            "status": "failed"
        }
    ]
}

Parameters

ParamNotes
statusOne status or several separated by commas — see Email Status
senderPart of the From mailbox, without regard to case
recipientPart of any recipient, Cc and Bcc included
matchcontains (the default) or exact. Exact makes sender and recipient one whole address each, found bare or inside Name <address>
templateThe name of the template the message was rendered from, without regard to case
tagOnly messages carrying this tag, without regard to case
api_key_idOnly mail accepted through this API key
smtp_server_idOnly mail delivered through this server
from, toA created_at window. Each is a date (2026-08-01) or an RFC 3339 instant, to exclusive, and a bare date on to includes that whole day
afterCreated strictly after this instant. A poller passes the created_at of the newest row it has
searchA whole recipient address, or part of a subject, without regard to case
limitDefault 50, maximum 200
cursorThe next_cursor of the previous page

Every filter is ANDed with the others. Rows come back newest first, ordered by created_at then id. That order is fixed — there is no sort parameter. The response carries next_cursor, empty on the last page.

What search actually matches

Two things, joined by OR:

  • A recipient, matched whole. The pattern is the complete address, so alice@example.com finds the message and alice@ finds nothing. It is matched against the recipient as it was submitted, so a send addressed to Alice <alice@example.com> is not found by the bare address. Case does not matter.
  • A subject, matched as a substring, case-insensitively. invoice finds “Your invoice for March”.

The body is never searched. It may be large, it may be redacted by a retention policy, and it may live in blob storage rather than in the row — none of which makes for a predictable search.

Paging is a cursor, not an offset

The log grows with every message sent, so it pages by cursor. Every page carries next_cursor, and the next request passes it back:

curl -G http://localhost:3000/api/v1/emails \
  -H "Authorization: Bearer myk_..." \
  --data-urlencode "cursor=MjAyNi0wMy0yMFQwOToxNDoyMi40ODFafDAxOThmNmExLTNjN2UtN2IyMS05ZjRkLTJhNWM4ZTBiMWQzMw"

The cursor is opaque. Inside it is the created_at and the id of the last row together, which is what makes it safe: two messages can share a created_at down to the microsecond, and a cursor made of the timestamp alone would drop the rows tied with it across a page boundary. An empty next_cursor is the last page.

There is no total, deliberately. Counting a table that grows per message costs more than the page it would decorate.

One message

GET /api/v1/emails/{id}

The full record: sender, recipients, subject, both bodies, headers, attachment metadata, the delivery state, and the tracking counters. Content may be shortened or removed by the installation’s retention settings once a message is old enough.

recipients is the envelope, every address the message was delivered to. Beside it, addressing splits that back into the lists the sender wrote: to and cc as the headers named them, and as bcc every recipient the headers did not name. A message sent with to alone has everybody under to and the other two empty.

"addressing": {
    "to": [
        "jane@customer.example"
    ],
    "cc": [],
    "bcc": [
        "archive@yourapp.example"
    ]
}

Related routes on the same message:

RouteAnswers
GET /api/v1/emails/{id}/statusJust the delivery state — the cheap poll
GET /api/v1/emails/{id}/attachments/{idx}One attachment’s bytes, by position
GET /api/v1/emails/{id}/emlThe message as an .eml file, built the way delivery builds it
GET /api/v1/emails/{id}/tracked-linksThe links rewritten for click tracking, with their tallies
POST /api/v1/emails/{id}/retryRequeue a failed message
POST /api/v1/emails/{id}/cancelWithdraw a scheduled or still queued message

Counts

GET /api/v1/emails/stats?from=2026-08-01&to=2026-08-31

Per-status totals for the project, which is what the dashboard tiles read. from and to are optional and take the same date-or-instant bounds as the list, so one month’s counts are one request:

{
    "counts": {
        "sent": 18422,
        "failed": 31,
        "queued": 4,
        "suppressed": 12
    }
}

In the console

The Emails page offers exactly what the API does: a status dropdown and one search box, labelled for what it takes — a recipient address or part of a subject. Paging is the same cursor, so Load more appends rather than jumping to a page number.

The page refreshes itself on a timer, quietly, and pauses that refresh once you have paged back through history — a refresh that yanked you forward every ten seconds while you were reading would be worse than slightly stale rows.

This list may be served by a read replica

Where replicas are configured, the log listing and the status counts are among the queries allowed to run on one. That makes them cheap and makes them eventually consistent: a message accepted a moment ago can be missing from the list for as long as replication lag lasts.

Fetching one message by id always reads the primary, so a send followed by a lookup of its own id is never affected.