docs/api.md598 lines · 31.5 KB

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.

Cost

Two calls are paid. Everything else is free.

CallCost
POST /v1/inboxes (provision)
POST /v1/inboxes/:id/extend
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-domainsfree
GET /v1/pricingfree
GET /v1/healthfree
Receiving mail, and mail rejected at SMTP timefree

Each paid call buys one fixed unit, at one fixed price: a cent (0.01 USD), paid in USDC on Base. Provision buys an inbox for 75 minutes with a 100 MiB quota; extend adds another 75 minutes. Neither call takes a size or a duration, so the price of a call depends on nothing in the request: an inbox kept for five hours is one provision and three extends, four cents. Payment is up front, per call, and the price is known before the caller commits (GET /v1/pricing, and the 402 itself). 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.

Paying

The paid calls use x402 version 2, the HTTP 402 Payment Required exchange: any x402 v2 client that pays the exact scheme in USDC on Base can make them, usually by wrapping the HTTP client it already uses. There is no account and no API key; the payment is the authorization to provision.

  1. Send the request without a payment. It is checked first, so a malformed request (or, on extend, a missing or foreign credential) is answered 400 or 401 before anything is asked of you. Otherwise the answer is 402 payment_required, with the payment requirement in the PAYMENT-REQUIRED header: base64 of a JSON challenge.
  2. Sign a payment for exactly that requirement, and send the same request again with it, base64 JSON, in PAYMENT-SIGNATURE. Under exact on Base the payment is a signed USDC transfer authorization (EIP-3009) for the stated amount to payTo, valid for at most maxTimeoutSeconds; it moves nothing until we settle it.
  3. We verify it, then run the call's own checks, then settle it (about a second), then apply the call. The success response carries PAYMENT-RESPONSE, base64 JSON of the settlement: success, transaction, network and payer.

PAYMENT-REQUIRED, decoded:

{
  "x402Version": 2,
  "resource": {
    "url": "https://api.recvmail.net/v1/inboxes",
    "description": "recvmail: one lifetime unit of an inbox (75 minutes)",
    "mimeType": "application/json"
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:8453",
      "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "amount": "10000",
      "payTo": "0x…",
      "maxTimeoutSeconds": 60,
      "extra": { "name": "USD Coin", "version": "2" }
    }
  ]
}

amount is in USDC's smallest unit (six decimals): 10000 is 0.01 USDC. extra is the token's EIP-712 domain, which the authorization is signed over. Pay the payTo the challenge names; GET /v1/pricing states the same requirement.

Nothing refused is ever settled. A payment is settled only once the call is going to apply, so a call refused after verifying (an Idempotency-Key used with a different body, a sender_domains list naming an opted-out domain, a reader's credential on extend, an expired or cancelled inbox) and an idempotent replay cost nothing: the authorization is left unused and lapses within its 60 seconds. Every attempt carries a payment of its own, retries included: one already presented is refused (payment_invalid, reason reused), and x402 clients sign a new one for each 402.

StatuscodeWhen
402payment_requiredno PAYMENT-SIGNATURE: pay the requirement in PAYMENT-REQUIRED
402payment_invalidthe payment was refused, and nothing was charged. details.reason: malformed (not an x402 v2 payment), mismatch (not for this requirement: scheme, network, asset, amount or recipient), window (not valid now, or expiring within a few seconds), signature (not signed by the wallet it names), reused (already presented), rejected (refused for another reason when verified)
402payment_insufficient_fundsthe paying wallet holds too little USDC on Base; nothing was charged
402payment_declinedthe payer was declined by the facilitator's screening; nothing was charged, and retrying from the same wallet will not help
503payment_unavailablethe payment could not be verified or settled right now; nothing was charged. Retry after Retry-After seconds (also details.retry_after_seconds)
503payment_pendingthe settlement's outcome is not known yet, or another request under the same Idempotency-Key is paying. Retry after Retry-After seconds with the same key: the retry finishes this payment rather than making a second one

Every 402 carries the challenge again in PAYMENT-REQUIRED, with error set to the code when a payment was refused. Send an Idempotency-Key on every paid call (see Conventions): it is what lets a retry after a lost response, a failure after settlement, or a payment_pending finish the call without paying twice. Without one, every attempt is a new call: a retry after a lost response pays again and buys again, and a payment that settled on a call that then failed cannot be finished.

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), and on the paid calls payment_required, payment_invalid, payment_insufficient_funds, payment_declined (402), payment_unavailable and payment_pending (503) (see Paying).
  • Rate limits. Every free 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 is answered with Idempotent-Replayed: true and applies nothing; the same key with a different body is 409 idempotency_conflict. A retry carries a payment like any paid call: it is verified, and on a replay or a conflict never settled (see Paying). A replay is answered 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. Extend takes no body, so a reused key is always a replay, and the replay returns the inbox as it is now, not as the first response had it: another extend since, or expiry, shows in its expires_at and state.
    • On provision, keys are scoped to the paying wallet and remembered for 24 hours from the first request: a retry must pay from the same wallet to find its inbox, and after a day 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:

CredentialPrefixMay do
credentials.ownerrmo_everything: poll, download, extend, set sender_domains, and anything added later
credentials.readerrmr_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). On extend, which is paid, the payment comes after the request's shape and before the rest (402 or 503, see Paying), so a refusal from there on is answered without settling it. 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 (acceptable use).

Endpoints

GET /v1/health

Cost: free. No credential.

Returns { "ok": true, "environment": "dev" | "prod" }.

GET /v1/pricing — what paid calls cost

Cost: free. No credential.

What each paid call costs and buys, and how it is paid, as JSON, so a caller can decide whether it can afford a call before making it. Response 200:

{
  "unit": { "lifetime_seconds": 4500, "quota_bytes": 104857600 },
  "paid": [
    { "method": "POST", "path": "/v1/inboxes", "price_usd": "0.01", "units": 1 },
    { "method": "POST", "path": "/v1/inboxes/{id}/extend", "price_usd": "0.01", "units": 1 }
  ],
  "payment": {
    "protocol": "x402",
    "x402_version": 2,
    "scheme": "exact",
    "network": "eip155:8453",
    "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "asset_symbol": "USDC",
    "decimals": 6,
    "amount": "10000",
    "pay_to": "0x…",
    "max_timeout_seconds": 60
  }
}
  • unit is what one paid call buys: the lifetime it adds and the inbox's quota.
  • lists the paid calls, each with its price in USD as a decimal string, and the units it buys.
  • payment is the requirement every paid call's 402 states: the x402 version and scheme, the network (CAIP-2: eip155:8453 is Base), the token's contract, symbol and decimals, the amount in its smallest unit (price_usd times 10^decimals), the wallet paid, and how long a payment may be valid.

It answers 503 payment_unavailable only if payments are closed.

POST /v1/inboxes — provision an inbox

Cost: , a cent (see Paying). No credential: the payment is the authorization.

Buys one unit: the inbox lives for 75 minutes (4500 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 {}):

{ "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 after the payment is verified and before it is settled, so nothing 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:

{
  "inbox_id": "7q3v8m2k9x1d5f6g0h4j",
  "address": "7q3v8m2k9x1d5f6g0h4j@recvmail.net",
  "created_at": "2026-09-14T17:00:00.000Z",
  "expires_at": "2026-09-14T18:15: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 and no PAYMENT-RESPONSE, instead of provisioning (and charging for) a second inbox.

Errors:

StatuscodeWhen
400invalid_requestbody 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)
400out_of_boundsmore than 20 sender_domains
409idempotency_conflictthe key was already used, within the last 24 hours, with a different body
409sender_opted_outa sender_domains entry is an opted-out domain or under one (details.domains)
402, 503payment_…see Paying

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:

SituationReason textRecorded?
no such inbox, or the inbox was deleted after expiryno such inboxno
inbox past expires_at (deletion follows within ~20 minutes)inbox expiredno
the inbox was cancelled for abuse (until the end of its paid lifetime)address disabled for abuse; reports: abuse@admin.recvmail.netno
the sender's domain has asked us to refuse all mail from itsender domain opted outyes, reason sender_opted_out
message larger than the remaining quotamailbox quota exceededyes, 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 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: mail from a sending IP with no reverse DNS, refused when it connects (any PTR record is enough), and a message that fails DMARC for a domain whose policy is p=reject, refused at the end of the message (sometimes as an SPF failure). Both are permanent 5xx refusals the sender sees in its bounce. 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:

{
  "inbox_id": "7q3v8m2k9x1d5f6g0h4j",
  "address": "7q3v8m2k9x1d5f6g0h4j@recvmail.net",
  "state": "live",
  "created_at": "2026-09-14T17:00:00.000Z",
  "expires_at": "2026-09-14T18:15: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):

{
  "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:

StatuscodeWhen
404message_not_foundno such message in this inbox, malformed id, a delivery still in flight, or a message deleted more than an hour ago
410message_goneit 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: , a cent, the same price as provisioning (see Paying).

Auth: owner credential (the reader gets 403 forbidden, after its payment is verified and without settling it). Adds one unit, 75 minutes (4500 seconds), to expires_at: relative to the current expiry, so a caller's clock never enters into it. Call it again for each further unit; there is no cap on an inbox's total lifetime. The quota stays as it is.

An inbox that expires cannot be extended, and its address is never issued again, so extend before expires_at, not at it. A caller that checks in on a schedule should extend when expires_at falls before its next check plus a margin: on an hourly schedule a new inbox reaches its first check with 15 minutes to spare, and that rule buys four units every five hours. To survive a missed check, extend ahead by as many units as the longest gap it must outlast.

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, and PAYMENT-RESPONSE. Send an Idempotency-Key (see Conventions) so a retry after a lost response cannot add a second unit; the retry gets the status as it is then, with Idempotent-Replayed: true and no charge.

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:

StatuscodeWhen
400invalid_requestthe body is not empty, {} or a JSON object, it has a field (details.field), or Idempotency-Key is malformed (details.field)
403forbiddenreader credential
409inbox_expiredexpires_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 200, with the inbox's status)
409idempotency_conflictthe key was already used on this inbox with a different body
402, 503payment_…see Paying

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.

{ "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:

StatuscodeWhen
400invalid_requestbody is not a JSON object, sender_domains is missing, or an entry is not a domain name (details.field)
400out_of_boundsmore than 20 domains (details.got is how many you sent)
403forbiddenreader credential
409inbox_expiredexpires_at has passed
409sender_opted_outan entry is an opted-out domain or under one (details.domains), as on provision; the list is unchanged
429rate_limitedsee Conventions

Planned

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

  • POST /v1/inboxes/:id/messages/:msg_id/reply — reply to the original sender (owner; later).