Mental model
Hard constraints
Prerequisites
- Feature flag. Your merchant account needs
cryptoPayoutEnabled: true(admin-controlled). Without it, all/payout/crypto/*endpoints return1820 CRYPTO_PAYOUT_NOT_ENABLED. - Callback URL registered. At least one active
CallbackUrlon 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
2. Fund the address
Send USDT or USDC on any supported chain. Once confirmed:- A
payout_creditledger transaction is posted. - The corresponding per-(chain, token) sidecar row is incremented.
- We emit
payout.crypto.deposit.completedto your callback URL.
3. List balances
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 is0.
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.
Response:
pending_callback — funds debited from your sidecar
- a
payout_withdraw_lockledger 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:
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_refundledger entry posted. - 5xx / timeout / network error → we retry up to 3 times with backoff. After the 3rd attempt fails, refund.
Example handler
Lifecycle events
After approval (or rejection), you get standard signed webhooks for the lifecycle — same envelope as the approval callback:
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):status,
chain, and forUserId (the resolved sub-user UUID — not the externalId
you supplied at create).
Edge cases
- Insufficient (chain, token) balance. Returns
1822before any state is touched. Per-(chain, token) sidecar gating; no draws from other chains. - Live fee exceeds your
maxFeeUsdCents. Returns1821. Nothing locked, no callback fired. Submit again when gas drops. - Duplicate
externalId. Returns1824. Pick a new value. Idempotency on this key is by design — re-submitting the sameexternalIdis not retried, it’s rejected so you can detect bugs in your dispatcher. - Callback URL missing or all disabled. Returns
1823at submit time. Re-register a URL, then submit. forSubUserdoesn’t exist or isn’t yours. Returns1825. The lookup is scoped to yourparentId— you can only tag your own children.- Sub-user suspended / soft-deleted. Allowed.
forUserIdis 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.completedand 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 samerefundedoutcome.) - Re-submit the same
externalId→ returns the original withdrawal (idempotent replay), not a new one or an error. maxFeeUsdCents: "1"→1821.amountCentslarger than your (chain, token) balance →1822.