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.
| 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/pricing | 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: 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.
- Send the request without a payment. It is checked first, so a malformed request (or,
on extend, a missing or foreign credential) is answered
400or401before anything is asked of you. Otherwise the answer is402 payment_required, with the payment requirement in thePAYMENT-REQUIREDheader: base64 of a JSON challenge. - Sign a payment for exactly that requirement, and send the same request again with
it, base64 JSON, in
PAYMENT-SIGNATURE. Underexacton Base the payment is a signed USDC transfer authorization (EIP-3009) for the statedamounttopayTo, valid for at mostmaxTimeoutSeconds; it moves nothing until we settle it. - 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,networkandpayer.
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.
| Status | code | When |
|---|---|---|
| 402 | payment_required | no PAYMENT-SIGNATURE: pay the requirement in PAYMENT-REQUIRED |
| 402 | payment_invalid | the 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) |
| 402 | payment_insufficient_funds | the paying wallet holds too little USDC on Base; nothing was charged |
| 402 | payment_declined | the payer was declined by the facilitator's screening; nothing was charged, and retrying from the same wallet will not help |
| 503 | payment_unavailable | the payment could not be verified or settled right now; nothing was charged. Retry after Retry-After seconds (also details.retry_after_seconds) |
| 503 | payment_pending | the 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.codeis 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 callspayment_required,payment_invalid,payment_insufficient_funds,payment_declined(402),payment_unavailableandpayment_pending(503) (see Paying). - Rate limits. Every free call on an inbox (
/v1/inboxes/:idand below) can answer429 rate_limited. The response carries aRetry-Afterheader in whole seconds, and the same number asdetails.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 toRetry-Afterrather than to a rate. Poll, status and settingsender_domainsshare 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_requestnaming it indetails.field. On a paid call a field we ignored would be something you thought you were paying for. - 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 at provisioning and never retrievable again, except by a replay of that same provision (seeIdempotency-Keybelow). - 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); send one on every paid call. A retry with the same key and body is answered withIdempotent-Replayed: trueand applies nothing; the same key with a different body is409 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_atandstate. - 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.
- 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
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). 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
}
}
unitis what one paid call buys: the lifetime it adds and the inbox's quota.paidlists the paid calls, each with its price in USD as a decimal string, and the units it buys.paymentis the requirement every paid call's 402 states: the x402 version and scheme, the network (CAIP-2:eip155:8453is Base), the token's contract, symbol and decimals, theamountin its smallest unit (price_usdtimes 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: paid, 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:
| 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) |
| 402, 503 | payment_… | 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:
| 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 remaining 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 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
}
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.authenticationis the SPF, DKIM and DMARC verdict of our mail server that received the message, each aresult(lowercase, as the server wrote it:pass,fail,softfail,neutral,none,temperror,permerror) and thedomainit is for: DMARC theFrom:domain, DKIM the signing domain (a passing signature aligned withFrom:if there is one), SPF the envelope sender's domain.dkim.resultisnonefor an unsigned message. To trust that a message comes from whofromsays, requiredmarc.result == "pass"withdmarc.domainthe domain you expect: apassfor DKIM or SPF alone only vouches for whichever domain signed or sent it. Mail failing DMARC for a domain withp=rejectnever reaches an inbox.authenticationisnullwhen 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 treatnullas unverified. The raw headers are in the download, unchanged.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, and it is not clamped toexpires_at: expiry deletes everything regardless. Compare it withserver_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
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.
reasonis one of:quota:size_bytesis 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_domainsdid not admit it, and the sender is retrying (see Receiving mail).sender_domainis the authenticatedFrom:domain, the one to add if you want the mail;nullmeans the message was not authenticated, and no list admits it.sender_opted_out: the sender's domain,sender_domain, refuses our addresses.
sender_domainis theFrom:domain the decision was about (forquota, the authenticated one if there was one, elsenull). 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 ininbox.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_requestfor acursorthat is not one of ours,out_of_boundsforlimit(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, 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:
| 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 200, with the inbox's status) |
| 409 | idempotency_conflict | the key was already used on this inbox with a different body |
| 402, 503 | payment_… | 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:
| 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.
POST /v1/inboxes/:id/messages/:msg_id/reply— reply to the original sender (owner; later).