# Mint a temporary inbox

`POST /v1/inboxes`

- Authentication: required (Bearer token)
- Required scope: `api.inboxes.manage`

Allocates a short-lived receiving address and returns it immediately.
Give the address to whoever is forwarding the mail, then long-poll
`poll_url`. When the inbox expires the address stops resolving and
everything stored against it — parsed messages, the raw original,
every staged attachment — is deleted.

A body is optional. `POST /v1/inboxes` with no body at all mints a
private, unrestricted inbox that lives for ten minutes, which is the
shortest useful call here.

**The expiry is final.** `ttl` is 60 to 3600 seconds and cannot be
extended afterwards: `PATCH` does not accept it, and there is no
renew. If you need longer, mint another inbox.

**Restricted inboxes are not metered.** Pass `allowed_senders`
containing only addresses you have verified (see
`POST /v1/verified-senders`) or `@yourdomain.com` for a domain your
org owns, and the inbox becomes `restricted: true`: mail from anybody
else is dropped and counted in `dropped_count`, never bounced. A
restricted inbox cannot receive a stranger's signup confirmation, so
it is exempt from the mint quota and the per-credential cap. An entry
that is neither verified nor a domain you own is a `422` naming the
field, not a silently dropped entry.

**Metering, when the inbox is NOT restricted.** An org that has
connected its own AWS account gets 30 mints an hour and 200 a day; one
that has not gets 3 a day. There is also a cap on live inboxes: 5 per
credential (unrestricted only) and 25 per org (always). A refusal is a
`429` whose `reason` says which limit, so a client can tell "wait an
hour" from "verify a sender" from "an administrator switched this off".

**Ownership and visibility.** The inbox belongs to the credential that
minted it — an API key or an OAuth client, or the person behind a
user-delegated token — and is `private` unless you say otherwise.
Another member of the same org reading a private inbox gets `404`, not
`403`: a `403` would confirm the id exists.

**Every mint also returns a pull URL.** `pull_url` is an address on
the fetch host that reads THIS INBOX'S MAIL WITH NO CREDENTIAL AT ALL —
no API key, no token, no session. That is what it is for: mint an inbox
for one of your own users and give them the link, and they never need a
SendOps account, because you hold the only credential. It is read-only
— it reads status and messages and can neither delete the inbox nor
change it — but whoever holds it reads the mail, so treat it as the
secret it is and put it somewhere only that user can see. Revoking the
token is how you take that back: `pull_token.id` names it, and
`DELETE /v1/inboxes/{id}/pull-tokens/{token_id}` cuts it off without
touching the inbox. `POST /v1/inboxes/{id}/pull-tokens` issues more, up
to ten live at a time.

**`pull_url` is returned once, here.** Only the token's hash is stored,
so `GET /v1/inboxes/{id}` and the list carry neither `pull_url` nor
`pull_token` — there is nothing for them to return. Lost it? Issue
another and revoke the old one.

**`account_ref` is your key, not ours.** Pass whichever id your own
product uses for the user this inbox is for. SendOps stores it, counts
caps per value, and lets you filter `GET /v1/inboxes?account_ref=` by
it — and interprets it in no other way. Optional here; REQUIRED for an
org in Platform mode, whose caps are counted per `account_ref` rather
than per credential.

**PII.** The address is real and the mail it receives is real. See
`api.inboxes.view` for what the messages route returns.

## Request body

Content type: `application/json`

```json
{
  "ttl": 600,
  "label": "string",
  "visibility": "private",
  "allowed_senders": [
    "dana@example.com",
    "@acme.com"
  ],
  "account_ref": "ws_123"
}
```

## Example request

```bash
curl -X POST 'https://api.sendops.dev/v1/inboxes' \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttl":600,"label":"string","visibility":"private","allowed_senders":["dana@example.com","@acme.com"],"account_ref":"ws_123"}'
```

## Responses

### 201 — The inbox, including the address to hand out and the URL to poll

Content type: `application/json`

```json
{
  "id": "00000000-0000-0000-0000-000000000000",
  "address": "k7q2m9xv4p@sndps.com",
  "expires_at": "2026-05-17T20:00:00Z",
  "poll_url": "https://example.com",
  "pull_url": "https://fetch.sendops.dev/p/pt_ab3k…/messages",
  "pull_token": {
    "id": "00000000-0000-0000-0000-000000000000",
    "prefix": "pt_ab3kd9",
    "label": "string",
    "created_at": "2026-05-17T20:00:00Z",
    "revoked_at": "2026-05-17T20:00:00Z"
  },
  "restricted": true,
  "restriction_source": "verified",
  "account_ref": "ws_123",
  "visibility": "private",
  "label": "string",
  "status": "active",
  "allowed_senders": [
    "string"
  ],
  "message_count": 0,
  "dropped_count": 0,
  "created_at": "2026-05-17T20:00:00Z"
}
```

### 401 — Missing, malformed, or unknown API key

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 403 — Either the credential lacks the required scope (`code: invalid_scope`), or it is bound to the `test` environment and this operation is irreversible (`code: test_environment_forbidden`). Branch on `code`: the first is fixed by granting the scope, the second only by using a live credential. See the "Live and test credentials" section of the API description.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 422 — A query parameter, path value or body field failed validation.

The body is a `validation_failed` Problem. When the refusal is about a
reference — a segment key, template slug, topic or attribute name the
organization does not have — it additionally carries `validation_code`,
`field`, `line`/`column`, `missing`, `candidates` and `next_step`, so a
client can correct the call without a second round of guessing. See
`ValidationProblem`.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ],
  "validation_code": "invalid_syntax",
  "field": "string",
  "line": 0,
  "column": 0,
  "missing": [
    {
      "kind": "segment",
      "key": "string"
    }
  ],
  "candidates": {},
  "next_step": "string"
}
```

### 429 — A mint limit refused the request. Nothing was created.

The `reason` extension says WHICH limit, and the values need different
reactions:

- `quota_hourly` — the org's hourly mint allowance. Clears itself;
  honour `Retry-After`.
- `quota_daily` — the org's daily allowance. An org that has not
  connected its own AWS account gets three unrestricted mints a day.
  Waiting until tomorrow works; VERIFYING A SENDER and passing it in
  `allowed_senders` works now, because a restricted inbox is not
  metered.
- `cap_user` — this credential already holds the maximum live
  unrestricted inboxes. Delete one, wait, or mint a restricted one.
  Standard orgs only.
- `cap_account_ref` — the Platform-mode twin of `cap_user`: THIS
  `account_ref` already holds the maximum live unrestricted inboxes,
  counted per partition key rather than per credential, so every other
  `account_ref` in the org is unaffected. Delete one of that user's
  inboxes, wait for one to expire, or mint a restricted one. Platform-
  profile orgs only.
- `cap_org` — the org already holds the maximum live inboxes. Applies
  to restricted inboxes too.
- `org_disabled` — an operator or the abuse breaker switched temporary
  inboxes off for this org. **NO `Retry-After` IS SENT, deliberately**:
  retrying will not help and a nominal value would be a lie that
  produced a hot-retry loop. Talk to an administrator. Inboxes already
  minted keep receiving until they expire.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 500 — Unexpected server-side failure. The `code` is `internal_error`. The
`request_id` field can be quoted to SendOps support to investigate.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```
