PMWallets
中文

Webhooks

Fills posted to your server, signed and retried.

Webhooks

Register an HTTPS endpoint on My feed and we POST each fill to it. The signing secret is shown once, when you register it. It cannot be read again, so store it then. 1 endpoint per account; delete it to point deliveries somewhere else. To reach two systems, forward requests from your receiver and monitor each destination there.

Registering and deleting are done on the site, not with an API key. A webhook is a delivery destination, and a key that can change where fills are delivered is a key that can redirect the stream permanently. Revoking the key that created a webhook does not remove it.

Each request carries x-pmw-signature: HMAC-SHA256 of the raw body, keyed with your secret, hex encoded. Verify it against the raw bytes; parsing and re-serialising the JSON changes them, and then every signature fails.

node
import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/hook", express.raw({ type: "application/json" }), async (req, res) => {
  const sent = Buffer.from(req.header("x-pmw-signature") ?? "", "hex");
  const mine = createHmac("sha256", process.env.PMW_SECRET).update(req.body).digest();

  // compare the RAW body, before any JSON parsing: re-serialising changes the bytes
  if (sent.length !== mine.length || !timingSafeEqual(sent, mine)) return res.sendStatus(401);

  // store it durably BEFORE answering: a 2xx means "done" and we stop retrying,
  // so a fill acknowledged and then lost is gone for good. Anything else is retried.
  const { type, data: fill } = JSON.parse(req.body.toString());   // { "type": "fill", "data": { … } }
  if (type !== "fill") return res.sendStatus(200);
  try { await saveOnce(fill.eventId, fill); } catch { return res.sendStatus(503); }
  res.sendStatus(200);
});

Delivery is at least once. We wait 5 seconds for a 2xx and retry up to 3 times; a crash between sending and recording can also replay a delivery. So the same fill can arrive twice and your handler must be idempotent. Deduplicate on eventId, which is stable across every retry.

Redirects are not followed and only public addresses are accepted, so an endpoint on localhost or a private range is rejected when you register it.

An endpoint that keeps failing is switched off. Once one has been failing for more than 24 hours, with at least 20 failed deliveries in a row and no success in between, we stop sending to it and email you. Fix the receiver, then turn it back on using the button on the My feed, or this call (an API key may make it):

shell
curl -X POST https://api.pmwallets.com/v1/account/webhooks/$WEBHOOK_ID/reactivate \
  -H "Authorization: Bearer $PMW_KEY"

Deliveries missed while it was off are not re-sent, except as below. A subscription whose only channel is the webhook is paused (and not charged) a minute after the switch-off; if it is still active when you turn the webhook back on, nothing changes for it and GET /v1/account/fills replays its gap. Once paused, turning the webhook back on resumes it from 65 blocks behind the head, so fills from the last ~96 seconds may be pushed again; deduplicate by eventId. Anything older in the off span is not available from GET /v1/account/fills either (use a history export). A subscription that also streams over WebSocket was never paused: it kept running and charging, and its missed webhook fills can be fetched with GET /v1/account/fills from your last cursor (see catching up). GET /v1/account/webhooks returns disabledAt and consecutiveFailures if you want to watch it yourself.

While it is off, subscriptions that deliver only by webhook are paused (pausedReason: "webhook_disabled") and are not charged; ones that also use the WebSocket carry on. Turning the webhook back on resumes them and charges their first hour. A subscription the balance cannot cover stays paused until you top up and resume it. A webhook-only subscription with no webhook at all (never set up, or deleted) is not charged either: creating one is refused, and an existing one pauses at its next renewal (pausedReason: "no_webhook") until you add a webhook and resume it.