PMWallets
中文

WebSocket

Connect to the low-latency stream and replay missed fills.

WebSocket

GETwss://api.pmwallets.com/v1/wsAPI key

The socket needs no subscription messages. It carries the fills of whatever entities your account is already subscribed to, and anything you send is ignored. This is the low-latency channel, and what a copy-trading bot normally connects to: one persistent connection, no DNS and TLS handshake per event, and no need to run a public HTTPS endpoint of your own. The browser feed on this site uses the same stream.

node
import WebSocket from "ws";
import { randomBytes } from "node:crypto";

// One id per running client, the same on every reconnect: it is how the server tells
// "this client coming back after a network drop" from "a second client".
const clientId = randomBytes(16).toString("base64url");
const takeover = false;   // true = replace whichever API client holds this account's stream

function connect(first) {
  const ws = new WebSocket(`wss://api.pmwallets.com/v1/ws?clientId=${clientId}${first && takeover ? "&takeover=1" : ""}`, {
    headers: {
      "x-api-key": process.env.PMW_KEY,     // a handshake cannot set Authorization
    },
  });
  ws.on("close", (code) => {
    if (code === 4411) return stop("another API client holds this account's stream");   // refused: do not retry
    if (code === 1000) return stop("replaced by another client's takeover");            // do not fight back
    setTimeout(() => connect(false), code === 1012 ? 0 : backoff());   // 1012 = we are restarting: come right back
  });
  return ws;
}
const ws = connect(true);

The socket does not retry frames. Frames are best effort: one handed to an open socket counts as sent, one with no socket to hand it to is dropped, and neither is resent. Keep a cursor to fetch dropped fills from GET /v1/account/fills, as described below. Take the webhook as well if you would rather have the fills pushed to you than fetch them, and deduplicate either way on eventId.

1 connection per account, and an API client comes first. A connection made with an API key takes the stream from a browser tab on this site (the tab is closed with code 4410); a browser tab can never take it from an API client. While one is connected, the feed page is refused with code 4409 and says so.

Between two API connections the first one keeps the stream. A second one, using another key of the same account or another instance of the bot, gets a {"type":"blocked","reason":"api_seat_taken"} frame and is closed with code 4411; the connection already streaming is not touched. To replace it on purpose, connect with ?takeover=1: the holder is closed with code 1000 and should stop rather than reconnect (reconnecting would take the stream back). Send a clientId (8–64 of [A-Za-z0-9_-], one per running client, the same on every reconnect): a reconnect carrying the holder’s own id replaces its dead connection without takeover, so a network drop never locks a client out of its own stream. The server pings every 15 seconds and drops a connection that stops answering.

When we restart the stream service, open sockets are closed with 1012 (service restart). Reconnect immediately without backoff; the replacement process is taking over. Replay the gap as below. The official SDKs do all of this for you.

Knowing what you missed, and fetching it

Every frame carries session and seq. Within a session the numbers are consecutive, so a skip means we dropped a frame. We drop frames when a consumer falls behind while leaving the socket open; the sequence gap is the only indication. A new session means you reconnected. Use either change to detect a gap.

js
// Persist these across reconnects — comparing against a session you just overwrote
// can never detect anything.
let session = load("session"), seq = load("seq") ?? 0;
let lastBlock = load("block") ?? 0, lastLogIndex = load("logIndex") ?? 0;

// One message at a time: a replay is awaited, and a second frame arriving meanwhile
// would advance the cursor past the gap being repaired. Note the catch — chaining
// onto a rejected promise skips every later .then(), so a single failed replay would
// leave the socket open and silently processing nothing at all.
let queue = Promise.resolve();
ws.onmessage = (e) => {
  queue = queue
    .then(() => handleFrame(JSON.parse(e.data)))
    .catch((err) => { report(err); ws.close(); });   // reconnect and replay rather than go quiet
};

async function handleFrame(m) {
  if (m.type === "hello") {
    // A NEW session means frames were missed while the socket was down. Replay BEFORE
    // adopting it — overwriting session here is what silently swallows the outage.
    // a failing replay must not be swallowed: leave the cursor where it is and throw, so the
    // handler above closes the socket and the next connection tries again from the same place
    if (session !== null && m.session !== session) await replayFrom(lastBlock, lastLogIndex);
    session = m.session; seq = m.seq; save();
    return;
  }
  if (m.type !== "fill") return;

  if (m.session !== session || m.seq !== seq + 1) {   // dropped frame, or a session we never saw
    await replayFrom(lastBlock, lastLogIndex);
    session = m.session;
  }
  seq = m.seq;

  if (!seen.has(m.data.eventId)) { seen.add(m.data.eventId); handle(m.data); }
  lastBlock = m.data.block; lastLogIndex = m.data.logIndex;
  save();
}

Then fetch the gap. GET /v1/account/fills returns the fills of everything you subscribe to after a cursor, oldest first, with keyset pagination. They are the same rows, with the same eventId, so deduplicating on it makes replay safe to over-fetch. It covers active subscriptions only, and only fills after each one's fromBlock: a resume moves that to the head, so a paused span cannot be fetched here.

js
// walk forward until the page is short; every row carries the same eventId as the stream
async function replayFrom(block, logIndex) {
  for (;;) {
    const r = await fetch(
      `https://api.pmwallets.com/v1/account/fills?sinceBlock=${block}&sinceLogIndex=${logIndex}&limit=500`,
      { headers: { "x-api-key": PMW_KEY } },
    );
    if (!r.ok) throw new Error(`replay failed: HTTP ${r.status}`);   // do NOT advance the cursor
    const page = await r.json();
    // fills are kept for 60 days: an older gap is gone, not empty
    if (page.truncated) console.warn(`fills before block ${page.retainedFrom} are no longer kept`);

    for (const row of page.rows) if (!seen.has(row.eventId)) { seen.add(row.eventId); handle(row); }
    if (!page.next) return;
    ({ sinceBlock: block, sinceLogIndex: logIndex } = page.next);
  }
}