Partner Payout Custody is a second wallet behind your merchant account
that’s intentionally walled off from the spot/skin-buying pipeline. You
deposit USDT or USDC; we hold it; you instruct withdrawals to any EVM
address. Useful when you want to use SkinShark as the crypto rail for your
own user payouts without commingling funds with skin-trading flows.
Mental model
Hard constraints
Withdrawals are per (chain, token), not from a single pool. SkinShark
holds no cross-chain liquidity. If you deposit USDC on Base, you can only
withdraw it on Base — not Ethereum, not Arbitrum. Each (chain, tokenAddress)
is an independent bucket. Use GET /payout/crypto/balances
to see what’s available where.
The withdrawal endpoint is API-key only. The read endpoints
(/address, /balances, /withdraw/quote, list, detail) accept JWT
too so the merchant dashboard can show state, but submitting a withdrawal
must come from your backend.
Prerequisites
- Feature flag. Your merchant account needs
cryptoPayoutEnabled: true
(admin-controlled). Without it, all /payout/crypto/* endpoints return
1820 CRYPTO_PAYOUT_NOT_ENABLED.
- Callback URL registered. At least one active
CallbackUrl on your
merchant — this is where the approval callback and lifecycle events land.
Use the dashboard’s Webhooks tab.
- Webhook secret registered. Approval callbacks and lifecycle events are
signed the same way as every other event (Webhooks).
Verify them.
Setup
1. Allocate the payout forwarder
GET /user/wallet/payout/crypto/address
api-key: sk_live_...
Returns a CREATE2-derived EVM address valid on all supported chains.
This address is distinct from your spot deposit forwarder. Sending
funds to the spot forwarder credits your spot wallet (and funds skin
buys); sending to the payout forwarder credits your partner custody.
Don’t confuse them.
{
"address": "0x6b3a...c41e",
"chains": ["ethereum", "base", "arbitrum", "optimism", "bsc"],
"tokens": ["USDT", "USDC"]
}
2. Fund the address
Send USDT or USDC on any supported chain. Once confirmed:
- A
payout_credit ledger transaction is posted.
- The corresponding per-(chain, token) sidecar row is incremented.
- We emit
payout.crypto.deposit.completed to your callback URL.
There’s a $1 minimum per deposit (gas-aware floor — below this we don’t
credit because the sweep economics break down).
3. List balances
GET /user/wallet/payout/crypto/balances
{
"balances": [
{ "chain": "base", "token": "USDC", "tokenAddress": "0x833589fc...", "balanceCents": "1024750" },
{ "chain": "ethereum", "token": "USDC", "tokenAddress": "0xa0b86991...", "balanceCents": "0" }
]
}
balanceCents is USD cents as a bigint string. One row per chain × token
combination you’ve ever deposited to.
Withdrawing
Preview the fee first
The withdrawal fee reflects current network gas. On L2 chains (Base,
Arbitrum, Optimism) the fee is 0.
POST /user/wallet/payout/crypto/withdraw/quote
Content-Type: application/json
{ "chain": "ethereum", "token": "USDC", "amountCents": "100000" }
{
"chain": "ethereum",
"token": "USDC",
"amountCents": "100000",
"liveFeeUsdCents": "247",
"liveTotalDebitCents": "100247",
"stats24h": {
"minFeeUsdCents": "92",
"p25FeeUsdCents": "139",
"avgFeeUsdCents": "219",
"p75FeeUsdCents": "276",
"maxFeeUsdCents": "640"
},
"computedAt": "2026-05-18T12:34:56.789Z"
}
The stats24h block lets you decide whether now is a cheap moment vs
typical. Quote is advisory — no state mutated.
Submit the withdrawal
Requires API key (api-key header). JWT auth is rejected on this one
endpoint. This is the only mutating action in the payout group.
POST /user/wallet/payout/crypto/withdraw
api-key: sk_live_...
Content-Type: application/json
{
"chain": "ethereum",
"token": "USDC",
"destination": "0xRECIPIENT...",
"amountCents": "100000",
"externalId": "payout-2026-05-18-001",
"forSubUser": "your-end-user-id",
"maxFeeUsdCents": "500"
}
| Field | Notes |
|---|
chain, token | The bucket you’re drawing from. Must match a sidecar row with enough balance. |
destination | Any valid EVM address. Cannot be your own forwarder or the SkinShark treasury. |
amountCents | USD cents to send to destination. Fee is added on top. |
externalId | Required. Your idempotency key. Must be unique per merchant. We echo this back in the approval callback so you can match against your records. |
forSubUser | Optional label. Sub-user UUID or your externalId for them. Funds always come from your merchant custody — this just tags the withdrawal for your audit/reporting. |
maxFeeUsdCents | Optional cap. If live fee exceeds this, returns 1821 and nothing happens. |
Response:
{
"id": "8a3f...",
"status": "pending_callback",
"chain": "ethereum",
"token": "USDC",
"destination": "0xrecipient...",
"amountCents": "100000",
"feeCents": "247",
"externalId": "payout-2026-05-18-001",
"forUserId": "uuid-of-sub-user",
"forUserExternalId": "your-end-user-id",
"createdAt": "2026-05-18T12:34:57.123Z"
}
The withdrawal is now pending_callback — funds debited from your sidecar
- a
payout_withdraw_lock ledger entry posted — and we’re about to ping
your callback URL.
The approval callback (the 2FA gate)
Within ~1 second of accepting your /withdraw request, our worker POSTs
to your registered callback URL. The body uses the same envelope shape
as every other webhook event:
{
"id": "01HF3K6...",
"type": "payout.crypto.withdraw.approval",
"createdAt": "2026-05-18T12:34:57.123Z",
"data": {
"withdrawal": {
"id": "8a3f...",
"userId": "...",
"externalId": "payout-2026-05-18-001",
"forUserId": "uuid-of-sub-user",
"forUserExternalId": "your-end-user-id",
"status": "pending_callback",
"chain": "ethereum",
"token": "USDC",
"tokenAddress": "0xa0b86991...",
"destination": "0xrecipient...",
"amountCents": 100000,
"feeCents": 247,
"amountWei": "100000000",
"txHash": null,
"failureReason": null,
"callbackAttempts": 0,
"createdAt": "2026-05-18T12:34:57.123Z",
"broadcastAt": null,
"confirmedAt": null
}
}
}
Signature: same webhook-id / webhook-timestamp / webhook-signature
scheme as every other event. Verify on the raw body. Because the envelope
matches, one verifier handles approval and lifecycle events alike — dispatch
on event.type.
Your job: look up data.withdrawal.externalId in your records. Confirm
you created this withdrawal with these exact parameters. Return:
- 2xx within 5 seconds → withdrawal proceeds to
queued → broadcast → confirmed.
- 4xx → withdrawal is immediately refunded. Sidecar restored.
payout_withdraw_refund ledger entry posted.
- 5xx / timeout / network error → we retry up to 3 times with backoff. After the 3rd attempt fails, refund.
This is your hard authentication boundary. Treat it like a 2FA prompt.
Example handler
import express from "express";
import { verifyWebhook, isError } from "@skinshark/sdk";
app.post(
"/webhooks/skinshark",
express.raw({ type: "application/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;
}
if (event.type === "payout.crypto.withdraw.approval") {
const w = event.data.withdrawal;
const ourRecord = await db.payouts.findUnique({
where: { externalId: w.externalId },
});
if (!ourRecord) return res.status(404).end(); // unknown → refund
if (
ourRecord.destination.toLowerCase() !== w.destination.toLowerCase() ||
ourRecord.amountCents !== w.amountCents ||
ourRecord.chain !== w.chain ||
ourRecord.token !== w.token
) {
return res.status(400).end(); // mismatch → refund
}
return res.status(200).end(); // authorize
}
// ... other events (lifecycle, deposit.completed)
return res.status(200).end();
},
);
Lifecycle events
After approval (or rejection), you get standard signed webhooks for the
lifecycle — same envelope as the approval callback:
| Event | Fired when | Carries |
|---|
payout.crypto.deposit.completed | A deposit credited your payout custody | data.deposit + data.forUserExternalId |
payout.crypto.withdraw.broadcast | On-chain transaction submitted | data.withdrawal with txHash |
payout.crypto.withdraw.confirmed | Final confirmation reached | data.withdrawal with txHash |
payout.crypto.withdraw.refunded | Any rejection, failure, or on-chain revert — sidecar re-credited, ledger reversed. The single terminal failure event (no separate .failed). | data.withdrawal with failureReason |
These follow the standard webhook delivery rules from
Webhooks: 11 retries over ~15h, auto-disable on 72h
continuous failure, signed with your webhook secret.
The synchronous approval callback uses the same body and signing scheme
but bypasses the retry queue: the worker POSTs directly, 5 s timeout,
3 retries on transient failure, then refund. See the Webhooks guide for
the full comparison table.
Polling status as a fallback
If you’d rather poll than rely on webhooks (or want to double-check):
GET /user/wallet/payout/crypto/withdrawals/{id}
GET /user/wallet/payout/crypto/withdrawals?status=broadcast&chain=ethereum&limit=50
The list endpoint supports cursor pagination, plus filtering by status,
chain, and forUserId (the resolved sub-user UUID — not the externalId
you supplied at create).
Edge cases
- Insufficient (chain, token) balance. Returns
1822 before any state
is touched. Per-(chain, token) sidecar gating; no draws from other chains.
- Live fee exceeds your
maxFeeUsdCents. Returns 1821. Nothing locked,
no callback fired. Submit again when gas drops.
- Duplicate
externalId. Returns 1824. Pick a new value. Idempotency
on this key is by design — re-submitting the same externalId is not
retried, it’s rejected so you can detect bugs in your dispatcher.
- Callback URL missing or all disabled. Returns
1823 at submit time.
Re-register a URL, then submit.
forSubUser doesn’t exist or isn’t yours. Returns 1825. The lookup
is scoped to your parentId — you can only tag your own children.
- Sub-user suspended / soft-deleted. Allowed.
forUserId is a label, not
an auth scope — the withdrawal still goes through.
- Reorg reverses a payout deposit. Pre-existing pipeline gap; we don’t
currently issue reversal postings on reorg. Track this in the SkinShark
admin treasury views and reach out if it affects you.
Verification checklist
After integrating, run these against the testnet:
- Allocate the payout forwarder. Confirm it differs from
/deposit/crypto/address.
- Send testnet USDC. Watch for
payout.crypto.deposit.completed and a sidecar row.
- Submit to the spot forwarder simultaneously — confirm that one credits the
spot wallet, not the payout custody. (Routing is by which forwarder
received funds — the flag never re-routes in-flight deposits.)
- Quote → withdraw with a mock callback returning 200. Status progresses
queued → broadcast → confirmed. Tx visible at destination.
- Quote → withdraw with a mock callback returning 403 → immediate
refunded, sidecar restored, payout wallet balance restored. (A 5xx /
timeout retries up to 3 times before the same refunded outcome.)
- Re-submit the same
externalId → returns the original withdrawal
(idempotent replay), not a new one or an error.
maxFeeUsdCents: "1" → 1821.
amountCents larger than your (chain, token) balance → 1822.