Skip to content
IndSec Temp Mail

Developer API

The API exposes the same operations the web inbox uses. Create an inbox, then authenticate with the token it returns to list and read messages, extract verification codes, extend the lifetime, or delete everything. Automated clients can identify themselves with a developer API key to get their own quota instead of sharing the per-IP limit.

Authentication

Creating an inbox needs no credentials. Every other inbox operation requires the token returned at creation, sent as a bearer credential:

Authorization: Bearer <inbox_token>

The token is returned exactly once, at creation. It is stored only as a hash, so it cannot be recovered.

Response envelope

Every response uses the same shape. Check success before reading data.

{
  "success": true,
  "data": { },
  "meta": { }
}
{
  "success": false,
  "error": {
    "code": "validation_failed",
    "message": "That domain is not available.",
    "details": [ { "path": "domain", "message": "Unknown domain." } ]
  }
}

Developer API keys

Anonymous requests are limited per IP address, which means every client behind one NAT shares a bucket. An API key gives a client its own bucket and a daily quota, so automated use does not compete with browser traffic.

X-API-Key: tmk_<64 hex characters>

The free plan does not include a key. There are two ways to get one: delegate your own domain and verify it, which earns a 20,000 requests/day key at no cost, or wait for a paid plan to open.

A key is shown once and stored only as a hash. Supplying an invalid key returns 401 rather than falling back to anonymous access, so a broken integration fails visibly.

Rate limits

Every route is limited to 120 requests per minute per IP address, including reads, so a polling loop cannot run unbounded. Inbox creation has a tighter per-endpoint limit on top of that. Limited responses return HTTP 429 together with Retry-After,X-RateLimit-Limit, X-RateLimit-Remaining andX-RateLimit-Reset. Requests made with a key also returnX-Quota-Limit and X-Quota-Remaining, and the daily quota resets at UTC midnight. A request carrying a valid key is charged against the key's own quota rather than the shared per-IP bucket.

These limits fail open: if the limiter's own storage is unavailable, requests are served rather than refused. Rate limiting should never be the reason the site is down.

API keys

POST/api/v1/keys

Issue a developer API key. Requires the operator admin secret; this is not self-service.

  • Send the admin secret as `X-Admin-Secret: <secret>` or `Authorization: Bearer <secret>`.
  • Body: `{ "label": "ci-pipeline", "dailyLimit": 1000 }`. Both fields are validated.
  • The raw key is returned **once** in this response and cannot be recovered.
  • When `ADMIN_SECRET` is unset on the deployment, this returns 403 rather than allowing open issuance.
curl -X POST https://your-host/api/v1/keys \
  -H "X-Admin-Secret: $ADMIN_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"label":"ci","dailyLimit":1000}'

# 201
# { "success": true,
#   "data": { "id": "key_...", "label": "ci", "dailyLimit": 1000,
#             "key": "tmk_..." } }
GET/api/v1/keys

List API keys with their current daily quota state. Admin only.

  • Key hashes are never included in the response.
  • A key whose quota date has rolled over reports zero usage.
DELETE/api/v1/keys/{id}

Revoke an API key. Admin only.

  • Revocation is immediate: the next request using that key is refused.
  • The row is retained with a revocation timestamp, so an audit trail survives.

Inboxes

POST/api/v1/inboxes

Create an inbox. Returns the address, its id, and a one-time access token. Rate limited per IP address, or per API key when one is supplied.

  • All body fields are optional.
  • `prefix` requests a custom local part; it is rejected if already taken.
  • `domain` must be one of the configured public domains.
  • `ttlMinutes` is clamped server-side to the deployment maximum.
  • The raw `token` is returned only in this response and cannot be recovered later.
  • Send `X-API-Key` to use a per-key rate-limit bucket and daily quota instead of the shared per-IP limit.
  • An invalid key is rejected with 401; it is never silently downgraded to anonymous access.
  • Responses using a key include `X-Quota-Limit` and `X-Quota-Remaining`.
curl -X POST https://your-host/api/v1/inboxes \
  -H 'Content-Type: application/json' \
  -d '{"prefix":"signup-test"}'

# with an API key
curl -X POST https://your-host/api/v1/inboxes \
  -H "X-API-Key: $TEMPMail_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{}'

# 201
# { "success": true,
#   "data": { "id": "inb_...", "address": "signup-test@example.com",
#             "token": "…", "expiresInSeconds": 3600 } }
GET/api/v1/inboxes/{id}

Read inbox state, including the remaining lifetime in seconds.

  • Requires `Authorization: Bearer <token>`.
DELETE/api/v1/inboxes/{id}

Delete the inbox and every message in it.

  • Requires the inbox token.
  • Deletion is immediate and permanent.
POST/api/v1/inboxes/{id}/extend

Extend the inbox lifetime, clamped to the deployment maximum.

  • Body: `{ "minutes": 60 }`. Defaults to 60 when omitted.
  • Requires the inbox token.
POST/api/v1/inboxes/{id}/share

Enable or revoke a public share link. Enabling returns a new share token exactly once.

  • Body: `{ "enabled": true }` or `{ "enabled": false }`.
  • While enabled, anyone with the share token can READ the inbox without an account.
  • A share token cannot delete messages, clear the inbox, extend it, re-share it, or delete it. Those require the owner token.
  • Disabling clears the share token and makes the inbox private again.
GET/api/v1/share/{token}

Resolve a share link (or owner token) to the inbox it points at. This is the entry point for `/inbox/#t=<token>` links.

  • Returns the same inbox object as `GET /api/v1/inboxes/{id}`, plus `via` reporting which credential matched (`token` or `share`).
  • A recipient holding only a share token has no inbox id, so this call is how they learn it. Every other inbox route is id-scoped.
  • Malformed tokens are refused before any database work. Expired inboxes return `410 gone`, never an empty success.
curl https://your-host/api/v1/share/<share_token>

# { "success": true,
#   "data": { "id": "inb_...", "address": "braveotter482@indsec.info",
#             "visibility": "public", "expiresInSeconds": 3600,
#             "via": "share" } }

Messages

GET/api/v1/inboxes/{id}/messages

List live messages, newest first. Each entry includes a text preview and any extracted verification codes.

  • Expired messages are excluded by the query, not filtered afterwards.
  • `?limit=` accepts 1-100 and defaults to 50.
  • Response `meta` carries `count` and the inbox expiry.
DELETE/api/v1/inboxes/{id}/messages

Clear the inbox. Returns the number of messages removed.

  • Requires the inbox token.
GET/api/v1/inboxes/{id}/messages/{messageId}

Read one message in full: sanitised HTML, plain text, extracted codes and links, and attachment metadata.

  • HTML is sanitised server-side and is safe to render only inside a sandboxed frame.
  • Expired or foreign message ids return 404 rather than 403, so ids cannot be probed.
DELETE/api/v1/inboxes/{id}/messages/{messageId}

Delete a single message.

  • Scoped to the inbox: an id from another inbox returns 404.
GET/api/v1/inboxes/{id}/messages/wait

Long-poll for the next message. Returns as soon as mail arrives, or after `timeout` seconds with `received: false`.

  • Call as `/api/v1/inboxes/{id}/messages/wait?timeout=30&since=<id-or-timestamp>`.
  • `timeout` is 1–30 seconds; anything outside that range is a 422 rather than being silently clamped.
  • `since` may be a message id or a unix timestamp. With an id, anything newer than that message is returned. Omitted, an inbox that already has mail answers immediately and an empty one waits.
  • Cheaper than polling on the free plan: sleeping inside the request is not billed as CPU time, so one wait costs one request instead of thirty.
  • Re-checks inbox expiry on every iteration, so a wait cannot outlive the inbox it is watching.
  • Response is `Cache-Control: no-store` — a cached negative would tell a client nothing ever arrives.
curl "https://your-host/api/v1/inboxes/INBOX_ID/messages/wait?timeout=30" \
  -H "Authorization: Bearer $INBOX_TOKEN"

# { "success": true,
#   "data": { "received": true, "message": { "id": "msg_...", ... } } }
GET/api/v1/inboxes/{id}/messages/{messageId}/raw

Download the original RFC 5322 message as `.eml`.

  • Requires the inbox token, and the message must still be live.
  • Only available when the deployment binds an R2 bucket: the D1 row stores sanitised bodies, not the original bytes. Without a bucket this returns 404 with an explanation, not a 500.
  • Returned as `message/rfc822` with a filename derived from the subject, with every character outside `[a-zA-Z0-9 _-]` replaced.

Domains

GET/api/v1/domains

List the domains available for address generation.

  • Public. No authentication required, and cached for 60 seconds.
  • Includes built-in domains plus any customer domain that has verified.
  • A private domain is never listed: only its owner may create addresses under it.
POST/api/v1/domains

Submit a domain for nameserver delegation. Returns what to configure at the registrar and what the verifier checks.

  • Body: `{ "domain": "example.com", "kind": "public" }`. `kind` defaults to `public`.
  • Apex domains only. `mail.example.com` is refused, because the delegation has to happen at the apex.
  • Rate limited to 5 submissions per IP per hour.
  • Submitting grants nothing on its own: the reward API key is issued only after delegation verifies.
  • `kind: "public"` joins the shared address pool, so anyone may use addresses under it. `kind: "private"` restricts it to its owner.
  • Returns 403 when the deployment has no `MAIL_MX_TARGET`, because no domain could ever verify.
curl -X POST https://your-host/api/v1/domains \
  -H 'Content-Type: application/json' \
  -d '{"domain":"example.com","kind":"public"}'

# 201
# { "success": true,
#   "data": { "domain": { "domain": "example.com", "status": "disabled", ... },
#             "next": { "mxTarget": "email.your-host", "note": "..." } } }
POST/api/v1/domains/verify

Re-check one submitted domain immediately, instead of waiting for the scheduled sweep.

  • Call as `/api/v1/domains/verify?domain=example.com`.
  • Performs the same state transition the cron does — this is not a dry run.
  • Rate limited to 20 checks per IP and 10 per domain, per hour.
  • Reports the two signals separately: `nsDelegated` (nameservers) and `mailPointsHere` (MX). A domain mid-migration fails one and passes the other.
  • A lookup that could not run at all (network, 5xx) changes no state, so a resolver outage cannot disable domains.
  • On the transition into `active`, the reward API key is returned **once** in this response.
DELETE/api/v1/domains/{domain}

Release a submitted domain. Admin only, until accounts exist.

  • Built-in domains can never be released: they ship with the deployment.
  • Revokes any API key the domain was awarded.
  • Unknown, built-in and not-yours all return 404, so the endpoint cannot be used to probe which domains exist.

Accounts

GET/api/v1/auth/{provider}/start

Begin an OAuth sign-in. Redirects to GitHub or Google. `provider` is `github` or `google`.

  • Returns 404 when that provider has no credentials configured, rather than redirecting with an empty client id.
  • A single-use state nonce is created in D1 and consumed by the callback, so a replayed callback fails.
  • No API key or inbox token is involved: this is a browser navigation, not an API call.
GET/api/v1/auth/{provider}/callback

OAuth callback. Sets the session cookie and redirects to `/account`.

  • Verifies the state nonce BEFORE exchanging the code, so a replayed callback costs nothing.
  • Requires the state to have been issued for the same provider, so a GitHub nonce cannot finish a Google flow.
  • Google `id_token`s are verified with RS256 pinned against Google's JWKS, with issuer, audience and expiry checked.
  • The session cookie is `HttpOnly; Secure; SameSite=Lax` — Lax rather than Strict because the callback arrives by cross-site redirect.
  • Any failure redirects to `/signin?error=oauth_failed` rather than returning JSON, because the user is mid-navigation.
POST/api/v1/auth/signout

End the current session.

  • Deletes the session row server-side AND expires the cookie. Clearing only the cookie would leave a token that still works.
  • Idempotent: signing out twice does not fail.
GET/api/v1/account

The signed-in user's account, effective plan, limits and usage.

  • Requires a session; returns 401 for an anonymous caller.
  • Reports the EFFECTIVE plan and the stored `storedPlanId` separately — they differ only when an entitlement has expired, which is the case a support question turns on.
  • Never exposes a session token or a token hash.
PATCH/api/v1/admin/users/{id}/plan

Set a user plan. Admin only — this is how plans are granted until a payment provider exists.

  • Body: `{ "planId": "pro", "source": "admin", "expiresAt": 1790000000 }`. `planId` is required; the rest are optional.
  • An unknown `planId` is rejected with 422 rather than silently defaulting to free, so an operator typo cannot downgrade an account.
  • `source` defaults to `admin`. A real payment webhook will write `stripe`, and the field is what makes the two kinds of entitlement distinguishable later.
  • Omitting `expiresAt` means the entitlement does not lapse.

Service

GET/api/v1/stats

Coarse aggregate counters: active inboxes and live messages.

  • Contains no addresses and no message content.
  • Cached briefly at the edge; treat as decorative rather than exact.
GET/api/v1/health

Liveness probe.

  • Public, and deliberately exempt from the IP allowlist so an operator who locks themselves out can still confirm the Worker is running.
  • Returns `{ "status": "ok", "timestamp": "..." }` with `Cache-Control: no-store`.
GET/api/v1/image

Proxy a remote image referenced by an email, so opening a message does not reveal the reader's IP to the sender.

  • Call as `/api/v1/image?u=<url-encoded absolute URL>`.
  • Loopback, private, link-local and cloud-metadata hosts are refused (SSRF guard).
  • Responses are capped at 2 MB and only known image content types are returned.
  • Returns raw image bytes, not the JSON envelope.

Error codes

CodeStatusMeaning
bad_request400Malformed request
unauthorized401Missing or invalid token
not_found404Inbox or message does not exist
gone410Inbox has expired
payload_too_large413Request exceeds a size limit
validation_failed422Field validation failed
rate_limited429Too many requests; see Retry-After
internal_error500Unexpected server failure