Create an inbox, give its address to whatever sends the mail, then wait. The wait call returns the moment a message lands, with the code already extracted.
API=https://nrthmail.com/api/v1
# 1 · Create an inbox. The token is shown once: keep it.
NEW=$(curl -s -X POST $API/inboxes)
TOKEN=$(echo "$NEW" | jq -r .token)
SINCE=$(echo "$NEW" | jq -r .inbox.created_at)
echo "$NEW" | jq -r .inbox.address # quiet.fjord47@nrthmail.com
# 2 · Use the address somewhere, then wait up to 60 s for mail.
ID=$(curl -s "$API/inbox/wait?timeout=60&since=$SINCE" \
-H "Authorization: Bearer $TOKEN" | jq -r '.messages[0].id')
# 3 · Read a message in full.
curl -s $API/inbox/messages/$ID -H "Authorization: Bearer $TOKEN" \
| jq '{subject, codes, links}'
const API = 'https://nrthmail.com/api/v1';
// 1 · Create an inbox. The token is shown once: keep it.
const { token, inbox } = await fetch(`${API}/inboxes`, { method: 'POST' }).then((r) => r.json());
const auth = { Authorization: `Bearer ${token}` };
console.log(inbox.address); // quiet.fjord47@nrthmail.com
// 2 · Use the address somewhere, then wait up to 60 s for mail.
const since = encodeURIComponent(inbox.created_at);
const waited = await fetch(`${API}/inbox/wait?timeout=60&since=${since}`, { headers: auth })
.then((r) => r.json());
// 3 · Read a message in full.
if (!waited.timed_out) {
const id = waited.messages[0].id;
const { message } = await fetch(`${API}/inbox/messages/${id}`, { headers: auth }).then((r) => r.json());
console.log(message.subject, message.codes[0]);
}
import requests
API = "https://nrthmail.com/api/v1"
# 1 · Create an inbox. The token is shown once: keep it.
new = requests.post(f"{API}/inboxes", timeout=10).json()
auth = {"Authorization": f"Bearer {new['token']}"}
print(new["inbox"]["address"]) # quiet.fjord47@nrthmail.com
# 2 · Use the address somewhere, then wait up to 60 s for mail.
waited = requests.get(f"{API}/inbox/wait", headers=auth, timeout=75,
params={"timeout": 60, "since": new["inbox"]["created_at"]}).json()
# 3 · Read a message in full.
if not waited["timed_out"]:
msg_id = waited["messages"][0]["id"]
message = requests.get(f"{API}/inbox/messages/{msg_id}", headers=auth, timeout=10).json()["message"]
print(message["subject"], message["codes"][:1])
The curl version uses jq to pick fields out of the JSON.
02
Basics
Authentication
Creating an inbox returns a token that starts with nrth_. Send it on every inbox call as Authorization: Bearer nrth_…. It is the only credential that exists, and whoever holds it can read the inbox. It is shown once; the server keeps only its SHA-256 hash.
Format
JSON in and out, snake_case field names. Timestamps are RFC 3339 in UTC with milliseconds, like 2026-10-03T19:44:19.123Z. Every response carries Cache-Control: no-store.
CORS
Any origin may call the API: methods GET POST PATCH DELETE, request headers Authorization and Content-Type, and Location is exposed. Browser code can talk to it directly.
Lifetime
An inbox lives 24 hours after its last use. Every authenticated request and every open event stream pushes expires_at forward; POST /inbox/extend does it on purpose. When it expires, its messages and files are deleted at once.
Cost
None. There are no keys, accounts, plans or rate limits. The only limits are about storage; they are listed under Limits.
03
Inboxes
POST/api/v1/inboxes
Create an inbox. Send no body for a random name like quiet.fjord47, or JSON with a name and an optional domain. Answers 201 with the token, the inbox and Location: /api/v1/inbox.
Name
In
Type
Notes
name
body
string
3–40 characters, a–z 0–9 . _ -, starts and ends with a letter or digit. Omit for a random name.
domain
body
string
One of GET /domains. Default: the first one.
409 address_unavailable when the name is taken or still locked; 422 validation_failed for a bad name or unknown domain; 422 name_reserved for names like postmaster.
Bring an expired inbox back with its old token. It returns empty, with a fresh 24 h. This works for 30 days after expiry. Burned inboxes stay gone: 410 gone. Unknown tokens get 404.
One attachment as a download, with its original filename and X-Content-Type-Options: nosniff.
05
Long-poll
GET/api/v1/inbox/wait
The simplest way to get mail in a script: one request that hangs until a matching message arrives or the timeout passes. It returns at once if matching messages newer than since already exist.
Name
In
Type
Notes
timeout
query
integer
1–60 seconds, default 30.
since
query
string
RFC 3339 time or message id. Default: none, so any message already in the inbox counts. A fresh inbox therefore returns its first message even if it landed before you started waiting.
from
query
string
Substring the sender must contain.
subject
query
string
Substring the subject must contain.
Returns: {"messages":[Message…],"timed_out":false,"waited_ms":4210}. Messages are full Message objects.
Give your HTTP client a timeout longer than timeout. To loop without missing anything, pass the newest id you have seen as since.
# Wait for the next message from GitHub, up to 60 s.
curl -s "https://nrthmail.com/api/v1/inbox/wait?timeout=60&from=github" \
-H "Authorization: Bearer $TOKEN"
const API = 'https://nrthmail.com/api/v1';
const auth = { Authorization: 'Bearer nrth_…' };
// Keep waiting, never miss one.
let since = new Date().toISOString();
for (;;) {
const url = `${API}/inbox/wait?timeout=60&since=${encodeURIComponent(since)}`;
const { messages } = await fetch(url, { headers: auth }).then((r) => r.json());
for (const m of messages) console.log(m.subject, m.codes);
if (messages.length) since = messages.map((m) => m.id).sort().at(-1);
}
import requests
from datetime import datetime, timezone
API = "https://nrthmail.com/api/v1"
auth = {"Authorization": "Bearer nrth_…"}
# Keep waiting, never miss one.
since = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
while True:
r = requests.get(f"{API}/inbox/wait", headers=auth, timeout=75,
params={"timeout": 60, "since": since})
messages = r.json()["messages"]
for m in messages:
print(m["subject"], m["codes"])
if messages:
since = max(m["id"] for m in messages)
06
Live events (SSE)
GET/api/v1/inbox/events
A Server-Sent Events stream that pushes every new message as it lands. Open it with the Authorization header from code that can stream a response (fetch, requests, curl). Native EventSource cannot send headers: get a ticket first and pass it as ?ticket=.
The inbox expired or was burned. The stream closes.
Name
In
Type
Notes
ticket
query
string
A ticket from POST /inbox/tickets, instead of the header.
Last-Event-ID
header
string
Replays messages newer than this id after a reconnect.
The stream starts with retry: 3000 and sends a : ping comment every 20 s; each ping also keeps the inbox alive. After a reconnect, send Last-Event-ID to replay the messages you missed. Response headers: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no.
const API = 'https://nrthmail.com/api/v1';
const token = 'nrth_…';
// Stream with fetch: the token stays in a header.
const res = await fetch(`${API}/inbox/events`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let end;
while ((end = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
let event = 'message', data = '';
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (event === 'message' && data) console.log(JSON.parse(data).codes);
}
}
// Native EventSource, with a ticket.
const { ticket } = await fetch(`${API}/inbox/tickets`, {
method: 'POST', headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
const source = new EventSource(`${API}/inbox/events?ticket=${ticket}`);
source.addEventListener('message', (e) => console.log(JSON.parse(e.data).subject));
source.addEventListener('expired', () => source.close());
// Tickets are single use: on error, get a new one and reconnect.
source.onerror = () => source.close();
import json
import requests
API = "https://nrthmail.com/api/v1"
auth = {"Authorization": "Bearer nrth_…", "Accept": "text/event-stream"}
# Stream with requests.
with requests.get(f"{API}/inbox/events", headers=auth, stream=True, timeout=(10, 90)) as r:
event, data = "message", ""
for line in r.iter_lines(decode_unicode=True):
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
elif line == "":
if event == "message" and data:
print(json.loads(data)["codes"])
event, data = "message", ""
POST/api/v1/inbox/tickets
A single-use ticket for EventSource, valid 60 s. Get a fresh one for every reconnect. Returns: {"ticket":"tk_…","expires_at":"…"}
07
Share and QR
Move an inbox to another device without putting the token in a URL. A share code is single use and lives 120 s; redeeming it returns the token on the other side.
POST/api/v1/inbox/share
Create a share code. url is https://nrthmail.com/#share=CODE; the code sits in the fragment, which browsers never send to a server. Returns: {"code","url","expires_at"}
POST/api/v1/share/{code}
Redeem a share code. No token needed. Returns the token and the inbox; 404 when the code is unknown, used or expired.
GET/api/v1/inbox/qr.svg
A QR code (SVG) of a fresh share URL. Every call makes a new code; X-Share-Expires says when it stops working. Modules are #000 on a transparent background: show it on a light surface.
curl
curl -s -X POST https://nrthmail.com/api/v1/inbox/share -H "Authorization: Bearer $TOKEN"
# {"code":"K7QF2M9XWD4A","url":"https://nrthmail.com/#share=K7QF2M9XWD4A","expires_at":"2026-10-03T19:48:19.123Z"}
# On the other device, no token needed:
curl -s -X POST https://nrthmail.com/api/v1/share/K7QF2M9XWD4A
curl -s https://nrthmail.com/api/v1/inbox/qr.svg -o inbox-qr.svg \
-H "Authorization: Bearer $TOKEN"
08
Signed file URLs
<a>, <img> and <iframe> cannot send an Authorization header, so a full Message carries signed links instead: html_url, raw_url and one url per attachment. They look like /api/v1/files/{raw|att|html}/{id}?exp=…&sig=… and work for one hour. After that they answer 403 forbidden; fetch the message again for fresh ones.
html_url serves the sanitized email as a complete HTML document under a strict sandbox CSP, ready for an iframe. Scripts, forms and plugins are removed, links open in a new tab, and remote images stay blocked unless you asked for ?images=true.
09
Objects
Inbox
Field
Type
Description
id
string
ibx_ + ULID.
address
string
Full address, lowercase.
local_part
string
The part before @.
domain
string
The part after @.
custom
boolean
true when the name was chosen, not generated.
status
string
active, expired or burned.
created_at
time
When the inbox was created.
expires_at
time
When it will be erased unless used again.
ttl_seconds
integer
Lifetime after the last use, in seconds.
message_count
integer
Messages in the inbox.
unseen_count
integer
Messages not yet marked seen.
MessageSummary
Field
Type
Description
id
string
msg_ + ULID. Sorts by arrival.
seq
integer
Per-inbox counter: 1, 2, 3…
received_at
time
When the message arrived.
from
{name, address}
Sender from the From header. name may be null.
to
string
The recipient as addressed, including any +tag.
tag
string · null
The +tag part, or null.
subject
string · null
Subject line, or null.
preview
string
First 160 characters of the text.
seen
boolean
Whether it was marked seen.
size
integer
Size of the raw message in bytes.
has_attachments
boolean
Whether it has attachments.
attachment_count
integer
Number of attachments.
codes
string[]
Up to 3 verification codes found in it, best first.
Sanitized HTML body. Null when there is no HTML part, or when it was too complex to render in time (use text). Remote images are blocked unless ?images=true.
html_url
string · null
Signed URL of the sanitized HTML as a full document, for an iframe. Null when the message has no HTML part.
remote_images
{blocked, count, trackers}
Whether remote images were blocked, how many there are, and how many look like trackers.