recvmail API
Machine-facing HTTP API at https://api.recvmail.net. This document is the contract
as built. For a task-oriented introduction written for agents, start with the skill at
https://recvmail.net/.
Status: not open yet. The endpoints below are built and their request and response shapes are final, but provisioning is closed until payments are live. Details of the payment mechanism (an HTTP
402 Payment Requiredchallenge on the two paid calls), the prices and a machine-readable pricing endpoint will be added when the service opens. Numeric bounds and retention windows marked provisional may change before then.
Cost
Two calls are paid. Everything else is free.
| Call | Cost |
|---|---|
POST /v1/inboxes (provision) | paid |
POST /v1/inboxes/:id/extend | paid |
GET /v1/inboxes/:id (status) | free |
GET /v1/inboxes/:id/messages (poll) | free |
GET, HEAD /v1/inboxes/:id/messages/:msg_id (download) | free |
GET /v1/health | free |
| Receiving mail, and mail rejected at SMTP time | free |
The price of a paid call is proportional to what it buys: quota_bytes and
lifetime_seconds on provision, the increments on extend. Payment is up front, per
call, and the price is known before the caller commits. Nothing is billed afterwards:
there are no accounts, no per-message, per-poll or per-download fees, and no
subscription. A caller's total cost is the sum of the provision and extend calls it
chose to make. Abusive polling will be rate limited, never priced.
Conventions
- All paths are under
/v1/. Request and response bodies are JSON; no HTML. - Errors are
{ "error": { "code", "message", "details"? } }with a matching HTTP status.codeis a stable snake_case identifier; renaming one is an API change. Current codes:unauthorized(401),forbidden(403),not_found(404),inbox_not_found(404),message_not_found(404),message_gone(410),inbox_expired(409),idempotency_conflict(409),invalid_request(400),out_of_bounds(400),range_not_satisfiable(416),rate_limited(429),internal(500).rate_limitedis reserved: no limit is enforced yet, but clients should already handle it by slowing down and retrying. - Timestamps are ISO 8601 in UTC. Sizes are integer bytes (
quota_bytes); durations are integer seconds (lifetime_seconds). - Identifiers: an
inbox_idis 20 lowercase Crockford base32 characters and is also the local-part of the inbox address. Message ids aremsg_…. Credentials arermo_…(owner) andrmr_…(reader), shown once at provisioning and never retrievable again. - A single inbound message can be at most 25 MiB, whatever the inbox's quota; larger mail is refused before it reaches the inbox.
- Paid operations accept an
Idempotency-Keyheader (1–255 printable ASCII characters, no spaces). On extend it is enforced: a retry with the same key and body returns the original response withIdempotent-Replayed: trueand applies nothing; the same key with a different body is409 idempotency_conflict. Keys are scoped to the inbox and deleted with it. A replay is answered from the stored response even if the inbox has expired since: the retry is asking whether the request applied. On provision the key is accepted today and will be enforced, with the same rules, before the service opens.
Credentials
Provisioning returns two credentials for the inbox:
| Credential | Prefix | May do |
|---|---|---|
credentials.owner | rmo_ | everything: poll, download, extend lifetime, add quota, and anything added later |
credentials.reader | rmr_ | poll and download only |
The split exists so an agent that provisions an inbox can hand monitoring to a
subagent without giving it the ability to spend. Both are bearer tokens
(Authorization: Bearer rmo_…) on the inbox's own endpoints; neither is accepted
for provisioning. A missing, malformed or wrong credential, or an inbox id that
does not exist, all return unauthorized (401): whether an inbox exists is not
revealed without its credential.
Endpoints
GET /v1/health
Cost: free. No credential.
Returns { "ok": true, "environment": "dev" | "prod" }.
POST /v1/inboxes — provision an inbox
Cost: paid. Closed until the service opens; the payment challenge will be documented
here then, and the request and response shapes below will not change.
Request:
{ "quota_bytes": 10485760, "lifetime_seconds": 3600 }
Bounds (provisional): lifetime_seconds 600–604800, quota_bytes 1048576–104857600.
A value outside the bounds returns out_of_bounds with details.field,
details.min, details.max.
Response 201:
{
"inbox_id": "7q3v8m2k9x1d5f6g0h4j",
"address": "7q3v8m2k9x1d5f6g0h4j@recvmail.net",
"created_at": "2026-09-14T17:00:00.000Z",
"expires_at": "2026-09-14T18:00:00.000Z",
"quota_bytes": 10485760,
"credentials": { "owner": "rmo_…", "reader": "rmr_…" }
}
Receiving mail
Cost: free, including mail that is rejected.
Mail to the address is accepted while the inbox is live and the message fits in
the remaining quota. Everything else is rejected at SMTP time, before the body is
stored, with a permanent 555 5.7.1 recvmail: <reason> that the sender sees in
its bounce:
| Situation | Reason text | Recorded? |
|---|---|---|
| no such inbox, or the inbox was deleted after expiry | no such inbox | no |
inbox past expires_at (deletion follows within ~20 minutes) | inbox expired | no |
| message larger than the free quota | mailbox quota exceeded | yes: rejection_count increments, and the event will appear in poll results |
Over-quota is the one case worth acting on: add quota with POST …/extend (the
rejection's size_bytes says how much) and ask the sender to resend. Nothing is
queued on our side.
Delivery is idempotent on Cloudflare's per-delivery id, so a slow handler that
Cloudflare re-invokes stores one message. A sender that retries after a
temporary failure on our side is a new delivery and may be stored twice; the
Message-ID header is kept with the message so an agent can dedupe on it.
Storage is bounded on purpose: a downloaded message is deleted five minutes later, a message listed by poll but never downloaded is deleted after an hour, and everything is deleted when the inbox expires. There is no reason to leave mail in an inbox.
GET /v1/inboxes/:id — inbox status
Cost: free.
Auth: owner or reader credential. Response 200:
{
"inbox_id": "7q3v8m2k9x1d5f6g0h4j",
"address": "7q3v8m2k9x1d5f6g0h4j@recvmail.net",
"state": "live",
"created_at": "2026-09-14T17:00:00.000Z",
"expires_at": "2026-09-14T18:00:00.000Z",
"quota_bytes": 10485760,
"quota_used_bytes": 1376,
"message_count": 1,
"rejection_count": 0
}
state is live or expired; an expired inbox answers with its final counts
until it is deleted (~20 minutes after expires_at), after which the credential
no longer resolves and the response is unauthorized. quota_used_bytes is the
size of messages currently held (including deliveries in flight);
message_count is how many there are, including deliveries still in flight, so it
can briefly exceed what poll lists. The same object is embedded in every poll
response, so a polling agent never needs to call this endpoint separately.
GET /v1/inboxes/:id/messages — poll
Cost: free. Abusive polling will be rate limited, never priced.
Auth: owner or reader credential. Query parameters, both optional: cursor (from a
previous response) and limit (1–200, default 50).
Response 200 (Cache-Control: no-store):
{
"inbox": { "inbox_id": "7q3v8m2k9x1d5f6g0h4j", "state": "live", "...": "the status object" },
"server_time": "2026-09-16T17:00:05.000Z",
"messages": [
{
"message_id": "msg_2x8k1d5f6g0h4j7q3v8m",
"url": "https://api.recvmail.net/v1/inboxes/7q3v8m2k9x1d5f6g0h4j/messages/msg_2x8k1d5f6g0h4j7q3v8m",
"from": { "name": "GitHub", "address": "noreply@github.com" },
"envelope_from": "noreply@github.com",
"subject": "[GitHub] Please verify your email address",
"size_bytes": 4798,
"received_at": "2026-09-16T17:00:01.000Z",
"downloaded_at": null,
"delete_after": "2026-09-16T18:00:05.000Z",
"internet_message_id": "<abc@github.com>"
}
],
"rejections": [
{ "at": "2026-09-16T16:59:00.000Z", "envelope_from": "big@example.com", "reason": "quota", "size_bytes": 9000000 }
],
"next_cursor": "1.k",
"next_url": "https://api.recvmail.net/v1/inboxes/7q3v8m2k9x1d5f6g0h4j/messages?cursor=1.k&limit=50",
"has_more": false
}
messagesis what can be downloaded right now: stored messages, and downloaded ones still inside their grace window (downloaded_atset). Deliveries in flight are not listed yet; deleted messages are never listed. Oldest first.fromis the parsedFrom:header (display name and address, RFC 2047 decoded; the envelope sender when the header is missing).envelope_fromis the SMTP sender, the same value rejections carry.subjectis decoded too.delete_afteris when the message will be deleted. Being listed for the first time sets it one hour out; a download moves it to five minutes out. It only ever moves earlier. Compare it withserver_time, not with a local clock.- Cursor. Without one you get everything fetchable from the beginning, plus every
rejection. With one you get only messages that became visible, and rejections
recorded, after that position. Pass it back verbatim (it is a position in the
inbox's event sequence, not a timestamp), or simply
GETnext_url, which carries it and yourlimit.has_more: truemeans there is more right now: callnext_urlagain.has_more: falsemeans you are caught up; pollnext_urlagain later and you will see only what is new. - Rejections are mail we refused at SMTP time on this inbox's behalf.
reasonisquotatoday:size_bytesis what the sender tried to deliver, so the recovery is to add at least that much quota (POST …/extend) and ask them to resend. - An expired inbox answers in the same shape with
inbox.state: "expired"and no messages; its rejections stay listed until it is deleted. - Errors:
invalid_requestfor acursorthat is not one of ours,out_of_boundsforlimit(details.field,min,max),inbox_not_foundif the inbox was deleted between the credential check and the read.
The loop, for an agent: poll; download every url you care about; GET next_url
until has_more is false; wait a few seconds; GET next_url again.
GET /v1/inboxes/:id/messages/:msg_id — download
Cost: free, HEAD and ranged requests included.
Auth: owner or reader credential. Response 200: the raw message exactly as
received, Content-Type: message/rfc822, with Content-Length, ETag,
Accept-Ranges: bytes and Cache-Control: no-store. This is the url from poll.
Downloading starts a five-minute deletion timer (delete_after in poll moves to
at most five minutes out) before the first byte is sent, so a transfer that fails can
be retried inside that window and then the message is gone. Fetch it once, keep it
yourself. HEAD returns the same headers without the body and does not start the
timer; use it to look without downloading.
Range: bytes=… (a single range: 0-99, 100-, -50) returns 206 with
Content-Range; a ranged GET counts as a download. A range past the end returns
416 range_not_satisfiable with Content-Range: bytes */<size> and the size again in
details.size_bytes. Multiple ranges are ignored and the whole message is served. A
HEAD with a Range header answers 200 with the ranged Content-Length and
Content-Range.
Errors:
| Status | code | When |
|---|---|---|
| 404 | message_not_found | no such message in this inbox, malformed id, or a delivery still in flight |
| 410 | message_gone | it was deleted; details.reason says why: downloaded (grace window passed), unclaimed (listed by poll but not downloaded within an hour), expired (the inbox expired), lost (our copy is missing; should not happen, and is logged) |
POST /v1/inboxes/:id/extend — add lifetime and/or quota
Cost: paid, priced like provisioning on what is added. The payment challenge will be
documented here when the service opens; the request and response shapes below will not
change.
Auth: owner credential (the reader gets 403 forbidden). Adds to the inbox's
lifetime, its quota, or both, in one call. Increments are relative, so a caller's
clock never enters into it, and one call (one payment) covers the usual recovery
after an over-quota rejection: more bytes and enough time for the resend.
Request, at least one field, both positive integers:
{ "add_lifetime_seconds": 600, "add_quota_bytes": 5242880 }
Bounds (provisional): add_lifetime_seconds 60–604800 (a week per call, as many
calls as you like: there is no cap on an inbox's total lifetime), add_quota_bytes
≥ 1 with the result at most 104857600. A violation returns out_of_bounds with
details.field, details.min, details.max (for quota, the room this inbox has
left, so the error tells you what would have been accepted) and details.got.
Both increments are checked before either is applied.
Response 200: the status object (as GET /v1/inboxes/:id) with the new
expires_at and quota_bytes. Send an Idempotency-Key (see Conventions) so a
retry after a lost response cannot apply twice.
Extending never shortens or lengthens a message's delete_after: a listed message
still goes away an hour after it was first listed, a downloaded one five minutes
after the download. The added quota is available to the next delivery.
Errors:
| Status | code | When |
|---|---|---|
| 400 | invalid_request | body is not JSON, neither field is present, or Idempotency-Key is malformed (details.field) |
| 400 | out_of_bounds | an increment is not a positive integer above its minimum, or would exceed the inbox's room |
| 403 | forbidden | reader credential |
| 409 | inbox_expired | expires_at has passed. Nothing can bring an expired inbox back: its messages were deleted at expiry and senders were told so. Extend before it expires, or provision a new inbox. (A replay of an extend that already succeeded still returns its stored 200) |
| 409 | idempotency_conflict | the key was already used on this inbox with a different body |
| 404 | inbox_not_found | the inbox was deleted between the credential check and the call |
Planned
Not available yet. Names and shapes may change before they ship.
GET /v1/inboxes/:id/messages/:msg_id/parsed— the message as JSON (text, html, headers, attachment list) for callers that do not want to parse MIME.GET /v1/pricing— machine-readable price function and bounds.GET /v1/inboxes/:id/messages?wait=— long poll: hold the request until something arrives.POST /v1/inboxes/:id/messages/:msg_id/reply— reply to the original sender (owner; later).