Skip to main content
The rules below are written so your handler keeps working as new event types and fields arrive - see Events & Payloads for the event catalog.

1. Respond before you process

Return a 2xx immediately, then do the work asynchronously. WalletSuite only acknowledges a delivery on a 2xx in time. If you verify the signature, then write to your database, call a third-party API, and then respond, a slow dependency turns into a delivery timeout, which turns into a retry, which turns into a duplicate. The correct shape: verify the signature, enqueue the event, return 200. Process from the queue.
Express
import express from "express";

const app = express();

app.post(
  "/webhooks/walletsuite",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verifySignature(req)) {
      return res.status(400).send("invalid signature");
    }

    await queue.enqueue(req.body.toString("utf8"));

    res.status(200).send("ok");
  }
);

2. Be idempotent

Dedupe on event_id with a database unique constraint: the first delivery processes; every later delivery of the same event is a no-op. Delivery is at-least-once, so dedupe on your idempotency key - the event_id (= the Webhook-Id header). A naive “have I seen this id?” check has a race: two concurrent deliveries both read “not seen,” both process. A conditional insert under a unique constraint closes it - exactly one delivery wins the insert and processes; the rest see the conflict and return.
Postgres
CREATE TABLE processed_events (
  event_id  uuid PRIMARY KEY,
  seen_at   timestamptz NOT NULL DEFAULT now()
);
Node + Postgres (pg)
async function handleEvent(event: WebhookEvent) {
  const claim = await pool.query(
    `INSERT INTO processed_events (event_id)
       VALUES ($1)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.event_id]
  );

  if (claim.rowCount === 0) {
    return;
  }

  await applyBusinessLogic(event);
}

3. Don’t assume order

Use occurred_at for temporal logic, never arrival order. Delivery order is best-effort, not guaranteed. Process each event independently. When you need to reason about when something happened on-chain - “is this the most recent transfer?”, “did this arrive before that?” - compare the occurred_at field, not the order packets reached your server.
if (event.occurred_at > state.latestBalanceEventTime) {
  state.latestBalanceEventTime = event.occurred_at;
}

4. Tolerate the unknown

Write tolerant parsers so new capabilities arrive without a redeploy. The payload versioning contract guarantees additive evolution within your pinned schema_version, and enums are open. A handler that acknowledges what it doesn’t recognize - new fields, types, and enum values - keeps working as the schema grows.
function route(event: WebhookEvent) {
  if (event.event_type !== "transfer.received") return;

  switch (event.data.asset_type) {
    case "native":
      return creditNative(event);
    case "erc20":
    case "trc20":
      return creditToken(event);
    default:
      log.info("unhandled asset_type", { type: event.data.asset_type });
      return;
  }
}

5. Treat webhooks as signals, not the ledger

Re-query authoritative state before any high-value action. A webhook is a low-latency signal that something happened - it is not your source of truth. Before you credit a large balance, release goods, or trigger a payout, confirm the transfer by reading current state from the REST API. This is defense-in-depth on two fronts:
  • Against stale data - signature verification proves origin; re-querying proves the transfer is real, final, and still reflected in current state.
  • Against gaps - deliveries retry across the full retry window, and a periodic reconciliation sweep that re-queries the API guarantees correctness even if your endpoint is down past it.
async function onTransfer(event: WebhookEvent) {
  const { chain, to, asset_type, asset_contract } = event.data;

  const balance = await sdk.api.getAssetBalances(to, {
    chain,
    assetIds: asset_type === "native" || !asset_contract ? [] : [asset_contract],
    includeNative: asset_type === "native",
  });

  if (confirmsExpectedCredit(balance.data, event)) {
    await releaseGoods(event);
  }
}

6. Store the secret securely

Keep whsec_… in a secret manager - never in code - and rotate via the API. Treat it exactly like an API key (see Securing Your API Keys). Rotate it if it’s ever exposed - see secret rotation.

Verify Signatures

Full copy-paste verification code, in multiple languages.

Delivery & Retries

The retry ladder, timeouts, and at-least-once semantics behind these rules.

Events & Payloads

Field-by-field reference for the transfer.received payload.

Quickstart

Subscribe, provision a secret, and receive your first event.