> ## Documentation Index
> Fetch the complete documentation index at: https://skinshark.gg/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Create payout withdrawal

> **Auth context:** merchant — **API key only** (JWT not accepted; this is the one
write endpoint in the payout group). Requires `cryptoPayoutEnabled=true`, a
provisioned webhook signing secret, and at least one active `CallbackUrl`
registered for the merchant.

> **Idempotent on `externalId`.** Retrying the same `externalId` returns the
> **original** withdrawal unchanged (idempotent replay) — it never double-spends
> or errors. The `1824` conflict is only returned on a genuine concurrent duplicate
> racing the first create.

> **Withdrawals are per (chain, token), not from a single pool.** SkinShark does
> not hold cross-chain liquidity. You can only withdraw from a chain × token
> combination you previously deposited to. Depositing USDC on Base and asking to
> withdraw USDC on Ethereum will fail with `1822 CRYPTO_PAYOUT_INSUFFICIENT_BALANCE`
> even if you have plenty of USDC on Base. Use `GET /payout/crypto/balances` to
> see what's withdrawable on each chain.

Flow:
1. Live fee computed; rejected with `1821` if `maxFeeUsdCents` is set and the live
   fee exceeds it. No state mutated.
2. Atomic transaction: sidecar decremented by `amountCents + liveFeeUsdCents`
   (rejected with `1822` if insufficient), `payout_withdraw_lock` ledger posting,
   withdrawal row created in `pending_callback`.
3. Synchronous **approval call**: a signed POST of type
   **`payout.crypto.withdraw.approval`** is sent to your `CallbackUrl` (5s timeout),
   separate from the async lifecycle queue. Return **2xx to approve** (release funds)
   or **4xx to reject**. The request carries `webhook-id`, `webhook-timestamp`, and
   `webhook-signature` headers — the signature is **always present** (payouts require
   a provisioned signing secret), so verify it before acting.
4. On 2xx → `queued` → `broadcast` → `confirmed`. On 4xx → immediate refund. On
   5xx/timeout/network → retry up to 3 times, then refund. Any rejection, failure,
   or on-chain revert resolves to the single terminal status `refunded`.
5. Lifecycle events (`payout.crypto.withdraw.broadcast`, `.confirmed`, and the
   single terminal `.refunded`) are delivered asynchronously via the standard signed
   webhook queue. There is no separate `.failed` delivery.




## OpenAPI

````yaml /openapi.yaml post /user/wallet/payout/crypto/withdraw
openapi: 3.1.0
info:
  title: SkinShark Merchant API
  description: >
    REST + WebSocket surface that you call **server-to-server** with your API
    key.

    Use it to manage sub-users, query wallets and trades, and act on behalf of
    any

    of your sub-users from a single key.


    ## Authentication


    Send your API key on every request:


    ```

    api-key: <your-key>

    ```


    Keys are hashed at rest and may be restricted to specific source IPs.
    Requests

    from a non-allowlisted IP are rejected with `API_KEY_IP_DENIED`.


    ### Acting on behalf of a sub-user


    For routes **outside** `/merchant/...`, add a header to scope the call to a

    specific sub-user:


    ```

    On-Behalf-Of: <subUserId | externalId>

    ```


    Both the UUID and the `externalId` you assigned are accepted; the server

    resolves either, scoped to your merchant. A UUID that belongs to a different

    merchant returns `USER_NOT_FOUND`.


    Without `On-Behalf-Of`, calls run **as your merchant account itself**. For

    example, `POST /market/buy` without the header buys for the merchant — its

    own spot wallet, its own Steam trade URL.


    ### Route groups


    | Prefix | What it does | `On-Behalf-Of` |

    |---|---|---|

    | `/merchant/...` | Merchant-level reads/writes (sub-users, trades
    aggregation, wallet, profile, fees). | Rejected — sub-users do not hold the
    merchant role. |

    | Everything else (`/user/...`, `/market/...`, `/auth/ws-token`) |
    User-scope operations. | Accepted — call runs as the targeted sub-user. |


    Each operation states the required context under **Auth context**.


    ## Dashboard-only operations


    A few account-management operations are not exposed to API key auth — they

    require signing in to the merchant dashboard:


    - Creating, rotating, and revoking API keys

    - Configuring webhook endpoints, secrets, and inspecting deliveries

    - Adjusting merchant fees

    - Account settings (password, email, 2FA, account deletion)

    - Audit logs and CSV exports


    Everything else in this document is available via API key.


    ## Response envelope


    Every JSON response is wrapped. Successful responses:


    ```json

    {
      "requestId": "req-...",
      "success": true,
      "data": { }
    }

    ```


    Error responses:


    ```json

    {
      "requestId": "req-...",
      "success": false,
      "error": {
        "code": 1500,
        "key": "INSUFFICIENT_BALANCE",
        "message": "Insufficient balance"
      }
    }

    ```


    Schemas below describe only the inner `data` shape — the envelope is
    implicit.


    ## Money format


    - Fields suffixed `Cents` are integer cents as JSON numbers
      (`15000` = $150.00).
    - Decimal-currency fields like `balance`, `totalPrice`, `gmv` are JSON
    numbers
      in the wallet currency (`5.23` = $5.23 in a USD wallet).
    - Amount **inputs** (e.g. `fund`, deposit `amount`) accept decimal strings
      (`"5.50"`) to avoid floating-point loss on the wire.

    ## Pagination


    Two styles are used, documented per endpoint:


    - **Page-based** — `?page=1&limit=25`. Response contains
      `{ items|users, total, page, limit, totalPages }`.
    - **Cursor-based** — `?cursor=<id>&limit=25`. Response contains
      `{ items|trades|transactions, nextCursor }`. `nextCursor` is `null` on the
      last page.

    ## Idempotency


    `POST /merchant/users/{id}/fund` requires an `Idempotency-Key` header.
    Replays

    with the same key return `idempotent: true` alongside the original

    transaction id, so retries are safe.


    ## Sub-user identifiers


    Anywhere a sub-user is referenced — the `On-Behalf-Of` header, `{id}` path

    params under `/merchant/users/...`, the `subUserId` filter on trade lists —

    the value can be **either**:


    - the sub-user's UUID, or

    - the `externalId` you assigned.


    The server resolves both, scoped to your merchant.
  version: 0.4.3
  contact:
    name: SkinShark Engineering
    email: support@skinshark.gg
  license:
    name: Proprietary
    url: https://skinshark.gg/terms
servers:
  - url: https://api.skinshark.gg
    description: Production
  - url: https://api-staging.skinshark.gg
    description: Staging
security:
  - apiKeyAuth: []
tags:
  - name: Account
    description: Merchant identity, fees, stats, wallet, ledger. Merchant context only.
  - name: Users
    description: >-
      Sub-user CRUD plus per-user wallet, ledger, trades, and funding. Merchant
      context only.
  - name: Trades
    description: Aggregate trade list across all sub-users. Merchant context only.
  - name: Profile
    description: >-
      Read the actor's profile (Steam link, Discord link, wallet snapshot).
      Merchant or sub-user context.
  - name: Trade URLs
    description: Manage Steam trade URLs for the actor. Merchant or sub-user context.
  - name: Wallet
    description: Actor's spot balance and ledger. Merchant or sub-user context.
  - name: Deposits
    description: >-
      Fund the actor's wallet via Gate Pay, on-ramp (card), or self-hosted EVM
      crypto. Merchant or sub-user context.
  - name: Partner Payout Custody
    description: >
      Separate per-merchant USD-denominated crypto custody (USDT/USDC at MVP).
      Funds deposited here

      are **not** spendable in the spot/skin-buying pipeline; they're
      withdrawable on-chain to any

      partner-supplied destination. All withdrawals require a synchronous
      approval callback to the

      merchant's registered `CallbackUrl` and a partner-supplied `externalId`
      for idempotency.

      Toggleable per merchant (`cryptoPayoutEnabled`).
  - name: Market
    description: >-
      Catalog search, listings, buy / quick-buy, and the actor's own trades.
      Merchant or sub-user context.
  - name: WebSocket
    description: >-
      Real-time trade and deposit events for a single sub-user
      (consumer-facing). Sub-user context only.
paths:
  /user/wallet/payout/crypto/withdraw:
    post:
      tags:
        - Partner Payout Custody
      summary: Create payout withdrawal
      description: >
        **Auth context:** merchant — **API key only** (JWT not accepted; this is
        the one

        write endpoint in the payout group). Requires
        `cryptoPayoutEnabled=true`, a

        provisioned webhook signing secret, and at least one active
        `CallbackUrl`

        registered for the merchant.


        > **Idempotent on `externalId`.** Retrying the same `externalId` returns
        the

        > **original** withdrawal unchanged (idempotent replay) — it never
        double-spends

        > or errors. The `1824` conflict is only returned on a genuine
        concurrent duplicate

        > racing the first create.


        > **Withdrawals are per (chain, token), not from a single pool.**
        SkinShark does

        > not hold cross-chain liquidity. You can only withdraw from a chain ×
        token

        > combination you previously deposited to. Depositing USDC on Base and
        asking to

        > withdraw USDC on Ethereum will fail with `1822
        CRYPTO_PAYOUT_INSUFFICIENT_BALANCE`

        > even if you have plenty of USDC on Base. Use `GET
        /payout/crypto/balances` to

        > see what's withdrawable on each chain.


        Flow:

        1. Live fee computed; rejected with `1821` if `maxFeeUsdCents` is set
        and the live
           fee exceeds it. No state mutated.
        2. Atomic transaction: sidecar decremented by `amountCents +
        liveFeeUsdCents`
           (rejected with `1822` if insufficient), `payout_withdraw_lock` ledger posting,
           withdrawal row created in `pending_callback`.
        3. Synchronous **approval call**: a signed POST of type
           **`payout.crypto.withdraw.approval`** is sent to your `CallbackUrl` (5s timeout),
           separate from the async lifecycle queue. Return **2xx to approve** (release funds)
           or **4xx to reject**. The request carries `webhook-id`, `webhook-timestamp`, and
           `webhook-signature` headers — the signature is **always present** (payouts require
           a provisioned signing secret), so verify it before acting.
        4. On 2xx → `queued` → `broadcast` → `confirmed`. On 4xx → immediate
        refund. On
           5xx/timeout/network → retry up to 3 times, then refund. Any rejection, failure,
           or on-chain revert resolves to the single terminal status `refunded`.
        5. Lifecycle events (`payout.crypto.withdraw.broadcast`, `.confirmed`,
        and the
           single terminal `.refunded`) are delivered asynchronously via the standard signed
           webhook queue. There is no separate `.failed` delivery.
      operationId: createPayoutCryptoWithdrawal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutWithdrawBody'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/PayoutWithdrawResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `1820 CRYPTO_PAYOUT_NOT_ENABLED` — merchant flag is off.
        '409':
          description: >
            `1824 CRYPTO_PAYOUT_DUPLICATE_EXTERNAL_ID` — only on a **genuine
            concurrent

            duplicate** racing the first create. A normal retry of the same
            `externalId`

            returns the original withdrawal with `200` (idempotent replay), not
            this error.
        '422':
          description: >
            One of:

            - `1010 WITHDRAWAL_DAILY_LIMIT` — payout counts against the daily
            withdrawal cap.

            - `1504 INVALID_AMOUNT` — `amountCents` is not a positive value.

            - `1803 CRYPTO_TOKEN_NOT_SUPPORTED` — token not in MVP allow-list
            for the chain.

            - `1806 CRYPTO_INVALID_DESTINATION` — destination is zero address,
            treasury,
              or one of the merchant's own forwarders.
            - `1821 CRYPTO_PAYOUT_FEE_EXCEEDS_MAX` — live fee >
            `maxFeeUsdCents`.

            - `1822 CRYPTO_PAYOUT_INSUFFICIENT_BALANCE` — sidecar < `amountCents
            + fee`.

            - `1823 CRYPTO_PAYOUT_NO_CALLBACK_URL` — no active `CallbackUrl`
            registered.

            - `1825 CRYPTO_PAYOUT_INVALID_SUBUSER` — `forSubUser` doesn't
            resolve to a
              sub-user of the calling merchant.
            - `1827 CRYPTO_PAYOUT_NO_SIGNING_KEY` — a webhook signing secret
            must be
              provisioned before requesting payouts (the approval call must be signable).
        '503':
          description: >
            `1826 CRYPTO_PAYOUT_FEE_UNAVAILABLE` — the gas/price oracle is down
            on a

            fee chain; retry shortly. (Also `1800 CRYPTO_NOT_CONFIGURED` if the
            crypto

            gateway is not configured.)
components:
  schemas:
    PayoutWithdrawBody:
      type: object
      required:
        - chain
        - token
        - destination
        - amountCents
        - externalId
      additionalProperties: false
      properties:
        chain:
          $ref: '#/components/schemas/CryptoEvmChain'
        token:
          $ref: '#/components/schemas/PayoutCryptoToken'
        destination:
          type: string
          pattern: ^0x[a-fA-F0-9]{40}$
          description: EVM address that will receive the funds on-chain.
        amountCents:
          type: string
          pattern: ^\d+$
          description: >-
            USD cents to send to the destination. Fee is added on top and
            debited together.
        externalId:
          type: string
          minLength: 1
          maxLength: 128
          description: >
            Partner-supplied reference. Echoed in the approval callback and all
            lifecycle events so

            you can match against your own records. Must be unique per merchant;
            duplicate ⇒ `1824`.
        forSubUser:
          type: string
          minLength: 1
          maxLength: 128
          description: >
            Optional label — sub-user `id` (UUID) or your `externalId` for that
            sub-user, scoped

            to the calling merchant. **Pure label, not auth scope:** funding
            always comes from

            the merchant's payout custody; the sub-user reference is recorded
            for audit and

            echoed in the approval callback / lifecycle events.
        maxFeeUsdCents:
          type: string
          pattern: ^\d+$
          description: >
            Optional fee cap. If the live fee at submission exceeds this cap,
            the request is

            rejected with `1821` and no state is mutated. Useful for declining
            high-gas moments.
    Envelope:
      type: object
      required:
        - requestId
        - success
      properties:
        requestId:
          type: string
        success:
          type: boolean
        data:
          description: 'Present when `success: true`. Shape varies per endpoint.'
        error:
          $ref: '#/components/schemas/Error'
    PayoutWithdrawResponse:
      type: object
      required:
        - id
        - status
        - chain
        - token
        - destination
        - amountCents
        - feeCents
        - externalId
        - forUserId
        - forUserExternalId
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/PayoutWithdrawalStatus'
        chain:
          type: string
        token:
          type: string
        destination:
          type: string
        amountCents:
          type: string
        feeCents:
          type: string
        externalId:
          type: string
        forUserId:
          type:
            - string
            - 'null'
          format: uuid
        forUserExternalId:
          type:
            - string
            - 'null'
        createdAt:
          type: string
          format: date-time
    CryptoEvmChain:
      type: string
      enum:
        - ethereum
        - base
        - arbitrum
        - optimism
        - bsc
    PayoutCryptoToken:
      type: string
      enum:
        - USDT
        - USDC
      description: Stables only at MVP.
    Error:
      type: object
      required:
        - code
        - key
        - message
      properties:
        code:
          type: integer
          example: 1500
        key:
          type: string
          example: INSUFFICIENT_BALANCE
        message:
          type: string
          example: Insufficient balance
      additionalProperties: true
    PayoutWithdrawalStatus:
      type: string
      enum:
        - pending_callback
        - queued
        - broadcast
        - confirmed
        - refunded
      description: >
        Lifecycle: `pending_callback` → (approval callback returns 2xx) →
        `queued` →

        `broadcast` → `confirmed`. Any rejection, failure, or on-chain revert
        resolves to

        `refunded` (funds returned to custody: sidecar re-credited, ledger
        reversed).

        `refunded` is the single terminal failure state — there is no separate
        `failed` step.
  responses:
    Unauthorized:
      description: >
        Missing/invalid credentials. `key` is one of `UNAUTHORIZED`,
        `MISSING_API_KEY`,

        `INVALID_API_KEY`, `API_KEY_REVOKED`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: api-key
      description: |
        Your raw API key. Generate, rotate, and revoke keys from the merchant
        dashboard. Keys can optionally be bound to one or more allowed source
        IPs — requests from any other IP are rejected with `API_KEY_IP_DENIED`.

````