WebSocket
The WebSocket at wss://mail.nullnet.sh/v1/ws pushes events for your inboxes as they happen: new mail, deleted mail, and inbox expiry. One connection can watch several inboxes.
Protocol
All frames are JSON text frames. The client sends objects with an op field. The server sends objects with a type field.
- Open
wss://mail.nullnet.sh/v1/ws. Do not put the token in the URL. - Within 10 seconds, send
{"op":"auth","token":"<session token>"}. The server answers{"type":"ready"}. - Subscribe to each inbox you want to watch with
{"op":"subscribe","inbox_id":"..."}. - Handle
eventandresyncmessages until you close the socket.
Only session tokens work here, anonymous or logged in. API keys are not accepted. You can subscribe to inboxes the session owns, and, for a logged-in session, to any inbox of the account.
If the first frame is not a valid auth or the token is invalid, the server sends an unauthorized error and closes the connection. While connected, the session is checked again every minute. If it has been revoked or has expired, the server sends {"type":"error","code":"unauthorized","message":"session ended"} and closes.
Browser
const ws = new WebSocket('wss://mail.nullnet.sh/v1/ws')
ws.onopen = () => ws.send(JSON.stringify({ op: 'auth', token }))
ws.onmessage = (frame) => {
const msg = JSON.parse(frame.data)
switch (msg.type) {
case 'ready':
ws.send(JSON.stringify({ op: 'subscribe', inbox_id: inboxId }))
break
case 'event':
if (msg.kind === 'new_message') showMessage(msg.data)
break
case 'resync':
refetchMessages()
break
case 'error':
console.warn(msg.code, msg.message)
break
}
}
// Client frames keep the connection alive.
setInterval(() => ws.send(JSON.stringify({ op: 'ping' })), 60_000)
Client messages
- Name
auth- Type
- token
- Description
{"op":"auth","token":"..."}. Must be the first frame. Sending it again later returns abad_requesterror.
- Name
subscribe- Type
- inbox_id
- Description
{"op":"subscribe","inbox_id":"..."}. Answered withsubscribed, or with an errornot_found(no such inbox, or not yours) ortoo_many_subscriptions.
- Name
unsubscribe- Type
- inbox_id
- Description
{"op":"unsubscribe","inbox_id":"..."}. Answered withunsubscribed.
- Name
ping- Description
{"op":"ping"}. Answered withpong.
An unknown op or malformed JSON returns an error with code bad_request. The connection stays open.
Server messages
- Name
ready- Description
{"type":"ready"}. Authentication succeeded.
- Name
subscribed- Type
- inbox_id
- Description
{"type":"subscribed","inbox_id":"..."}.
- Name
unsubscribed- Type
- inbox_id
- Description
{"type":"unsubscribed","inbox_id":"..."}.
- Name
pong- Description
{"type":"pong"}.
- Name
event- Type
- inbox_id, kind, data
- Description
{"type":"event","inbox_id":"...","kind":"new_message","data":{...}}. See event kinds.
- Name
resync- Description
{"type":"resync"}. The connection fell behind and some events were dropped. Refetch the lists you show.
- Name
error- Type
- code, message
- Description
{"type":"error","code":"...","message":"..."}.
Error codes
code | Meaning |
|---|---|
unauthorized | Bad or missing auth, or the session ended. The connection closes. |
too_many_connections | The session already has as many sockets open as its tier allows. The connection closes. |
too_many_subscriptions | This connection already watches as many inboxes as its tier allows. |
not_found | The inbox does not exist or is not yours. |
bad_request | Unknown op, malformed JSON, or a second auth. |
Event kinds
- Name
new_message- Description
A message was stored. Add it to the top of the list, or refetch the first page.
- Name
message_deleted- Description
A message was deleted, for example from another client.
- Name
expiring- Description
The inbox expires in about five minutes. Offer to extend it.
- Name
expired- Description
The inbox expired. It and its mail are gone, and its endpoints now return
404.
new_message
{
"type": "event",
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"kind": "new_message",
"data": {
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"message_id": "019a2f61-3e5a-7b12-8c4d-6e8f0a2b4c6d",
"received_at": "2026-10-26T08:05:12+00:00",
"from_addr": "noreply@example.com",
"from_name": "Example",
"subject": "Your sign-in code",
"snippet": "Your code is 481920. It expires in 10 minutes.",
"size_bytes": 5821,
"has_attachments": false
}
}
message_deleted
{
"type": "event",
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"kind": "message_deleted",
"data": {
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"message_id": "019a2f61-3e5a-7b12-8c4d-6e8f0a2b4c6d"
}
}
expiring / expired
{
"type": "event",
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"kind": "expiring",
"data": {
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"expires_at": "2026-10-26T09:00:00+00:00"
}
}
The data fields of new_message:
- Name
inbox_id- Type
- string
- Description
Inbox that received the message.
- Name
message_id- Type
- string
- Description
The stored message. Fetch it with
GET /v1/messages/:id.
- Name
received_at- Type
- timestamp
- Description
Receipt time.
- Name
from_addr- Type
- string | null
- Description
Sender address.
- Name
from_name- Type
- string | null
- Description
Sender display name.
- Name
subject- Type
- string | null
- Description
Subject.
- Name
snippet- Type
- string
- Description
Text preview.
- Name
size_bytes- Type
- integer
- Description
Raw size.
- Name
has_attachments- Type
- boolean
- Description
Whether the message has attachments that are not inline.
Keepalive and limits
- The server sends WebSocket ping frames while a connection is open. They do not count as activity.
- A connection with no client frame (text or binary) for 5 minutes is closed. Send
{"op":"ping"}every minute or so if nothing else is sent. - Connections per session and subscriptions per connection depend on the tier: an anonymous session gets 2 connections with 3 subscriptions each, a free account 3 with 5, a premium account 20 with 200. See tiers and limits.
- Use one socket for the whole app and subscribe to the inboxes on screen.
- After a disconnect, reconnect with backoff, authenticate again and resubscribe. Events that arrived while you were disconnected are not replayed; refetch the message list.