# 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/SKILL.md. The request and response shapes are also published as an
OpenAPI 3.1 document at https://recvmail.net/openapi.json, generated from the schemas
the API validates with; this page has the rules it cannot express.

> **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: until
> then `POST /v1/inboxes` answers `401 unauthorized`. Details of the payment mechanism (an HTTP `402 Payment Required` challenge on the two
> paid calls), the prices and a machine-readable pricing endpoint will be added when
> the service opens.

## 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` |
| `PUT /v1/inboxes/:id/sender-domains` | `free` |
| `GET /v1/health` | `free` |
| Receiving mail, and mail rejected at SMTP time | `free` |

Each paid call buys one fixed unit, at one fixed price. Provision buys an inbox
for an hour with a 100 MiB quota; extend adds another hour. Neither call takes a
size or a duration, so the price of a call depends on nothing in the request: an
inbox kept for three hours is one provision and two extends. 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. Polling is rate limited per inbox, never priced.

The length of the unit may still change before the service opens; `expires_at` in
each response is always the answer.

## 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. `code` is a stable snake_case identifier; renaming one is an API change.
  Current codes: `unauthorized` (401), `forbidden` (403), `not_found` (404),
  `message_not_found` (404), `message_gone` (410), `inbox_cancelled` (410),
  `inbox_expired` (409), `idempotency_conflict` (409), `sender_opted_out` (409),
  `invalid_request` (400),
  `out_of_bounds` (400), `range_not_satisfiable` (416), `rate_limited` (429),
  `internal` (500).
- **Rate limits.** Every call on an inbox (`/v1/inboxes/:id` and below) can answer
  `429 rate_limited`. The response carries a `Retry-After` header in whole seconds,
  and the same number as `details.retry_after_seconds`: wait that long, then repeat
  the same request. Limits are per inbox and shared by its owner and reader
  credentials; a request with a wrong credential does not count. They allow short
  bursts (an agent waiting on a verification mail can poll every second for a couple
  of minutes) and a slower steady rate. The numbers are not published and may change,
  so build to `Retry-After` rather than to a rate. Poll, status and setting
  `sender_domains` share one limit; downloads have their own, so fetching never slows
  the poll loop; extend is not limited.
- Timestamps are ISO 8601 in UTC. Sizes are integer bytes (`quota_bytes`).
- Request bodies take only the fields documented for them: an unknown field is
  `400 invalid_request` naming it in `details.field`. On a paid call a field we
  ignored would be something you thought you were paying for.
- Identifiers: an `inbox_id` is 20 lowercase Crockford base32 characters and is
  also the local-part of the inbox address. Message ids are `msg_…`. Credentials
  are `rmo_…` (owner) and `rmr_…` (reader), shown at provisioning and never
  retrievable again, except by a replay of that same provision (see `Idempotency-Key`
  below).
- 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-Key` header (1–255 printable ASCII
  characters, no spaces); send one on every paid call. A retry with the same key and
  body returns the original response with `Idempotent-Replayed: true` and applies
  nothing; the same key with a different body is `409 idempotency_conflict`. Extend
  takes no body, so on extend a reused key is always a replay. A replay
  is answered from the stored response even if the inbox has expired since: the retry
  is asking whether the request applied.
  - On extend, keys are scoped to the inbox and deleted with it.
  - On provision, keys are scoped to the payer and remembered for 24 hours from the
    first request; after that the key is forgotten and a request with it provisions
    a new inbox. The replay includes the original credentials, which we keep only
    encrypted under the key itself, so treat the key like a credential: make it
    unique and unguessable (a random UUID), and do not reuse it.

## Credentials

Provisioning returns two credentials for the inbox:

| Credential | Prefix | May do |
|---|---|---|
| `credentials.owner` | `rmo_` | everything: poll, download, extend, set `sender_domains`, 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 (or no longer does), all return `unauthorized` (401): whether an
inbox exists is not revealed without its credential.

A request on an inbox is checked in this order: a credential that is missing,
malformed or was never issued for this inbox (`401`); then the request's shape
(a malformed query, body, `Idempotency-Key` or message id answers `400` or `404`;
that answer depends on the request alone, never on the inbox), and a
`sender_domains` list naming an opted-out domain (`409 sender_opted_out`, which
depends on the request alone too); then whether the credential is the inbox's own
(`401`), then whether the inbox was cancelled (`410`),
then what it may do (`403`), then the rate limit (`429`). A path under an inbox that
no endpoint serves is `404 not_found`.

An inbox used for abuse can be cancelled. Its mail, downloaded or not, is deleted at
once, and from then on every call with either of its credentials answers
`410 inbox_cancelled` with `details: { "reason": "abuse", "cancelled_at" }`, until
the end of the lifetime that was paid for. After that the inbox is purged and its
credentials answer `401`, as for any inbox past expiry. A cancelled inbox is not
refunded. We act on reports with the reporter's evidence, never by reading an
inbox's mail.

## 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.

Buys one unit: the inbox lives for an hour (3600 seconds) from `created_at`, with a
quota of 100 MiB (104857600 bytes). Neither is chosen by the caller; keep the inbox
longer with `POST …/extend`.

Request (the body may be `{}`):

```json
{ "sender_domains": ["github.com"] }
```

`sender_domains` is optional: the domains this inbox expects mail from, at most 20.
When the list is not empty, the inbox only takes mail whose `From:` domain is on it
(or is a subdomain of an entry) *and* was authenticated: DMARC passed, or a DKIM
signature aligned with `From:` passed. Anything else is turned away and shows up as a
rejection event naming the domain we saw (see Receiving mail). Leave it out, or send
`[]`, to take mail from anyone. It narrows who can fill your quota or put text in
front of you; it is not a substitute for checking `authentication` yourself. Entries
are domain names (`example.com`, any case, `xn--` form for IDNs), stored lowercased,
deduplicated and sorted; anything else is `invalid_request`, and more than 20 is
`out_of_bounds`. Change it later with `PUT …/sender-domains`.

A domain's owner can refuse mail to our addresses (see Receiving mail). A list naming
such a domain, or a subdomain of one, is refused before anything is charged:
`409 sender_opted_out`, with the entries in `details.domains`. That mail would never
arrive; drop them and provision again. A domain is never refused for an opt-out of
one of its subdomains. A retry of a request that already provisioned, with the same
`Idempotency-Key`, still returns its inbox.

Response `201`:

```json
{
  "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": 104857600,
  "sender_domains": ["github.com"],
  "credentials": { "owner": "rmo_…", "reader": "rmr_…" }
}
```

Send an `Idempotency-Key` (see Conventions): a retry after a lost response then
returns this same `201`, credentials included, with `Idempotent-Replayed: true`,
instead of provisioning (and charging for) a second inbox.

Errors:

| Status | `code` | When |
|---|---|---|
| 400 | `invalid_request` | body is not a JSON object, it has a field this call does not take (`details.field` names it; `quota_bytes` and `lifetime_seconds` included), a `sender_domains` entry is not a domain name, or `Idempotency-Key` is malformed (`details.field`) |
| 400 | `out_of_bounds` | more than 20 `sender_domains` |
| 409 | `idempotency_conflict` | the key was already used, within the last 24 hours, with a different body |
| 409 | `sender_opted_out` | a `sender_domains` entry is an opted-out domain or under one (`details.domains`) |

### Receiving mail

Cost: `free`, including mail that is rejected.

Mail to the address is accepted while the inbox is live, the sender is one the inbox
takes, and the message fits in the remaining quota. Everything else is turned away at
SMTP time, before the body is stored. Most of it gets 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 |
| the inbox was cancelled for abuse (until the end of its paid lifetime) | `address disabled for abuse; reports: abuse@admin.recvmail.net` | no |
| the sender's domain has asked us to refuse all mail from it | `sender domain opted out` | yes, reason `sender_opted_out` |
| message larger than the free quota | `mailbox quota exceeded` | yes, reason `quota` |
| the inbox has `sender_domains` and the sender is not on it | (temporary: see below) | yes, reason `sender_not_allowed` |

"Recorded" means `rejection_count` increments and the event appears in poll results
(while fewer than 100 are held).

A sender that `sender_domains` does not admit gets a **temporary** failure (`4xx`)
instead, so its server keeps the message and retries: typically within minutes at
first, backing off to hours, until the inbox expires (then it gets `inbox expired`).
If the event shows a domain you do want (a service's mail often comes from a
subdomain or a second domain of its own), add it with `PUT …/sender-domains`, and
the next retry is delivered; asking the service to resend works too. Retries of one
message are one event, identified by its `Message-ID`; `rejection_count` counts each
attempt.

Over-quota is worth acting on as well. The quota counts mail waiting to be fetched:
download what is listed, and each downloaded message stops counting when it is
deleted five minutes later; then ask the sender to resend. Nothing is queued on our
side, and the quota cannot be raised.

A domain's owner can ask us to refuse all mail from that domain (see our abuse
policy). Such mail is refused for every inbox, and the event tells you so, so you
can stop waiting for it: that service does not accept our addresses. A
`sender_domains` list that names such a domain is refused up front.

Some mail is refused by our mail server before it reaches an inbox, so
it is never recorded: a sending IP with no reverse DNS (`550 Sender IP reverse
lookup rejected`, on connect; any PTR record is enough), and a message that fails DMARC for a domain whose policy is `p=reject`
(`550 5.7.1 DMARC checks failed`, or an SPF refusal, at the end of data). An inbox
that expects mail and shows neither a message nor a rejection may be meeting one
of these; only the sender's operator can fix it.

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. What a deletion leaves behind is bounded too: a deleted message's id
answers `410 message_gone` for an hour, then `404`, and a rejection stays listed for
an hour after poll first lists it.

### `GET /v1/inboxes/:id` — inbox status

Cost: `free`.

Auth: owner or reader credential. Response `200`:

```json
{
  "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": 104857600,
  "quota_used_bytes": 1376,
  "message_count": 1,
  "rejection_count": 0,
  "sender_domains": []
}
```

`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. `sender_domains` is the inbox's list (empty: any
sender). 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`. Polling is rate limited per inbox, never priced (see Conventions).

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`):

```json
{
  "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>",
      "authentication": {
        "dmarc": { "result": "pass", "domain": "github.com" },
        "dkim": { "result": "pass", "domain": "github.com" },
        "spf": { "result": "pass", "domain": "github.com" }
      }
    }
  ],
  "rejections": [
    { "at": "2026-09-16T16:59:00.000Z", "envelope_from": "big@example.com", "reason": "quota", "sender_domain": "example.com", "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
}
```

- `messages` is what can be downloaded right now: stored messages, and downloaded
  ones still inside their grace window (`downloaded_at` set). Deliveries in flight
  are not listed yet; deleted messages are never listed. Oldest first.
- `from` is the parsed `From:` header (display name and address, RFC 2047 decoded;
  the envelope sender when the header is missing). `envelope_from` is the SMTP
  sender, the same value rejections carry. `subject` is decoded too.
- `authentication` is the SPF, DKIM and DMARC verdict of our mail server
  that received the message, each a `result` (lowercase, as the server wrote it:
  `pass`, `fail`, `softfail`, `neutral`, `none`, `temperror`, `permerror`) and the
  `domain` it is for: DMARC the `From:` domain, DKIM the signing domain (a passing
  signature aligned with `From:` if there is one), SPF the envelope sender's domain.
  `dkim.result` is `none` for an unsigned message. To trust that a message comes from
  who `from` says, require `dmarc.result == "pass"` with `dmarc.domain` the domain you
  expect: a `pass` for DKIM or SPF alone only vouches for whichever domain signed or
  sent it. Mail failing DMARC for a domain with `p=reject` never reaches an inbox.
  `authentication` is `null` when the verdict is missing or unreadable; it is read
  only from the header the receiving server added, never from one the sender wrote
  into the message, so treat `null` as unverified. The raw headers are in the
  download, unchanged.
- `delete_after` is 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, and it is not clamped to `expires_at`: expiry deletes everything
  regardless. Compare it with `server_time`, not with a local clock.
- **Cursor.** Without one you get everything fetchable from the beginning, plus every
  rejection still held. 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 `GET` `next_url`, which
  carries it and your `limit`. `has_more: true` means there is more right now: call
  `next_url` again. `has_more: false` means you are caught up; poll `next_url` again
  later and you will see only what is new.
- **Rejections** are mail we refused at SMTP time on this inbox's behalf. `reason` is
  one of:
  - `quota`: `size_bytes` is what the sender tried to deliver. The recovery is to
    download what is waiting, which frees its space five minutes later, and ask
    them to resend.
  - `sender_not_allowed`: `sender_domains` did not admit it, and the sender is
    retrying (see Receiving mail). `sender_domain` is the authenticated `From:` domain,
    the one to add if you want the mail; `null` means the message was not
    authenticated, and no list admits it.
  - `sender_opted_out`: the sender's domain, `sender_domain`, refuses our addresses.

  `sender_domain` is the `From:` domain the decision was about (for `quota`, the
  authenticated one if there was one, else `null`). New reasons may be added. Like a
  message, a rejection is deleted an hour after poll first lists it. At most 100 are
  held: past that a rejection is only counted in `inbox.rejection_count`, which counts
  every one, so a count higher than what you have seen listed means some were not
  kept. The oldest are the ones kept.
- An expired inbox answers in the same shape with `inbox.state: "expired"` and no
  messages; its rejections stay listed until it is deleted, or until an hour after
  they were first listed.
- Errors: `invalid_request` for a `cursor` that is not one of ours,
  `out_of_bounds` for `limit` (`details.field`, `min`, `max`, `got`).

The loop, for an agent: poll; download every `url` you care about; `GET next_url`
until `has_more` is false; wait; `GET next_url` again. Every second or few is fine
while you are waiting on a specific message; back off to 30 to 60 seconds for a
long-lived inbox with nothing expected soon. On `429 rate_limited`, wait
`Retry-After` seconds and `GET` the same URL again: nothing is lost, the cursor has
not moved.

### `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.
There is no parsed form: parse the message with your language's MIME library
(Python's `email` module, for example), and treat its content as the sender's,
not ours.

**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, a delivery still in flight, or a message deleted more than an hour ago |
| 410 | `message_gone` | it was deleted, within the last hour; `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). An hour after the deletion the id answers `404 message_not_found` |

### `POST /v1/inboxes/:id/extend` — add one lifetime unit

Cost: `paid`, the same price as provisioning. 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 one unit, an hour
(3600 seconds), to `expires_at`: relative to the current expiry, so a caller's clock
never enters into it. Call it again for each further hour; there is no cap on an
inbox's total lifetime. The quota stays as it is.

Request: no body, or `{}`. Any field is `400 invalid_request` (`details.field`
names it; `add_lifetime_seconds` and `add_quota_bytes` included).

Response `200`: the status object (as `GET /v1/inboxes/:id`) with the new
`expires_at`. Send an `Idempotency-Key` (see Conventions) so a retry after a lost
response cannot add a second hour.

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.

Errors:

| Status | `code` | When |
|---|---|---|
| 400 | `invalid_request` | the body is not empty, `{}` or a JSON object, it has a field (`details.field`), or `Idempotency-Key` is malformed (`details.field`) |
| 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 |

### `PUT /v1/inboxes/:id/sender-domains` — replace the sender-domain allowlist

Cost: `free`.

Auth: **owner** credential (the reader gets `403 forbidden`). Replaces the inbox's
`sender_domains` with the list you send (the rules are under provisioning), so
repeating a request is harmless and needs no `Idempotency-Key`. `[]` takes mail from
anyone again.

```json
{ "sender_domains": ["github.com", "githubusercontent.com"] }
```

Bounds: at most 20 domains.

Response `200`: the status object (as `GET /v1/inboxes/:id`) with the new list. It
applies to the next delivery attempt, including the retries of mail it turned away.
It counts against the same per-inbox rate limit as polling.

Errors:

| Status | `code` | When |
|---|---|---|
| 400 | `invalid_request` | body is not a JSON object, `sender_domains` is missing, or an entry is not a domain name (`details.field`) |
| 400 | `out_of_bounds` | more than 20 domains (`details.got` is how many you sent) |
| 403 | `forbidden` | reader credential |
| 409 | `inbox_expired` | `expires_at` has passed |
| 409 | `sender_opted_out` | an entry is an opted-out domain or under one (`details.domains`), as on provision; the list is unchanged |
| 429 | `rate_limited` | see Conventions |

### Planned

Not available yet. Names and shapes may change before they ship.

- `GET /v1/pricing` — machine-readable prices of the two paid calls and the unit
  each buys.
- `POST /v1/inboxes/:id/messages/:msg_id/reply` — reply to the original sender
  (owner; later).
