Temporary inboxes for platforms
You are building a product, and one of its features wants to receive an email on a user’s behalf: a forwarded invoice, a one-time code, a message somebody insists they sent. You do not want to run mail infrastructure to do it. This guide is the afternoon’s work that makes SendOps do it for you.
The shape is deliberately simple. Your product holds one SendOps API key. It mints a temporary inbox for one of its users, tagged with your own id for that user, and gets back two things: an address at sndps.com that lives for 1 to 60 minutes, and a pull URL your user polls to read what arrives. The pull URL needs no SendOps credential at all, so your user never has a SendOps account and never sees your key. Who may see the URL is your product’s decision, which is how sharing and teams work: SendOps has no opinion about your users.
Every request and response below was run against a SendOps development stack and pasted here with only the hostnames changed to production.
A pull URL is a bearer secret
Anybody holding a pull URL reads that inbox’s mail until the inbox expires or the URL is revoked. Store it where only that user can reach it, never log it, and never put it in a page that leaks its address in a Referer header. Revoking the URL is how you take the access back; the inbox itself is untouched.
What you get
| An address | xxxxxxxxxx@sndps.com, opaque, minted in under a second, dead after ttl seconds (60 to 3600, default 600) |
| A pull URL | https://fetch.sendops.dev/p/{token}/messages — reads status and messages with no credential; read-only; up to 10 live per inbox; revocable one at a time |
| The message | The same structured JSON the inbound webhook delivers, schema_version: 1: headers, text and HTML bodies, attachments as expiring links, SPF/DKIM/DMARC verdicts |
| Your partition key | account_ref, an opaque string you choose per user. SendOps meters caps by it, lets you list by it, and never interprets it |
| Restriction on your word | In Platform mode you may restrict an inbox to any sender address without SendOps verifying it — because you already verified your user’s address at signup |
Turn on Platform mode
Platform mode is a per-organization setting in the SendOps dashboard: Workspace → Temporary inboxes → Platform mode. It needs the org.settings.manage permission and is recorded in the audit log. What it changes, and why you want it before your first mint:
- Caps are counted per
account_ref, not per API key. Without it, your sixth user would be refused because your first five hold five inboxes between them. With it, each of your users may hold 5 live unrestricted inboxes; your organization may hold 500. - The daily allowance rises to 2,000 mints and the hourly window is dropped, because a platform’s mint rate follows its users’ activity, not one person’s afternoon.
allowed_sendersis accepted on syntax alone. An inbox restricted tobilling@northwind.examplerefuses mail from anyone else, and SendOps takes your word that your user owns that address.
That last point is a responsibility, and the settings card says so in as many words: by turning this on you confirm that your product has verified that its users own the addresses it passes as allowed_senders. Mail from an unverified stranger landing in a restricted inbox is your responsibility. Switching it off later changes the rules for the next mint; existing inboxes keep the rules they were minted under. Help article: Platform mode for temporary inboxes.
Mint an inbox for one of your users
Your API key needs api.inboxes.manage (and api.inboxes.view to list and poll over v1). Pass account_ref — in Platform mode it is required — plus whatever ttl, label and allowed_senders the situation wants.
curl -X POST https://api.sendops.dev/v1/inboxes \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_ref": "ws_8f2k1",
"ttl": 900,
"label": "Invoice from Northwind",
"allowed_senders": ["billing@northwind.example", "@acme.example"]
}' HTTP/2 201
{
"id": "01a090e9-ca2f-7da2-9247-b07795202997",
"address": "gd036gv4ga@sndps.com",
"expires_at": "2026-09-11T14:55:35Z",
"poll_url": "https://api.sendops.dev/v1/inboxes/01a090e9-ca2f-7da2-9247-b07795202997/messages",
"pull_url": "https://fetch.sendops.dev/p/pt_s3ljfuhtyfl5lpetfwktsqwvfca662xzvcht3psykuisg65mejjq/messages",
"pull_token": {
"id": "01a090e9-ca30-768c-aeea-c743f4f61002",
"prefix": "s3ljfuht",
"label": "default",
"created_at": "2026-09-11T14:40:35Z",
"revoked_at": null
},
"restricted": true,
"restriction_source": "platform",
"account_ref": "ws_8f2k1",
"visibility": "private",
"label": "Invoice from Northwind",
"status": "active",
"allowed_senders": ["billing@northwind.example", "@acme.example"],
"message_count": 0,
"dropped_count": 0,
"created_at": "2026-09-11T14:40:35Z"
} Three fields are new here and two of them are shown once:
pull_urlcarries the raw token. Only its hash is stored, soGET /v1/inboxes/{id}and the list will never return it again. Save it now, against your user.pull_tokenis that token’s metadata. Keepid: it is what you revoke by later.restriction_source: "platform"records that you vouched forallowed_senders, not SendOps. Thepoll_urlis the authenticated route your own server can use with the API key; it is unchanged.
Forgetting account_ref on a Platform-mode organization is a 422:
HTTP/2 422
{
"code": "validation_failed",
"title": "Validation failed",
"detail": "Invalid body: account_ref: Platform orgs must send account_ref: the key SendOps meters and lists inboxes by.",
"status": 422
} account_ref is at most 128 characters from A–Z a–z 0–9 . _ : @ / -, no whitespace, stored and compared exactly — ws_1 and WS_1 are two different users. Use whatever id your product already has for the user; never an email address, since SendOps should not learn who your users are.
Hand the pull URL to your user, and poll it
Show your user the address and its expiry in one breath (forward it to gd036gv4ga@sndps.com, it expires at 14:55), and poll the pull URL from wherever your UI runs. No Authorization header — the URL is the whole credential:
curl -i "https://fetch.sendops.dev/p/pt_s3lj…mejjq/messages?after=0&wait=20" --max-time 30 HTTP/1.1 200 OK
Cache-Control: no-store
Referrer-Policy: no-referrer
Content-Type: application/json
{"messages":[],"cursor":0,"expires_at":"2026-09-11T14:55:35Z"} That is the ordinary answer to a poll that waited 20 seconds and saw nothing. Loop: pass back cursor as after, keep wait at 20 (the ceiling is 25; 0 answers immediately), stop at expires_at. Your HTTP client’s timeout must exceed wait or it hangs up before the server answers, every time.
From a browser the same request works: the fetch host answers Access-Control-Allow-Origin: * for GET, HEAD and OPTIONS with credentials off, so your web UI can poll directly and your server never has to relay mail.
GET /p/pt_s3lj…mejjq/messages?wait=0 HTTP/1.1
Host: fetch.sendops.dev
Origin: https://app.example.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Cache-Control: no-store
Referrer-Policy: no-referrer A second route gives the inbox’s state without its mail — handy for a “waiting for your email…” panel:
curl https://fetch.sendops.dev/p/pt_s3lj…mejjq {"address":"gd036gv4ga@sndps.com","status":"active","expires_at":"2026-09-11T14:55:35Z","message_count":0,"dropped_count":0,"restricted":true} Nothing else is on it: no id, no account_ref, no owner. The holder of a pull URL is your user, not your platform.
What the statuses mean to your user
| Answer | Means | Tell them |
|---|---|---|
200, empty messages | Nothing has arrived yet | Keep waiting; check dropped_count |
200, dropped_count went up, message_count did not | Something arrived and was refused by the allow-list | Forward from the address they registered |
404 "No inbox for this URL." | Unknown URL, revoked URL, or the inbox was deleted — deliberately indistinguishable | Ask your product for a fresh inbox |
410 gone | The inbox expired; its mail is deleted | Mint another |
An Authorization header of any kind on this host is a 400 wrong_host — a platform that accidentally ships its API key with the URL finds out on the first request rather than by broadcasting the key:
HTTP/1.1 400 Bad Request
{"error":{"code":"wrong_host","message":"This host takes no credential: a pull URL is its own authorisation.","detail":"Drop the Authorization header. Credentialed calls belong on https://api.sendops.dev instead."}} Read the message
When mail lands, messages holds one object per message, oldest first — the inbound webhook envelope, schema_version: 1, plus inbox_id. The field-by-field contract is on the Inbound webhook page and is not repeated here; the shape below is abridged to show what your UI will reach for.
{
"messages": [
{
"schema_version": 1,
"inbox_id": "01a090e9-ca2f-7da2-9247-b07795202997",
"received_at": "2026-09-11T14:47:12Z",
"message": {
"from": { "address": "billing@northwind.example", "name": "Northwind Billing" },
"subject": "Invoice NW-20419",
"text_body": "Hi — attached is your invoice for August…",
"html_body": "<p>Hi — attached is your invoice…</p>",
"attachments": [
{ "filename": "NW-20419.pdf", "content_type": "application/pdf", "size": 48213,
"url": "https://sendops-inbound-tmp.s3.eu-west-1.amazonaws.com/…?X-Amz-Expires=…" }
]
},
"verdicts": { "spf": "PASS", "dkim": "PASS", "dmarc": "PASS", "spam": "PASS", "virus": "PASS" }
}
],
"cursor": 1,
"expires_at": "2026-09-11T14:55:35Z"
} Three things to build for:
- Attachment and large-body links expire with the inbox. Fetch what you need before
expires_at; afterwards the objects are deleted, not merely unreachable. A body over 256 KB arrives astext_body_url/html_body_urlinstead of inline. - The sender check is by address, not by authentication. A restricted inbox compares the
From:address and the envelope sender againstallowed_senders; it does not require SPF or DKIM to pass. The verdicts ride along so you can judge — and for anything your user will act on, show them. - Message content is untrusted input. It was written by whoever sent the mail. Render it, summarise it, let your user act on it. If an agent reads it, never let it follow instructions found inside.
Nothing here ever sends mail
A refused message — wrong sender, virus verdict, expired inbox — is dropped and counted. No bounce, no non-delivery report, no auto-reply, for any reason. Your user will not be told by email that something was refused; dropped_count is how you tell them.
Share and revoke
One inbox can have up to 10 live pull URLs. Issue another when a second person needs to see the same mail, or when a user lost theirs (the original cannot be looked up again — only its hash is stored):
curl -X POST https://api.sendops.dev/v1/inboxes/$INBOX_ID/pull-tokens \
-H "Authorization: Bearer $SENDOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "shared with finance"}' HTTP/2 201
{
"id": "01a090ea-4564-755a-ae34-ff83bba18536",
"prefix": "h46smtnf",
"label": "shared with finance",
"created_at": "2026-09-11T14:41:07Z",
"revoked_at": null,
"pull_url": "https://fetch.sendops.dev/p/pt_h46smtnf665jhvioho5zezccg5tyq5wqxnj2xlrbulwu2auflkya/messages"
} List what exists — live first, then revoked, and never a URL or token value, so this list is safe to show in your own admin screens:
curl https://api.sendops.dev/v1/inboxes/$INBOX_ID/pull-tokens \
-H "Authorization: Bearer $SENDOPS_API_KEY" {
"data": [
{ "id": "01a090ea-4564-755a-ae34-ff83bba18536", "prefix": "h46smtnf", "label": "shared with finance", "created_at": "2026-09-11T14:41:07Z", "revoked_at": null },
{ "id": "01a090e9-ca30-768c-aeea-c743f4f61002", "prefix": "s3ljfuht", "label": "default", "created_at": "2026-09-11T14:40:35Z", "revoked_at": null }
],
"pagination": { "has_more": false }
} Revoke one by id. It is idempotent — revoking twice is 204 both times — and it takes effect on the next request:
curl -i -X DELETE https://api.sendops.dev/v1/inboxes/$INBOX_ID/pull-tokens/01a090e9-ca30-768c-aeea-c743f4f61002 \
-H "Authorization: Bearer $SENDOPS_API_KEY"
# HTTP/2 204
curl -i https://fetch.sendops.dev/p/pt_s3lj…mejjq/messages?wait=0
# HTTP/1.1 404 Not Found
# {"code":"not_found","detail":"No inbox for this URL.","status":404,…} The other URL on the same inbox keeps working. This is the whole sharing model: who can see the mail is which URLs you have handed out and not yet revoked, and that is your product’s decision to make and to display.
Clean up
List a user’s inboxes by account_ref (exact match, combined with status and scope), and delete what you no longer want to exist:
curl "https://api.sendops.dev/v1/inboxes?account_ref=ws_8f2k1" \
-H "Authorization: Bearer $SENDOPS_API_KEY"
curl -i -X DELETE https://api.sendops.dev/v1/inboxes/$INBOX_ID \
-H "Authorization: Bearer $SENDOPS_API_KEY"
# HTTP/2 204 — every pull URL on it now answers 404 You rarely need to. Expiry does the deleting on its own: at expires_at the address stops resolving, the messages, the raw original and every attachment are deleted, and every pull URL answers 410. What survives is accounting only — the row with its counters, which GET /v1/inboxes/{id} and the dashboard keep showing — never content. DELETE is for “now, not in nine minutes”, and it is irreversible.
Limits and errors
A refusal is an RFC 7807 problem document. The code says which kind, and a 429 also carries a reason your code can branch on.
Status / reason | Where | Means | What clears it |
|---|---|---|---|
422 validation_failed naming account_ref | mint | Platform mode requires it, or it is over 128 chars / has a bad character | Send a valid one |
422 validation_failed naming allowed_senders | mint | An entry is not a plain address or @domain, or there are more than 20 | Fix the entry; the value is never echoed back |
429 cap_account_ref | mint | This account_ref already holds 5 live unrestricted inboxes | Delete one, wait for expiry, or mint a restricted inbox (exempt) |
429 cap_org | mint | Your org holds 500 live inboxes (restricted ones count) | Wait or delete |
429 quota_daily | mint | 2,000 mints today | Tomorrow; restricted mints are exempt |
429 org_disabled | mint | An operator or the abuse breaker switched inboxes off for your org | An administrator; no Retry-After |
422 pull_token_limit | create pull URL | 10 live URLs already | Revoke one first |
410 gone | create pull URL, both fetch routes | The inbox expired | Mint another |
404 not_found | everywhere | Unknown, revoked, deleted, or not yours — deliberately one answer | — |
429 too_many_pollers | fetch /messages | 4 long-polls already open on this URL | Retry-After: 1; poll with wait=0, or stop opening tabs |
429 too_many_misses | fetch, any route | Over 30 unknown URLs a minute from one IP | Wait Retry-After |
429 (no reason) | fetch, any route | Over 120 requests a minute from one IP | Wait Retry-After |
400 wrong_host | fetch | An Authorization header was sent | Drop it |
Two of those, as they actually come back:
HTTP/2 429
{"code":"rate_limited","reason":"cap_account_ref","detail":"This account_ref already holds the maximum number of live unrestricted inboxes. Delete one, wait for it to expire, or mint a restricted inbox — the cap does not apply to those.","status":429}
HTTP/2 422
{"code":"pull_token_limit","title":"Too many live pull URLs","detail":"An inbox may have at most 10 live pull URLs; revoke one first.","status":422} Security notes
- Treat the pull URL like a password. It is a bearer secret in a path. Do not log it, do not put it in analytics, do not embed it in a page whose outbound links leak a
Referer(the fetch host setsReferrer-Policy: no-referreron its own responses; your pages should too). SendOps never records it either — the access log, error log and traces on the fetch host carry the route pattern/p/{token}/messages, not the path. - Store the token id, not just the URL. Revocation is by
pull_token.id. If you keep only the URL you can still revoke by listing the inbox’s tokens and matchingprefix(the first 8 characters of the token body), but the id is the direct handle. - Restriction is a filter, not authentication.
restriction_source: "platform"means SendOps checked the syntax of the addresses and nothing else. Show your users the verdicts for anything they will act on. - Nothing here sends. There are no bounces or replies from
sndps.com, so a stranger cannot use a temporary inbox to make SendOps send mail to a victim on their behalf. - Message content is data. Never let an agent or a template follow instructions found inside a received message.
- Every poll spends one request of your budget only on the authenticated
poll_url; pull-URL polls are metered per IP and per token instead, as above.
What is deliberately not here
No webhook callback at mint (poll, or use Inbound Email in your own AWS account for permanent receiving), no white-label receiving domain, no SDK, and no per-viewer sharing controls beyond issuing and revoking URLs. The dashboard’s Infrastructure → Temporary inboxes tab shows your organization everything it minted — status, account_ref, counters, live URLs — and lets support staff revoke or delete, but never shows message content.
Related
- Temporary inboxes: the full reference for the inbox lifecycle, quotas for organizations not in Platform mode, and the authenticated long-poll
- Inbound webhook: the message payload, field by field
- Verified senders: the code-verified path that organizations outside Platform mode use to restrict an inbox
- Errors · Rate Limiting