Messages API

Send email, list inbox messages, and retrieve individual messages.

GET /v1/mailboxes/:id/messages

GET/v1/mailboxes/:id/messages

List messages for a mailbox with optional filters.

Query parameters

NameTypeDescription
direction"INBOUND" | "OUTBOUND"Filter by direction
threadIdstringFilter by thread UUID
sincestringISO 8601 timestamp — only messages after this time
limitnumber1-100 (default 50)
offsetnumberPagination offset (default 0)
response — 200 OK
{
  "messages": [
    {
      "id": "b2c3d4e5-6789-4abc-def0-222222222222",
      "direction": "INBOUND",
      "messageId": "<unique@example.com>",
      "fromAddress": "sender@example.com",
      "toAddresses": ["myagent@robotomail.co"],
      "subject": "Hello",
      "bodyText": "Hi there!",
      "bodyHtml": "<p>Hi there!</p>",
      "status": "RECEIVED",
      "hasAttachments": true,
      "attachmentsDropped": false,
      "attachmentsDroppedReason": null,
      "threadId": "c3d4e5f6-789a-4bcd-ef01-333333333333",
      "createdAt": "2026-03-11T12:00:00.000Z",
      "attachments": [
        {
          "id": "f7e8d9c0-1234-4abc-def0-555555555555",
          "filename": "invoice.pdf",
          "contentType": "application/pdf",
          "sizeBytes": 12345,
          "contentId": null,
          "createdAt": "2026-03-11T12:00:00.000Z"
        }
      ]
    }
  ],
  "metadata": {
    "overLimitCount": 3,
    "upgrade": {
      "browserUrl": "https://robotomail.com/billing",
      "apiEndpoint": { "method": "POST", "path": "/v1/billing/upgrade" },
      "hint": "POST /v1/billing/upgrade for a programmatic checkout URL, or visit browserUrl to sign in and upgrade"
    },
    "limitHint": {
      "current": 80,
      "limit": 100,
      "percentage": 80,
      "reset_date": "2026-05-01T00:00:00.000Z",
      "status": "near"
    }
  }
}

Note: the embedded attachments array on a message read does NOT include a presigned url. To download an attachment, call GET /v1/attachments/:id with the attachment's id. The webhook/SSE event payloads DO include a fresh download_url per attachment — see the event payloads reference.

Inbound-limit gating. When the account has exceeded its monthly inbound cap, over-limit messages are filtered out of this list and counted in metadata.overLimitCount. The field is 0 under normal operation. Upgrading the plan or the first-of-month reset unlocks every gated message in place — a subsequent call to this endpoint returns them with full content. See 402 INBOUND_LIMIT_EXCEEDED.

Metadata envelope

  • overLimitCount — number of messages hidden by the current filter (0 under normal operation).
  • upgrade — always present. Carries browserUrl, apiEndpoint, and a hint string. Surface the URL to end a quota block immediately.
  • limitHint — appears once monthly inbound usage reaches 50%. Snake-case shape (current, limit, percentage, reset_date, status) matching the inbound_usage block on webhook / SSE payloads, so the same countdown renderer works on both transports. status is one of approaching (≥ 50%), near (≥ 80%), or limit_reached.

POST /v1/mailboxes/:id/messages

POST/v1/mailboxes/:id/messages

Send an email from the specified mailbox. The message is sent immediately via Resend.

Request body

NameTypeDescription
to*string[]Recipient email addresses
ccstring[]CC recipients
subject*stringEmail subject (1-998 chars)
bodyText*stringPlain text body
bodyHtmlstringHTML body
inReplyTostringRFC 5322 Message-ID to reply to
attachmentsstring[]Attachment UUIDs from /v1/attachments
headersobjectCustom headers (x-custom-* or x-robotomail-* only)
curl
curl -X POST https://api.robotomail.com/v1/mailboxes/a1b2c3d4-5678-4def-abcd-111111111111/messages \
  -H "Authorization: Bearer $ROBOTOMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["recipient@example.com"],
    "subject": "Project update",
    "bodyText": "Here is the latest status...",
    "bodyHtml": "<p>Here is the latest status...</p>"
  }'
response — 201 Created
{
  "message": {
    "id": "b2c3d4e5-6789-4abc-def0-222222222222",
    "direction": "OUTBOUND",
    "fromAddress": "myagent@robotomail.co",
    "toAddresses": ["recipient@example.com"],
    "subject": "Project update",
    "status": "SENT",
    "externalMessageId": "0102abc...",
    "threadId": "c3d4e5f6-789a-4bcd-ef01-333333333333",
    "createdAt": "2026-03-11T12:30:00.000Z"
  }
}

Errors

  • 400 — Invalid input, mailbox inactive, address on the suppression list, or message validation failed (e.g. exceeds 40 MB, invalid address, invalid attachment)
  • 403 — Account suspended (response includes { suspended: true, reason }), email not verified, or scoped key referencing out-of-scope attachments
  • 429 — Daily/monthly send limit exceeded, velocity limit exceeded (30/min per mailbox, 60/min per account), or upstream rate limit
  • 500 — Unexpected send failure

GET /v1/mailboxes/:id/messages/:msgId

GET/v1/mailboxes/:id/messages/:msgId

Retrieve a single message with full body content and attachment metadata.

response — 200 OK
{
  "message": {
    "id": "b2c3d4e5-6789-4abc-def0-222222222222",
    "mailboxId": "a1b2c3d4-5678-4def-abcd-111111111111",
    "direction": "INBOUND",
    "messageId": "<unique@example.com>",
    "inReplyTo": null,
    "threadId": "c3d4e5f6-789a-4bcd-ef01-333333333333",
    "fromAddress": "sender@example.com",
    "toAddresses": ["myagent@robotomail.co"],
    "ccAddresses": [],
    "subject": "Hello",
    "bodyText": "Hi there!",
    "bodyHtml": "<p>Hi there!</p>",
    "headers": {},
    "status": "RECEIVED",
    "hasAttachments": true,
    "attachmentsDropped": false,
    "attachmentsDroppedReason": null,
    "createdAt": "2026-03-11T12:00:00.000Z",
    "attachments": [
      {
        "id": "f7e8d9c0-1234-4abc-def0-555555555555",
        "filename": "invoice.pdf",
        "contentType": "application/pdf",
        "sizeBytes": 12345,
        "contentId": null,
        "createdAt": "2026-03-11T12:00:00.000Z"
      }
    ]
  }
}

Note: API responses use camelCase field names (e.g. bodyText, fromAddress). Webhook and SSE event payloads use snake_case field names (e.g. body_text, from). See the event payloads reference for the webhook/SSE schema.

The embedded attachments array does NOT include a presigned url. To download an attachment, call GET /v1/attachments/:id with the attachment's id.

Errors

  • 402INBOUND_LIMIT_EXCEEDED: the requested inbound message is gated because the account has exceeded its monthly inbound cap. Outbound messages and under-limit inbound messages are unaffected. See 402 INBOUND_LIMIT_EXCEEDED
  • 404 — Mailbox or message not found (or not owned by the caller)

Inbound attachments

When a message arrives at one of your mailboxes, Robotomail extracts any MIME attachments, sanitizes the filenames, uploads the bytes to R2, and links them to the Message row. Inbound attachments are charged against your storage quota.

Limits

  • 25 MB per attachment
  • 20 attachments per message

When a message exceeds either limit, the message body is still ingested but the over-cap part(s) are dropped. The message row has attachmentsDropped: true and attachmentsDroppedReason set to one of:

  • "size" — at least one attachment exceeded the 25 MB cap
  • "count" — the message had more than 20 attachment parts
  • "both" — both conditions

Attachment object

response shape
{
  "id": "f7e8d9c0-1234-4abc-def0-555555555555",
  "messageId": "b2c3d4e5-6789-4abc-def0-222222222222",
  "userId": "u1234",
  "filename": "invoice.pdf",       // sanitized
  "contentType": "application/pdf",
  "sizeBytes": 12345,
  "r2Key": "u1234/inbound/f7e8.../invoice.pdf",
  "contentId": null,               // Content-ID for inline images, else null
  "url": "https://r2.example.com/...",  // presigned, valid 24h
  "createdAt": "2026-03-11T12:00:00.000Z"
}

Inline images

Inline images (parts referenced by cid: URIs in the HTML body, e.g. <img src="cid:logo@acme">) are extracted as normal attachments with contentId set to the Content-ID header value (without angle brackets — e.g. "logo@acme").

The bodyHtml field is stored verbatim — Robotomail does NOT rewrite cid: URLs. If you render the HTML directly, substitute each cid:foowith the matching attachment's download URL at render time. The webhook payload includesdownload_url per attachment, which you can use directly:

webhook flow
function renderInboundHtmlFromWebhook(data) {
  let html = data.body_html;
  for (const att of data.attachments) {
    if (att.content_id) {
      html = html.replaceAll(`cid:${att.content_id}`, att.download_url);
    }
  }
  return html;
}

For the REST API flow, fetch each inline attachment via GET /v1/attachments/:id to get a fresh presigned url:

REST API flow
async function renderInboundHtmlFromApi(mailboxId, messageId, client) {
  const { message } = await client.get(
    `/v1/mailboxes/${mailboxId}/messages/${messageId}`,
  );
  let html = message.bodyHtml;
  for (const att of message.attachments) {
    if (att.contentId) {
      const fresh = await client.get(`/v1/attachments/${att.id}`);
      html = html.replaceAll(`cid:${att.contentId}`, fresh.url);
    }
  }
  return html;
}

Caching note: presigned URLs are valid for 24 hours. If you cache the rendered HTML, re-fetch before the cached copy expires — storing pre-rendered HTML for longer than the URL TTL means stale broken images.

Message statuses

  • QUEUED — Message is queued for sending
  • SENT — Accepted for delivery
  • DELIVERED — Confirmed delivered to recipient's mail server
  • BOUNCED — Permanently bounced (address doesn't exist or rejected)
  • COMPLAINED — Recipient reported the message as spam
  • FAILED — Sending failed
  • RECEIVED — Inbound message stored successfully