API · v1

One POST, one inbox. Wait for the mail, read the code.

Free and public. No key, no sign-up, no rate limits.

Base URL
https://nrthmail.com/api/v1
Format
JSON · snake_case · UTC
CORS
any origin
OpenAPI
/api/v1/openapi.json

01

Quick start: 3 calls

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.

NameInTypeNotes
namebodystring3–40 characters, a–z 0–9 . _ -, starts and ends with a letter or digit. Omit for a random name.
domainbodystringOne 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.

curl
curl -s -X POST https://nrthmail.com/api/v1/inboxes \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"signup-test-$RANDOM\"}"
201 · Response
{
  "token": "nrth_3kQ9vR2mXc7LpT0aWb5YzN8sHd4FgJ6eUo1iKyMhPq7",
  "inbox": {
    "id": "ibx_01K6P8Z3QK4T2V9W1X5Y7A0C3D",
    "address": "signup-test-4821@nrthmail.com",
    "local_part": "signup-test-4821",
    "domain": "nrthmail.com",
    "custom": true,
    "status": "active",
    "created_at": "2026-10-03T19:44:19.123Z",
    "expires_at": "2026-10-04T19:44:19.123Z",
    "ttl_seconds": 86400,
    "message_count": 0,
    "unseen_count": 0
  }
}

POST/api/v1/inboxes/recover

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.

NameInTypeNotes
tokenbodystringThe inbox token, nrth_…
curl
curl -s -X POST https://nrthmail.com/api/v1/inboxes/recover \
  -H "Content-Type: application/json" \
  -d '{"token":"nrth_…"}'

GET/api/v1/inbox

The inbox, with its counts and expiry. Returns: {"inbox": Inbox}

curl
curl -s https://nrthmail.com/api/v1/inbox -H "Authorization: Bearer $TOKEN"
# {"inbox":{"address":"quiet.fjord47@nrthmail.com","status":"active","expires_at":"…",…}}

POST/api/v1/inbox/extend

Reset the expiry to now + 24 h. Returns: {"inbox": Inbox}

DELETE/api/v1/inbox

Burn the inbox: messages and files are deleted now, the address is locked for 30 days, and the token stops working. Answers 204.

curl
curl -s -X DELETE https://nrthmail.com/api/v1/inbox -H "Authorization: Bearer $TOKEN"

GET/api/v1/domains

Domains you can create inboxes on. No token needed. Returns: {"domains":[{"domain":"nrthmail.com","default":true}]}

04

Messages

GET/api/v1/inbox/messages

Message summaries, newest first. Send the previous ETag back as If-None-Match and you get 304 when nothing changed: the cheapest way to poll.

NameInTypeNotes
limitqueryinteger1–100, default 25.
cursorquerystringThe next_cursor of the previous page.
sincequerystringRFC 3339 time or a message id. Only newer messages.
unseenquerybooleantrue for unseen messages only.
curl
curl -s "https://nrthmail.com/api/v1/inbox/messages?limit=10&unseen=true" \
  -H "Authorization: Bearer $TOKEN"
200 · Response
{
  "data": [
    {
      "id": "msg_01K6P9B7M2C4D8E6F0G3H5J7K9",
      "seq": 3,
      "received_at": "2026-10-03T19:46:02.481Z",
      "from": { "name": "GitHub", "address": "noreply@github.com" },
      "to": "quiet.fjord47+github@nrthmail.com",
      "tag": "github",
      "subject": "Your code is 482913",
      "preview": "Use 482913 to finish signing in…",
      "seen": false,
      "size": 18342,
      "has_attachments": false,
      "attachment_count": 0,
      "codes": ["482913"]
    }
  ],
  "next_cursor": null,
  "has_more": false
}

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

One message in full: text, sanitized HTML, links, codes, headers, attachments and signed file URLs. Returns: {"message": Message}

NameInTypeNotes
imagesquerybooleantrue to get html and html_url with remote images allowed.
curl
curl -s https://nrthmail.com/api/v1/inbox/messages/msg_01K6P9B7M2C4D8E6F0G3H5J7K9 \
  -H "Authorization: Bearer $TOKEN"

PATCH/api/v1/inbox/messages/{id}

Mark a message seen or unseen. Body {"seen":true} as application/json or application/merge-patch+json. Returns the summary.

curl
curl -s -X PATCH https://nrthmail.com/api/v1/inbox/messages/msg_01K6P9B7M2C4D8E6F0G3H5J7K9 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"seen":true}'

DELETE/api/v1/inbox/messages/{id}

Delete one message. Answers 204.

DELETE/api/v1/inbox/messages

Delete every message. The inbox and its address stay. Answers 204.

GET/api/v1/inbox/messages/{id}/raw

The original message as message/rfc822, served as a download named <id>.eml.

curl
curl -s -OJ https://nrthmail.com/api/v1/inbox/messages/msg_01K6P9B7M2C4D8E6F0G3H5J7K9/raw \
  -H "Authorization: Bearer $TOKEN"

GET/api/v1/inbox/messages/{id}/attachments/{att_id}

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.

NameInTypeNotes
timeoutqueryinteger1–60 seconds, default 30.
sincequerystringRFC 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.
fromquerystringSubstring the sender must contain.
subjectquerystringSubstring 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=.

EventDataWhen
messageMessageSummaryA new message arrived. The SSE id is the message id.
message_deleted{"id"}A message was deleted.
inboxInboxThe inbox was extended or recovered.
expired{}The inbox expired or was burned. The stream closes.
NameInTypeNotes
ticketquerystringA ticket from POST /inbox/tickets, instead of the header.
Last-Event-IDheaderstringReplays 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.

curl -N https://nrthmail.com/api/v1/inbox/events \
  -H "Authorization: Bearer $TOKEN"

# retry: 3000
#
# event: message
# id: msg_01K6P9B7M2C4D8E6F0G3H5J7K9
# data: {"id":"msg_01K6P9B7M2C4D8E6F0G3H5J7K9","seq":3,"subject":"Your code is 482913","codes":["482913"],…}
#
# : ping
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

FieldTypeDescription
idstringibx_ + ULID.
addressstringFull address, lowercase.
local_partstringThe part before @.
domainstringThe part after @.
custombooleantrue when the name was chosen, not generated.
statusstringactive, expired or burned.
created_attimeWhen the inbox was created.
expires_attimeWhen it will be erased unless used again.
ttl_secondsintegerLifetime after the last use, in seconds.
message_countintegerMessages in the inbox.
unseen_countintegerMessages not yet marked seen.

MessageSummary

FieldTypeDescription
idstringmsg_ + ULID. Sorts by arrival.
seqintegerPer-inbox counter: 1, 2, 3…
received_attimeWhen the message arrived.
from{name, address}Sender from the From header. name may be null.
tostringThe recipient as addressed, including any +tag.
tagstring · nullThe +tag part, or null.
subjectstring · nullSubject line, or null.
previewstringFirst 160 characters of the text.
seenbooleanWhether it was marked seen.
sizeintegerSize of the raw message in bytes.
has_attachmentsbooleanWhether it has attachments.
attachment_countintegerNumber of attachments.
codesstring[]Up to 3 verification codes found in it, best first.

Message

A MessageSummary plus these fields:

FieldTypeDescription
cc{name, address}[]Cc recipients.
reply_tostring · nullThe Reply-To address, or null.
textstring · nullPlain-text body, or null.
htmlstring · nullSanitized 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_urlstring · nullSigned 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.
auth{spf, dkim, dmarc}SPF, DKIM and DMARC results: pass fail softfail neutral none unknown.
headers{name, value}[]The first 60 headers, values up to 1000 characters.
links{url, text, kind}[]Up to 6 links. kind is verify or other; verify links come first.
attachmentsAttachment[]The attachments, as Attachment objects.
raw_urlstringSigned URL of the original .eml.

Attachment

FieldTypeDescription
idstringatt_ + ULID.
filenamestring · nullOriginal filename, or null.
content_typestringMIME type.
sizeintegerSize in bytes.
inlinebooleantrue for images embedded in the HTML body.
content_idstring · nullThe cid: id for inline parts, or null.
urlstringSigned download URL.

Problem

FieldTypeDescription
typestringLink to the error code on this page.
titlestringHTTP status text.
statusintegerHTTP status code.
codestringStable error code. Branch on this.
detailstringWhat went wrong, in words.
errorsobject[]Only for validation_failed: [{field, code, message}].
10

Errors

Every non-2xx response is RFC 9457 application/problem+json. Branch on code; detail is for humans.

CodeStatusMeaning
invalid_request400Malformed JSON, or a body that is not a JSON object.
unauthorized401Missing or unknown token. Comes with WWW-Authenticate: Bearer.
forbidden403A signed file URL is wrong or expired.
not_found404No such message, attachment, token or share code.
address_unavailable409The name is taken, or locked for 30 days after an inbox expired or was burned.
gone410The inbox expired or was burned. Expired inboxes can be recovered with their token.
payload_too_large413The request body is too large.
unsupported_media_type415Wrong Content-Type for the body.
validation_failed422A body field or query parameter failed validation; see errors.
name_reserved422Names like admin, postmaster or support cannot be claimed.
internal500Our fault. Try again.
404 · application/problem+json
{
  "type": "https://nrthmail.com/docs#error-not_found",
  "title": "Not Found",
  "status": 404,
  "code": "not_found",
  "detail": "No message msg_01K6P9B7M2C4D8E6F0G3H5J7K9 in this inbox"
}
11

Limits

None of these are rate limits. They keep the disk clean so the service can stay free.

WhatLimit
RequestsNo limit. No key, no quota.
Message size25 MB. Larger mail is refused by the mail server.
Messages per inbox100. The oldest is deleted when a new one arrives.
Message age7 days at most, even in an inbox that stays in use.
Inbox lifetime24 h after the last request or open stream.
Address lock30 days after expiry or burn. During that time only the old token can bring back an expired inbox; a burned one stays gone.
Long-poll timeoutUp to 60 s per request.
Page sizeUp to 100 messages.
Share codes · tickets · signed URLsValid 120 s · 60 s · 1 h, single use where noted.

Polling works, and nobody will stop you. A long-poll or the event stream gets the mail to you sooner with fewer requests.

12

Stats and OpenAPI

GET/api/v1/stats

Public counters for the whole service. Returns: {"messages_24h":1204,"inboxes_24h":311,"inboxes_active":97}

GET/api/v1/openapi.json

An OpenAPI 3.1 description of everything on this page, for code generators and API clients.

The full machine-readable description lives at /api/v1/openapi.json.