Skip to main content
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 — webhooks for state-of-record, sockets for UX. The headers and signature scheme follow Standard Webhooks, 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 — terminal failure (insufficient supply, marketplace decline, etc.).
  • 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.
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.approvalsynchronous 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:
PropertyApproval callbackAll other events
Body shapeSame { id, type, createdAt, data } envelopeSame
Headers + signaturewebhook-id / webhook-timestamp / webhook-signature, HMAC-SHA256Same
DeliveryDirect HTTP POST from the workerQueued via the standard webhook processor
Timeout5 seconds10 seconds + retries
Required response2xx within timeout2xx eventually, retried otherwise
Failure modeExplicit 4xx → immediate refund. 5xx/timeout/network → up to 3 retries, then refund.Retried 11 times over ~15h
PurposeGate 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 queuedbroadcast.
  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

{
  "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:
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 };
      }
    | { 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. Settlement events (trade.settled, trade.completed) additionally include the fee breakdown. Deposit events carry a serialised deposit (gateway or self-hosted crypto fields, depending on method).

Delivery headers

HeaderPurpose
content-type: application/jsonAlways.
user-agent: SkinShark-Webhooks/1.0Identifies us.
webhook-idDelivery UUID — use this for idempotency on your side. Stable across retries of the same event.
webhook-timestampUnix epoch seconds at the moment of signing.
webhook-signaturet=<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

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:
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.
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.

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.
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

WebhooksWebSocket
Where it landsYour HTTPS endpointBrowser / app
ReliabilityRetried 11× over ~15h, auto-disable on 72h continuous failureBest-effort while connected
LatencySeconds (queued)Sub-second
IdempotencyBuilt in (webhook-id reused)Reconnect logic on you
Use forOrder state-of-record, accountingUI updates, progress bars
Most production setups use both: webhook fires source-of-truth, WS pushes the same event for UI snappiness.