API Reference
API Reference
GitHub ↗
Email Sending

Email Status

Track email delivery status

Every send answers with an id. This route turns that id back into delivery state, without the bodies, headers or attachments the full record carries — it is the cheap call to poll.

GET /api/v1/emails/{id}/status
curl http://localhost:3000/api/v1/emails/0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33/status \
  -H "Authorization: Bearer myk_..."
{
    "id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
    "status": "sent",
    "attempts": 1,
    "error_message": "",
    "sent_at": "2026-01-01T00:00:01Z"
}

Five fields, and that is the whole response. Use GET /api/v1/emails/{id} when you need the message itself.

Machine API schema

The /api/v1 surface describes itself: run mailyard export-api-spec and feed the document to Swagger UI, Postman, or a client generator.

The states

StatusMeaningMoves on
queuedAccepted and waiting for a workerWhen a worker claims it
scheduledHeld for a future send_atAt that time
processingClaimed and being handed to SMTPWithin one attempt
sentA server accepted it for one recipient or moreTerminal
failedEvery attempt was spentTerminal, unless retried
suppressedEvery recipient was blocked before sendingTerminal
cancelledWithdrawn by the caller before a worker took itTerminal

A seventh value, pending, is accepted as a filter on the email log for historical reasons and is never written. Filtering on it always returns nothing.

sent means accepted, not delivered

It records that a receiving SMTP server took the message. What happens after that — a mailbox that is full, a spam folder, a delayed bounce — arrives separately, as a bounce or a webhook . A message can be sent and bounced at the same time, and both facts are true. When the server refused some recipients and took the message for the rest, it is sent and error_message names the refused addresses.

Between queued and sent a message may be tried against several SMTP servers. attempts counts attempts, not servers — a single attempt can walk a whole failover chain before it gives up.

Retry

POST /api/v1/emails/{id}/retry

Only a failed message can be retried. Anything else is refused, naming the state it is actually in:

only failed emails can be retried (status is "sent")

Retry resets the attempt counter

The message goes back to queued with attempts set to zero and the error message cleared, so it gets a full set of tries again rather than resuming where it left off.

That is what you want after fixing the cause — new credentials, a corrected DNS record. It is not a way to nudge a message that failed for a permanent reason, which will simply spend the whole budget again.

Two more refusals, both 409 Conflict:

  • The content may be gone. A message older than the body or attachment retention window (see retention ) cannot be retried, because the sweep may already have emptied its body or dropped its attachments, and a retry would deliver what is left. Send it again as a new message instead. Failed messages are not exempt from the content windows: retention is the operator’s statement of how long content is kept.
  • Nobody is left to send to. Recipients suppressed since the failure - a hard bounce on the first attempt suppresses the address - are dropped from the retry, the way a new send drops them. When that leaves nobody, the retry is refused.

The response is the full email record, not the status summary. The worker is woken immediately, so a healthy queue picks the message up in the same second rather than at the next poll.

Cancel

POST /api/v1/emails/{id}/cancel

A scheduled message, or a queued one no worker has claimed yet, becomes cancelled and is never sent. Anything a worker already holds, or has finished with, is refused naming the state it is in. The decision is made in one statement against the same row the worker would claim, so a message is either claimed or cancelled and never both. A webhook subscribed to email.cancelled is told.

Polling

For a transactional send the interesting window is short — most messages leave queued within seconds. Poll this route every few seconds while the status is pending, queued or processing, and stop once it is not.

Do not poll a scheduled message. It is waiting on purpose, possibly for days, and the answer is the send_at you already know. The console makes the same distinction for the same reason.

For anything longer-lived, webhooks push the transitions instead of you asking for them.