> ## Documentation Index
> Fetch the complete documentation index at: https://docs.walletsuite.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Best Practices

> Rules for consuming WalletSuite webhooks reliably - fast acknowledgement, idempotency, ordering, and forward compatibility.

<Note>
  The rules below are written so your handler keeps working as new event types and fields arrive - see [Events & Payloads](/webhooks/events) for the event catalog.
</Note>

## 1. Respond before you process

**Return a 2xx immediately, then do the work asynchronously.** WalletSuite only [acknowledges a delivery on a `2xx` in time](/webhooks/delivery#acknowledgement). 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.

```ts Express theme={null}
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](/webhooks/delivery#delivery-semantics), so dedupe on your [idempotency key](/webhooks/delivery#idempotency) - 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.

```sql Postgres theme={null}
CREATE TABLE processed_events (
  event_id  uuid PRIMARY KEY,
  seen_at   timestamptz NOT NULL DEFAULT now()
);
```

```ts Node + Postgres (pg) theme={null}
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](/webhooks/delivery#delivery-semantics).

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.

```ts theme={null}
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](/webhooks/events#payload-versioning) guarantees additive evolution within your pinned `schema_version`, and [enums are open](/webhooks/events). A handler that acknowledges what it doesn't recognize - new fields, types, and enum values - keeps working as the schema grows.

```ts theme={null}
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](/getting-started/api-reference/overview).

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](/webhooks/delivery#retry-behavior), and a periodic reconciliation sweep that re-queries the API guarantees correctness even if your endpoint is down past it.

```ts theme={null}
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](/getting-started/prerequisites/credentials-and-authentication)). Rotate it if it's ever exposed - see [secret rotation](/webhooks/verify#secret-rotation).

## Related

<CardGroup cols={2}>
  <Card title="Verify Signatures" href="/webhooks/verify" icon="shield-check">
    Full copy-paste verification code, in multiple languages.
  </Card>

  <Card title="Delivery & Retries" href="/webhooks/delivery" icon="repeat">
    The retry ladder, timeouts, and at-least-once semantics behind these rules.
  </Card>

  <Card title="Events & Payloads" href="/webhooks/events" icon="braces">
    Field-by-field reference for the `transfer.received` payload.
  </Card>

  <Card title="Quickstart" href="/webhooks/quickstart" icon="rocket">
    Subscribe, provision a secret, and receive your first event.
  </Card>
</CardGroup>
