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.

  1. Open wss://mail.nullnet.sh/v1/ws. Do not put the token in the URL.
  2. Within 10 seconds, send {"op":"auth","token":"<session token>"}. The server answers {"type":"ready"}.
  3. Subscribe to each inbox you want to watch with {"op":"subscribe","inbox_id":"..."}.
  4. Handle event and resync messages 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 a bad_request error.

  • Name
    subscribe
    Type
    inbox_id
    Description

    {"op":"subscribe","inbox_id":"..."}. Answered with subscribed, or with an error not_found (no such inbox, or not yours) or too_many_subscriptions.

  • Name
    unsubscribe
    Type
    inbox_id
    Description

    {"op":"unsubscribe","inbox_id":"..."}. Answered with unsubscribed.

  • Name
    ping
    Description

    {"op":"ping"}. Answered with pong.

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

codeMeaning
unauthorizedBad or missing auth, or the session ended. The connection closes.
too_many_connectionsThe session already has as many sockets open as its tier allows. The connection closes.
too_many_subscriptionsThis connection already watches as many inboxes as its tier allows.
not_foundThe inbox does not exist or is not yours.
bad_requestUnknown 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.