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


  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](/api-reference/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 |


  

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_senders` is accepted on syntax alone.** An inbox restricted to `billing@northwind.example` refuses 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](https://help.sendops.dev/ai-agents/platform-mode).

  
  

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.

```bash
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"]
  }'
```

```json
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_url`** carries the raw token. Only its hash is stored, so `GET /v1/inboxes/{id}` and the list will never return it again. Save it now, against your user.
- **`pull_token`** is that token's metadata. Keep `id`: it is what you revoke by later.
- **`restriction_source: "platform"`** records that *you* vouched for `allowed_senders`, not SendOps. The `poll_url` is 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`:

```json
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.

  
  

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:

```bash
curl -i "https://fetch.sendops.dev/p/pt_s3lj…mejjq/messages?after=0&wait=20" --max-time 30
```

```http
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.

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

```bash
curl https://fetch.sendops.dev/p/pt_s3lj…mejjq
```

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

```json
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."}}
```

  
  

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](/api-reference/inbound-webhook#the-payload) page and is not repeated here; the shape below is abridged to show what your UI will reach for.

```json
{
  "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": "Hi — attached is your invoice…",
        "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 as `text_body_url` / `html_body_url` instead of inline.
- **The sender check is by address, not by authentication.** A restricted inbox compares the `From:` address and the envelope sender against `allowed_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.


  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.


  
  

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

```bash
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"}'
```

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

```bash
curl https://api.sendops.dev/v1/inboxes/$INBOX_ID/pull-tokens \
  -H "Authorization: Bearer $SENDOPS_API_KEY"
```

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

```bash
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.

  
  

List a user's inboxes by `account_ref` (exact match, combined with `status` and `scope`), and delete what you no longer want to exist:

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

```json
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 sets `Referrer-Policy: no-referrer` on 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 matching `prefix` (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](/api-reference/inbound) 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](/api-reference/inboxes): the full reference for the inbox lifecycle, quotas for organizations not in Platform mode, and the authenticated long-poll
- [Inbound webhook](/api-reference/inbound-webhook): the message payload, field by field
- [Verified senders](/api-reference/verified-senders): the code-verified path that organizations outside Platform mode use to restrict an inbox
- [Errors](/api-reference/errors) · [Rate Limiting](/api-reference/rate-limiting)