Mail API
NULL mail is a disposable email service at https://mail.nullnet.sh. You create an inbox, receive mail at it, read the mail over HTTP or a WebSocket, and the inbox expires on its own. The web app does everything through the public HTTP API at https://mail.nullnet.sh/v1, so your own code can do the same.
You can use inboxes without an account. An anonymous session token is enough. If you want inboxes that last longer, more inboxes, or premium features, create an account. Accounts are anonymous too: an account is a random account code plus a password. There is no email address, phone number or name on file, and no way to reset a password by email.
The machine-readable contract is the OpenAPI 3 document at /v1/openapi.json. It is generated from the handlers.
Conventions
- Base URL:
https://mail.nullnet.sh/v1. Request and response bodies are JSON (Content-Type: application/json). - Ids (inboxes, messages, webhooks, API keys, domains, sessions) are UUID strings.
- Timestamps are RFC 3339 strings in UTC, for example
2026-10-26T08:00:00Z. - Sizes are integers in bytes. Durations are integers in seconds. The field name gives the unit (
size_bytes,retention_secs). - Money is USD as a decimal string with 18 fractional digits, for example
"4.990000000000000000". The server never uses floating point. See Billing. - If an endpoint's body is optional, you can send no body, an empty body or the JSON literal
null. - An inbox or message that belongs to someone else returns
404, never403, so ids cannot be probed.
Authentication
There are three ways to call the API. A few endpoints need no credentials at all: GET /v1/domains, GET /v1/inboxes/available, GET /v1/billing/plans, GET /v1/billing/assets and GET /v1/passes/public-key.
- Name
Anonymous session- Type
- Authorization: Bearer
- Description
POST /v1/sessionreturns a session token. Send it asAuthorization: Bearer <token>. The session owns the inboxes it creates. Nobody else can reach them. If you lose the token, you lose the inboxes.
- Name
Account session- Type
- Authorization: Bearer
- Description
POST /v1/accounts(register) orPOST /v1/auth/loginreturns the token of a logged-in session. You send it the same way. Inboxes created while logged in belong to the account and are visible from every session of that account. The account, billing, referral, webhook, custom-domain and API-key endpoints need this kind of session.
- Name
API key- Type
- X-API-Key
- Description
Premium accounts can create API keys (
grk_…) and send them asX-API-Key: <key>. A key acts for its account on the inbox, message and paste-pass endpoints only. A key has areadscope (GET,HEAD) and awritescope (every other method). See API keys.
Session tokens are 43-character base64url strings. A session lasts 30 days, and the expiry moves forward while the session is in use. Never put a token in a URL. The WebSocket also takes the token in its first message, not in the URL.
If a request carries both headers, the bearer token is used. A missing, expired or revoked token returns 401 unauthorized.
Authenticated requests
curl https://mail.nullnet.sh/v1/inboxes \
-H "Authorization: Bearer {token}"
curl https://mail.nullnet.sh/v1/inboxes \
-H "X-API-Key: {api_key}"
Tiers and limits
Every request resolves to one tier. The limits come from server configuration. Premium tiers are more generous because their configured numbers are higher. API responses use these tier names:
- Name
anon_ip- Description
No credentials. Limits are counted per client IP.
- Name
anon_session- Description
An anonymous session token.
- Name
free_account- Description
A logged-in account without an active premium subscription.
- Name
premium_account- Description
A logged-in account with an active premium subscription.
- Name
premium_api_key- Description
An API key of a premium account. Limits are counted per key.
These are the current production limits for the tiers that can own inboxes:
| Limit | anon_session | free_account | premium_account | premium_api_key |
|---|---|---|---|---|
| API requests per minute (burst) | 60 (20) | 120 (40) | 1,200 (200) | 3,000 (500) |
| Live inboxes at once | 3 | 5 | 200 | 500 |
| New inboxes per hour | 10 | 30 | 500 | 1,000 |
| Inbox retention | 1 hour | 24 hours | 30 days | 30 days |
| Messages kept per inbox | 50 | 200 | 10,000 | 10,000 |
| Storage per inbox | 25 MiB | 100 MiB | 2 GiB | 2 GiB |
| Largest accepted message | 10 MiB | 10 MiB | 50 MiB | 50 MiB |
| Inbound messages per inbox per hour | 100 | 300 | 5,000 | 5,000 |
| Address availability checks per minute | 10 | 20 | 120 | 300 |
| Attachment downloads per hour | 120 | 300 | 5,000 | 10,000 |
| Raw and export downloads per hour | 20 | 60 | 1,000 | 2,000 |
| Full-text searches per minute | – | 10 | 120 | 300 |
| WebSocket connections per session | 2 | 3 | 20 | – |
| Subscriptions per WebSocket | 3 | 5 | 200 | – |
| Webhook endpoints | – | – | 20 | – |
| Custom domains | – | – | 10 | – |
| API keys | – | – | 10 | – |
A dash means the feature is not offered at that tier, or is not reachable with that credential: the WebSocket accepts session tokens only, and webhooks, custom domains and API keys are managed from a logged-in session.
Requests without credentials (anon_ip) get 30 requests per minute (burst 10) and 5 availability checks per minute. They cannot own inboxes. Login is limited to 10 attempts per hour per caller and also per account code. The SMTP receiver refuses any single message larger than 25 MiB, whatever the tier.
These numbers can change. GET /v1/account returns the resolved limits object for your account. In that object, an optional quota is null when the feature is not offered at your tier.
Limits for a logged-in account are counted per account, across all of its sessions. Registration, login, inbox creation and availability checks are also counted per client network (an IPv4 address, or an IPv6 /64) with the same numbers. Creating extra sessions does not buy more of them.
Rate limits and quotas
- Name
429 rate_limited- Description
A rate limit is used up. The response carries
Retry-Afterin seconds. Wait at least that long. Retrying sooner does not help. Every request also counts against a per-IP budget of 600 requests per minute, checked before authentication.
- Name
402 payment_required- Description
A count quota is full (live inboxes, API keys, webhooks, custom domains), or there is not enough credit for a purchase. Delete something, or upgrade. The message says which limit was hit.
- Name
403 tier_required- Description
The feature is not offered at your tier, for example search for an anonymous session or webhooks for a free account.
Responses that pass the tier limit carry X-RateLimit-Limit, the per-minute request limit of your tier.
Errors
Every error has the same body: an error object with a stable, machine-readable code and a human-readable message.
Error body
{
"error": {
"code": "unauthorized",
"message": "authentication required"
}
}
| Status | code | Meaning |
|---|---|---|
| 400 | bad_request | The body, a query parameter or a value is invalid. The message names the problem. |
| 401 | unauthorized | No credentials, or the token is unknown, expired or revoked. Also returned for an anonymous session on an endpoint that needs an account. |
| 401 | invalid_credentials | A login or password check failed. Unknown account code, wrong password and wrong recovery key all get this same answer. |
| 402 | payment_required | A count quota is full, or credit is short. |
| 403 | tier_required | The feature is not available at your tier. |
| 403 | forbidden | Authenticated but not allowed, for example an API key without the scope for this method. |
| 404 | not_found | The resource does not exist or is not yours. |
| 409 | conflict | The address or name is taken or reserved, or the session is already logged in. |
| 429 | rate_limited | A rate limit is used up. See Retry-After. |
| 500 | internal | Server error. No detail is returned. |
| 503 | not_ready | A server dependency is not ready. Retry later. |
| 503 | unavailable | A feature's backing service is not configured or not reachable (payments, paste passes). |
Pagination
Lists that can grow without bound (an inbox's messages, the credit ledger) use cursor pagination.
- Name
limit- Type
- integer
- Description
Page size. Default 25, clamped to 1–100.
- Name
cursor- Type
- string
- Description
Opaque cursor from the previous page's
next_cursor.
A page is an object with items (newest first) and next_cursor. next_cursor is null on the last page. A malformed cursor returns 400 bad_request. Other lists (inboxes, webhooks, API keys, deposits) are returned whole, as JSON arrays.
Page
{
"items": [],
"next_cursor": null
}
CORS
The API allows browser requests from other origins only if the origin is on the server's list. Today the list has one entry, https://paste.nullnet.sh. The mail web app is served from the same origin as the API and needs no CORS. CORS does not affect server-side clients.
Allowed methods are GET, POST, PATCH, DELETE and OPTIONS. Allowed request headers are Authorization, Content-Type and X-API-Key. The API uses bearer tokens only. It sets no cookies, and CORS never allows credentials.
List domains
The system domains you can create addresses on. No authentication. Premium accounts can also add their own domains; see custom domains.
Response
An array of objects.
- Name
name- Type
- string
- Description
Domain name.
Request
curl https://mail.nullnet.sh/v1/domains
Response
[
{ "name": "mail.nullnet.sh" },
{ "name": "nullnet.sh" }
]
Quick start
Create an inbox and read it
# 1. Anonymous session
TOKEN=$(curl -s -X POST https://mail.nullnet.sh/v1/session | jq -r .token)
# 2. Inbox with a random address
INBOX=$(curl -s -X POST https://mail.nullnet.sh/v1/inboxes \
-H "Authorization: Bearer $TOKEN")
echo "$INBOX" | jq -r .address # e.g. kqv-7f3m2a@mail.nullnet.sh
# 3. Send mail to that address, then list it
curl -s https://mail.nullnet.sh/v1/inboxes/$(echo "$INBOX" | jq -r .id)/messages \
-H "Authorization: Bearer $TOKEN"
Next: Accounts and sessions, Inboxes, Messages, and the WebSocket for live updates.