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

# Webhooks

> Server-to-server delivery of trade and deposit events using the Standard Webhooks signature scheme, with bounded retries and auto-disable on continuous failure.

Webhooks are how SkinShark notifies your server when something happens to
a trade or deposit. They're durable: failed deliveries are retried with
backoff. For low-latency in-app updates, pair them with
[WebSocket](/docs/guides/websocket) — webhooks for state-of-record, sockets
for UX.

The headers and signature scheme follow [Standard Webhooks](https://www.standardwebhooks.com/),
so any Standard Webhooks library will verify our payloads out of the box.

## Configuring

Webhook URLs and signing secrets are dashboard-only operations
(API-key auth can't change them). In your merchant dashboard:

1. **Webhooks** tab → set the callback URL.
2. **Webhook secret** tab → create a secret. The raw secret is shown
   once at creation; the server stores only an encrypted copy.
3. Optionally hit "Test" to fire a one-shot delivery against the URL.

## Event types

Trade lifecycle:

* `trade.initiated` — buy accepted, escrow held, items not yet placed at the marketplace.
* `trade.pending` — marketplace worker successfully placed the order; awaiting fulfillment.
* `trade.active` — marketplace is processing/delivering.
* `trade.hold` — items received, holding for the dispute window.
* `trade.completed` — hold released, trade settled.
* `trade.failed` — couldn't be placed/delivered (insufficient supply, marketplace decline, etc.). Each item's `error` carries a `TradeFailureCode`; user fully refunded.
* `trade.canceled` — canceled before delivery (by the platform, or by the user while still `pending`); user fully refunded, no penalty.
* `trade.declined` — buyer's fault after the Steam offer went out (declined or let it expire); user refunded minus a 2% penalty.
* `trade.reverted` — items recalled or refunded after the marketplace accepted.
* `trade.settled` — fee/earnings ledger postings completed (paired with `trade.completed`).
* `trade.refunded` — full or partial refund posted to the user wallet; carries a per-item `refund` block (amount refunded + any buyer-fault penalty, in USD).

Deposit lifecycle:

* `deposit.initiated`
* `deposit.pending`
* `deposit.completed`
* `deposit.partial`
* `deposit.expired`
* `deposit.failed`
* `deposit.refunded`
* `deposit.cancelled`

Partner crypto payout custody:

* `payout.crypto.deposit.completed` — a deposit landed in the partner's payout custody (per-(chain, token) sidecar credited). **Distinct from `deposit.completed`** — the latter is for spot wallet credits.
* `payout.crypto.withdraw.approval` — **synchronous approval gate**, see below. Special delivery semantics.
* `payout.crypto.withdraw.broadcast` — withdrawal broadcast on-chain. Includes `txHash` and `nonce`.
* `payout.crypto.withdraw.confirmed` — final confirmation reached.
* `payout.crypto.withdraw.refunded` — the **single terminal failure event**: any approval rejection, broadcast failure, or on-chain revert re-credits the sidecar and restores the payout wallet. There is no separate `.failed` event.

## The approval callback — synchronous, not queued

`payout.crypto.withdraw.approval` uses the **same envelope, headers, and signature scheme** as
every other event — only the delivery semantics differ:

| Property            | Approval callback                                                                    | All other events                          |
| ------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------- |
| Body shape          | Same `{ id, type, createdAt, data }` envelope                                        | Same                                      |
| Headers + signature | `webhook-id` / `webhook-timestamp` / `webhook-signature`, HMAC-SHA256                | Same                                      |
| Delivery            | Direct HTTP POST from the worker                                                     | Queued via the standard webhook processor |
| Timeout             | **5 seconds**                                                                        | 10 seconds + retries                      |
| Required response   | **2xx within timeout**                                                               | 2xx eventually, retried otherwise         |
| Failure mode        | Explicit 4xx → immediate refund. 5xx/timeout/network → up to 3 retries, then refund. | Retried 11 times over \~15h               |
| Purpose             | **Gate** the withdrawal — your 2xx is the merchant-side "yes, I authorized this"     | Notify after-the-fact                     |

Because the envelope and signing match every other event, one handler with one Standard
Webhooks verifier can ingest approvals alongside lifecycle events — just dispatch on
`event.type === 'payout.crypto.withdraw.approval'` to apply the synchronous behavior below.

When your server receives `payout.crypto.withdraw.approval`:

1. Verify the signature (same scheme as every other event).
2. Look up `data.withdrawal.externalId` in your records. If you didn't create this withdrawal — respond **4xx immediately** to refund.
3. If you did create it, confirm `destination`, `amountCents`, and `forUserExternalId` match what you expect.
4. Return 2xx within 5 seconds → the worker proceeds to `queued` → `broadcast`.
5. Return 4xx → withdrawal is immediately refunded.

If you simply forgot to register a callback URL, the withdrawal fails with `1823 CRYPTO_PAYOUT_NO_CALLBACK_URL` at submission time — it never reaches this stage.

### Approval payload shape

```json theme={null}
{
  "id": "01HF3K6...",
  "type": "payout.crypto.withdraw.approval",
  "createdAt": "2026-05-18T12:34:56.000Z",
  "data": {
    "withdrawal": {
      "id": "8a3f...",
      "userId": "...",
      "externalId": "merchant-ref-001",
      "forUserId": "uuid-or-null",
      "forUserExternalId": "your-subuser-ref-or-null",
      "status": "pending_callback",
      "chain": "ethereum",
      "token": "USDC",
      "tokenAddress": "0xa0b8...",
      "destination": "0x...",
      "amountCents": 100000,
      "feeCents": 247,
      "amountWei": "100000000",
      "txHash": null,
      "failureReason": null,
      "callbackAttempts": 0,
      "createdAt": "2026-05-18T12:34:56.000Z",
      "broadcastAt": null,
      "confirmedAt": null
    }
  }
}
```

The async lifecycle events (`payout.crypto.withdraw.broadcast`, `.confirmed`,
`.refunded`) carry the **exact same** `data.withdrawal` shape — only `status`, `txHash`,
`broadcastAt`, `confirmedAt`, and `failureReason` differ across the lifecycle.

## Envelope

Every delivery body is JSON in this shape — including the synchronous approval callback:

```ts theme={null}
interface WebhookEnvelope {
  id: string;                 // delivery UUID — same value as `webhook-id` header
  type: string;               // event type, e.g. "trade.completed"
  createdAt: string;          // ISO-8601
  data:
    | {
        trade: Trade;                   // same shape as GET /merchant/trades/{id}
        settlement?: { houseFeeCents: number; merchantFeeCents: number };
        refund?: {                      // trade.refunded — per refunded item, USD
          itemId: string;               // matches TradeItem.id
          externalId?: string;          // your own per-item ref, when you supplied one
          buyerFault: boolean;
          refunded: number;             // returned to the wallet (sourcing + fee)
          penalty: number;              // withheld on buyer fault, else 0
        };
      }
    | { deposit: Deposit }
    | {
        deposit: Deposit;
        forUserExternalId: string | null;
      }                                  // payout.crypto.deposit.completed
    | {
        withdrawal: PayoutWithdrawal;    // shape mirrors GET /payout/crypto/withdrawals/{id}
      };                                 // payout.crypto.withdraw.{approval,broadcast,confirmed,refunded}
}
```

Trade events carry the same `Trade` object you'd get from the trade
detail endpoint — `id`, `status`, `currency`, `totalPrice`, full
`items` array, etc. The `items` array reflects the trade's state at
event-delivery time, including each item's price, marketplace,
delivery mode, status, and display metadata as it becomes available.
Each refunded item also carries an inline `refund` breakdown (`amount`,
`penalty`, `buyerFault`, with `amount + penalty == price`) on the item
itself — present on **every** trade event once the item is refunded, so
prefer it over the top-level `refund` block when reconciling.
Settlement events (`trade.settled`, `trade.completed`) additionally
include the fee breakdown. `trade.refunded` events additionally include a
`refund` block — the per-item breakdown of how much went back to the
wallet (`refunded`) and any buyer-fault `penalty` withheld, both in USD.
One `trade.refunded` is emitted per refunded item; match it to the item
via `refund.itemId`. Deposit events carry a serialised deposit (gateway
or self-hosted crypto fields, depending on method).

## Delivery headers

| Header                               | Purpose                                                                                         |
| ------------------------------------ | ----------------------------------------------------------------------------------------------- |
| `content-type: application/json`     | Always.                                                                                         |
| `user-agent: SkinShark-Webhooks/1.0` | Identifies us.                                                                                  |
| `webhook-id`                         | Delivery UUID — use this for idempotency on your side. Stable across retries of the same event. |
| `webhook-timestamp`                  | Unix epoch **seconds** at the moment of signing.                                                |
| `webhook-signature`                  | `t=<unix>,s=<base64url-hmac>` (and `s1=<old>` during secret rotation).                          |

`webhook-signature` is space-separated-value style: `t=...` is the
timestamp, `s=...` is the current-secret signature, and `s1=...` (when
present) is the previous-secret signature for the rotation window.

## Signature verification

The signature is HMAC-SHA256 over `<id>.<timestamp>.<raw_body>` keyed
with your secret, encoded as **base64url** (no padding).

**Verify on the raw bytes, before JSON parsing** — re-serialised JSON has
different whitespace and won't match.

### With `@skinshark/sdk`

```ts theme={null}
import express from "express";
import { verifyWebhook, isError } from "@skinshark/sdk";

const app = express();
const SECRET = process.env.SKINSHARK_WEBHOOK_SECRET!;

app.post(
  "/webhooks/skinshark",
  express.raw({ type: "application/json" }),     // raw bytes, not parsed JSON
  async (req, res) => {
    let event;
    try {
      event = verifyWebhook(req.body, req.headers, { secret: SECRET });
    } catch (e) {
      if (isError(e, "INVALID_SIGNATURE")) return res.status(401).end();
      throw e;
    }

    // event is typed as a discriminated WebhookEvent union.
    if (await alreadyProcessed(event.id)) return res.status(200).end();
    await handleEvent(event);
    await markProcessed(event.id);
    return res.status(200).end();
  },
);
```

`verifyWebhook` checks the timestamp tolerance (default 300s, configurable
via `toleranceSeconds`), validates the HMAC against `s=` and the rotation
slot `s1=`, and returns the parsed event. It throws `SkinsharkError` with
`key: "INVALID_SIGNATURE"` on any failure mode.

### Without the SDK

If you don't want the SDK runtime, the verification is \~30 lines of
`node:crypto`:

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import { Buffer } from "node:buffer";

interface VerifyResult {
  ok: boolean;
  reason?: "missing-header" | "stale-timestamp" | "bad-signature";
}

export function verifySkinSharkSignature(
  id: string | undefined,
  timestamp: string | undefined,
  signatureHeader: string | undefined,
  rawBody: Buffer,
  secret: string,
  toleranceSec = 300,
): VerifyResult {
  if (!id || !timestamp || !signatureHeader) return { ok: false, reason: "missing-header" };

  const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(skew) || skew > toleranceSec) return { ok: false, reason: "stale-timestamp" };

  // Header format: t=<ts>,s=<sig>[,s1=<sig>]
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );

  const candidates = [parts.s, parts.s1].filter(Boolean) as string[];
  if (candidates.length === 0) return { ok: false, reason: "bad-signature" };

  const signed = `${id}.${timestamp}.${rawBody.toString("utf8")}`;
  const expected = createHmac("sha256", secret).update(signed).digest("base64url");
  const a = Buffer.from(expected);

  for (const c of candidates) {
    const b = Buffer.from(c);
    if (a.length === b.length && timingSafeEqual(a, b)) return { ok: true };
  }
  return { ok: false, reason: "bad-signature" };
}
```

## Retries and disabling

* Deliveries enqueue immediately and run as a background processor.
* Failed deliveries (non-2xx, timeout, network error) retry on a fixed
  schedule — **11 attempts total** spread over \~14¾ hours per event:
  30s, 30s, 5m, 5m, 15m, 15m, 1h, 1h, 6h, 6h.
* After 11 attempts, the delivery is marked `exhausted` — no further
  retries for that event.
* The same `webhook-id` is reused on every retry of the same event.
* If your callback URL is failing **continuously for 72 hours** (no
  intervening success), the URL is auto-disabled. Fix the endpoint, then
  re-enable from the dashboard. Until then, new events are queued but
  not delivered.
* Use **resend** in the dashboard or via API to retry an exhausted /
  individual delivery on demand.

<Tip>
  Make your handler idempotent on `webhook-id`. Persist the processed
  event before acking and skip duplicates. The simplest pattern: a unique
  constraint on `webhook_id` in your inbox table.
</Tip>

## Testing locally

Use a public tunnel (ngrok, Cloudflare Tunnel) to expose your local
handler, set the URL in the dashboard, then hit "Test" to fire a
synthetic event — or trigger a real one with a tiny buy.

```bash theme={null}
ngrok http 3000
# Set dashboard webhook URL to: https://<id>.ngrok-free.app/webhooks/skinshark
```

## Inspecting deliveries

The merchant dashboard's **Webhooks → Deliveries** tab lists every
attempt with the request payload, response status, response headers,
truncated response body, and error if any. Useful for debugging
signature verification because you can see exactly what we sent.

## Webhooks vs WebSocket

|                | Webhooks                                                       | WebSocket                   |
| -------------- | -------------------------------------------------------------- | --------------------------- |
| Where it lands | Your HTTPS endpoint                                            | Browser / app               |
| Reliability    | Retried 11× over \~15h, auto-disable on 72h continuous failure | Best-effort while connected |
| Latency        | Seconds (queued)                                               | Sub-second                  |
| Idempotency    | Built in (`webhook-id` reused)                                 | Reconnect logic on you      |
| Use for        | Order state-of-record, accounting                              | UI updates, progress bars   |

Most production setups use both: webhook fires source-of-truth, WS pushes
the same event for UI snappiness.
