CarlyEmail docs

WebSockets

Subscribe to mail and delivery events over one authenticated real-time connection.

Use WebSockets when an agent or console is already running and should react without polling. For a sleeping server or Cloudflare Worker, use a webhook instead—the delivery wakes it and retries without requiring a process to hold a connection.

Connect and subscribe

WebSocket authentication uses the same API key as REST, passed as api_key because browser WebSocket handshakes cannot set an Authorization header. Scope the key to one inbox or pod whenever the listener does not need the whole organization.

const apiKey = process.env.CARLYEMAIL_API_KEY;
const socket = new WebSocket(
  `wss://ws.carlyemail.com/v0?api_key=${encodeURIComponent(apiKey)}`,
);

socket.addEventListener("open", () => {
  socket.send(JSON.stringify({
    type: "subscribe",
    event_types: ["message.received", "message.delivered", "message.bounced"],
    inbox_ids: ["agent@carlyemail.com"],
  }));
});

socket.addEventListener("message", ({ data }) => {
  const frame = JSON.parse(data);
  if (frame.type === "event" && frame.event_type === "message.received") {
    console.log(frame.message, frame.thread);
  }
});

The acknowledgement contains the filters CarlyEmail actually applied:

{
  "type": "subscribed",
  "event_types": ["message.received"],
  "inbox_ids": ["agent@carlyemail.com"],
  "pod_ids": []
}

An inbox- or pod-scoped key cannot widen itself. If it requests another inbox, the acknowledgement shows its own scope and only matching events arrive. The socket also honors the key's permission map: message events require message_read, and domain verification events require domain_read.

Event frames

Every event has a stable event_id and an event_type. The entity key follows the event: received mail has message and thread, sent mail has send, and delivery feedback has delivery, bounce, complaint, or reject.

{
  "type": "event",
  "event_type": "message.received",
  "event_id": "evt_00n...",
  "message": {
    "inbox_id": "agent@carlyemail.com",
    "message_id": "<example@sender.test>",
    "subject": "Can you help?"
  },
  "thread": {
    "thread_id": "thread_00n...",
    "message_count": 1
  }
}

Supported event types are the same as webhooks: received, sent, delivery, bounce, complaint, rejection, and domain verification events.

Reconnect without losing mail

WebSocket delivery is at least once. Deduplicate side effects with event_id. Connections receive keepalive {"type":"ping"} frames, but the platform may still close a connection for maintenance or after its maximum lifetime. Clients must reconnect with exponential backoff, send the subscription again, and backfill inbox events from the REST event endpoint using the last durable cursor.

let delay = 250;

function connect() {
  const socket = new WebSocket(
    `wss://ws.carlyemail.com/v0?api_key=${encodeURIComponent(apiKey)}`,
  );
  socket.addEventListener("open", () => {
    delay = 250;
    socket.send(JSON.stringify({ type: "subscribe" }));
  });
  socket.addEventListener("close", () => {
    setTimeout(connect, delay + Math.random() * delay);
    delay = Math.min(delay * 2, 30_000);
  });
}

connect();

Warning

Do not put the full WebSocket URL in application logs—the query string contains the API key. CarlyEmail's own access logs deliberately omit query strings.