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:
- Webhooks tab → set the callback URL.
- Webhook secret tab → create a secret. The raw secret is shown
once at creation; the server stores only an encrypted copy.
- 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.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:
- Verify the signature (same scheme as every other event).
- Look up
data.withdrawal.externalId in your records. If you didn’t create this withdrawal — respond 4xx immediately to refund.
- If you did create it, confirm
destination, amountCents, and forUserExternalId match what you expect.
- Return 2xx within 5 seconds → the worker proceeds to
queued → broadcast.
- 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).
| 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
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
| 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.