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

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

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

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:

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.

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

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