Messages API
Send email, list inbox messages, and retrieve individual messages.
GET /v1/mailboxes/:id/messages
List messages for a mailbox with optional filters.
Query parameters
| Name | Type | Description |
|---|---|---|
| direction | "INBOUND" | "OUTBOUND" | Filter by direction |
| threadId | string | Filter by thread UUID |
| since | string | ISO 8601 timestamp — only messages after this time |
| limit | number | 1-100 (default 50) |
| offset | number | Pagination offset (default 0) |
{
"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. CarriesbrowserUrl,apiEndpoint, and ahintstring. 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 theinbound_usageblock on webhook / SSE payloads, so the same countdown renderer works on both transports.statusis one ofapproaching(≥ 50%),near(≥ 80%), orlimit_reached.
POST /v1/mailboxes/:id/messages
Send an email from the specified mailbox. The message is sent immediately via Resend.
Request body
| Name | Type | Description |
|---|---|---|
| to* | string[] | Recipient email addresses |
| cc | string[] | CC recipients |
| subject* | string | Email subject (1-998 chars) |
| bodyText* | string | Plain text body |
| bodyHtml | string | HTML body |
| inReplyTo | string | RFC 5322 Message-ID to reply to |
| attachments | string[] | Attachment UUIDs from /v1/attachments |
| headers | object | Custom headers (x-custom-* or x-robotomail-* only) |
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>"
}'{
"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 attachments429— Daily/monthly send limit exceeded, velocity limit exceeded (30/min per mailbox, 60/min per account), or upstream rate limit500— Unexpected send failure
GET /v1/mailboxes/:id/messages/:msgId
Retrieve a single message with full body content and attachment metadata.
{
"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
402—INBOUND_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 402INBOUND_LIMIT_EXCEEDED404— 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
{
"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:
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:
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 sendingSENT— Accepted for deliveryDELIVERED— Confirmed delivered to recipient's mail serverBOUNCED— Permanently bounced (address doesn't exist or rejected)COMPLAINED— Recipient reported the message as spamFAILED— Sending failedRECEIVED— Inbound message stored successfully