Developers

SkinsFlow API

Accept skins with one API call, follow every payment with signed webhooks, and send skins out from your balance. REST over HTTPS, JSON in and out.

Introduction

The SkinsFlow API lets your website accept CS2, Dota 2 and Rust skins as payment, and spend the resulting balance on skins for your customers. Every request is made to one base URL:

https://api.skinsflow.com/v1

You need a SkinsFlow account and a project (one per website). Each project has its own API keys, webhook URL, bonus multiplier and USD balance. You can create projects and keys right away; live payments start once your business is verified.

Authentication

Authenticate every request with your project's credentials in two headers. Find them in the dashboard on your project's page.

HeaderValue
X-Client-IdYour project's client ID, sf_…. Not secret.
X-Client-SecretYour project's secret, sk_live_…. Shown once when created; rotate it in the dashboard.
Example
curl https://api.skinsflow.com/v1/project \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Keep the secret on your server. Never put it in browser or app code, and never commit it. If it leaks, rotate it in the dashboard; the old secret stops working at once.

You can restrict a project to your server IPs (single addresses or CIDR ranges) in its settings. Requests from other IPs get 403 ip_not_allowed.

Requests & responses

  • Send POST bodies as JSON with Content-Type: application/json (recommended) or as form fields.
  • Responses are always JSON, with Cache-Control: no-store.
  • Every object has an object field and a prefixed ID: dep_… deposits, pur_… purchases, whd_… webhook deliveries, prj_… projects.
  • Times are unix timestamps in seconds (UTC).
  • Fields ending in _usd are US dollars. amount fields on a deposit are in the deposit's currency.
  • Games are cs2, dota2 and rust.

List endpoints accept page (from 1) and limit (1–100, default 25) and return:

{
    "object": "list",
    "data": [
        "…"
    ],
    "page": 1,
    "limit": 25,
    "total": 123,
    "has_more": true
}

Errors

Errors use HTTP status codes and a JSON body with a stable, machine-readable code, a human message and, for validation errors, the param at fault.

{
    "error": {
        "code": "invalid_request",
        "message": "order_id is required (1–128 printable characters, no spaces).",
        "param": "order_id"
    }
}
HTTPcodeMeaning
401unauthenticatedCredentials missing.
401invalid_credentialsClient ID or secret is wrong.
402insufficient_fundsBalance too low for a purchase.
403ip_not_allowedRequest IP is not on the project allow-list.
403project_disabledThe project or business is disabled.
403project_inactiveThe project cannot take payments yet (verification pending).
404not_foundUnknown endpoint or object.
409duplicate_order_idA deposit with this order_id exists.
409not_in_holdOnly in_hold deposits can be approved.
422invalid_requestA parameter is missing or invalid; see param.
422(network codes)e.g. user_inventory_is_hidden, inventory_is_outdated, user_not_tradable, skin_unavailable.
429rate_limitedToo many requests; see retry_after.
500server_errorOur fault. Logged; safe to retry with the same order_id / custom_id.
502(network codes)The payment network did not answer. Retry later.
503(network codes)The payment network is under maintenance.

Show message to your team, not to customers, and branch on code. New codes may be added; treat unknown ones like the HTTP status they come with.

Rate limits

  • 600 requests per minute per project. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining.
  • 1,200 requests per minute per IP address, across projects.
  • Over the limit you get 429 rate_limited with Retry-After and retry_after (seconds) in the body. Back off and try again.

Don't poll. Use webhooks to learn about status changes, and GET an object only when you need to double-check.

Idempotency

Network calls fail. Your own references make create calls safe to repeat:

  • Deposits: order_id. Unique per project. Repeating a POST /deposits with the same order_id never creates a second deposit; it returns 409 duplicate_order_id, and you fetch the original with GET /deposits?order_id=….
  • Purchases: custom_id. Unique per project. Repeating POST /market/purchases with the same custom_id returns the existing purchase with 200 instead of buying twice.

Use your own order or withdrawal ID for these fields, so a retry after a timeout always lands on the same object.

Deposit flow

  1. Your server calls POST /deposits with your order_id.
  2. You send the customer to pay_url (or open embed_url in an iframe).
  3. The customer pastes their trade link, picks items and sees the value, bonus included.
  4. We send a Steam trade offer: status pending, webhook deposit.pending.
  5. The customer accepts in Steam. The deposit becomes success (credited), or in_hold when Steam Trade Protection applies.
  6. You receive a webhook for every change, and the customer returns to your success_url or fail_url.

Credit your customer on deposit.success or deposit.hold_approved, never on the redirect alone. The redirect carries deposit_id, order_id and status query parameters for display, but only a signed webhook (or a GET /deposits/{id}) is proof.

Deposit statuses

created ──► pending ──► success
   │           ├──────► fail
   │           └──► in_hold ──► success
   │                   ├──────► fail
   │                   └──► hold_approved ──► success
   │                                └──────► hold_returned
   └──► expired
StatusMeaningFinal
createdLink created. The customer has not sent a trade yet.No
pendingTrade offer sent. Waiting for the customer to accept it in Steam.No
in_holdAccepted, but Steam Trade Protection applies. Not credited yet; see hold_until and user_risk. You may release it early.No
hold_approvedYou released a held deposit early. Credited. Can still become hold_returned or success.No
successCompleted. Credited (net_usd = amount_usd − fee_usd).Yes
failDeclined, cancelled or timed out. See fail_reason. Nothing credited.Yes
hold_returnedSteam returned the items during protection. If it was released early, the credit is reversed.Yes
expiredThe link was never used before expires_at.Yes

Statuses only move forward. A deposit is credited exactly once, when it first reaches success or hold_approved; credited tells you whether it currently counts towards your balance.

Hosted checkout

The simplest integration. Create a deposit, redirect, wait for the webhook.

PHP
<?php
// Create the payment on your server
$ch = curl_init('https://api.skinsflow.com/v1/deposits');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'X-Client-Id: ' . getenv('SKINSFLOW_CLIENT_ID'),
        'X-Client-Secret: ' . getenv('SKINSFLOW_CLIENT_SECRET'),
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'order_id'    => $order->id,
        'success_url' => 'https://shop.example/orders/' . $order->id,
        'metadata'    => ['user_id' => (string) $user->id],
    ]),
]);
$deposit = json_decode(curl_exec($ch), true);

// Send the customer to the checkout
header('Location: ' . $deposit['pay_url']);
  • Pass trade_url if you already know the customer's trade link; the checkout then opens straight on their inventory.
  • Use min_amount / max_amount to bound the total, for example for a fixed-price product.
  • Links expire after expires_in seconds if unused (default 24 hours), then become expired.

Embedding in an iframe

Load embed_url (pay_url + ?embed=1) in an iframe to keep customers on your page. The checkout is allowed in any frame and works without third-party cookies.

HTML
<iframe src="https://pay.skinsflow.com/d/9c1e…?embed=1"
        title="Pay with skins"
        style="width:100%;min-height:720px;border:0;border-radius:14px"
        allow="clipboard-write"></iframe>

In embedded mode the checkout does not redirect when it finishes. Instead it posts a message to the parent window on load and at every status change:

{
    "source": "skinsflow",
    "type": "deposit.status",
    "status": "pending",
    "deposit_id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
    "order_id": "order-10293"
}
JavaScript
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://pay.skinsflow.com') return;   // only trust our checkout
  const msg = event.data;
  if (!msg || msg.source !== 'skinsflow' || msg.type !== 'deposit.status') return;

  if (msg.status === 'success' || msg.status === 'in_hold' || msg.status === 'hold_approved') {
    showThankYou(msg.order_id);          // UI only: credit on the webhook
  } else if (msg.status === 'fail' || msg.status === 'expired') {
    showRetry(msg.order_id);
  }
});

Messages are for your UI. Credit the customer only after the matching webhook, which is signed.

Direct deposits

Want the item picker inside your own interface? Use the API end to end:

  1. GET /inventory with the customer's trade link returns their items with prices and availability.
  2. Show the items and let the customer choose. Use price_with_bonus if you display your bonus.
  3. POST /deposits/direct with the chosen asset_ids creates the trade offer.
  4. Send the customer to trade_offer_url to accept it in Steam, then follow the webhooks.

If the inventory changed between the two calls, you get 422 inventory_is_outdated: reload it and ask the customer to pick again.

Trade protection

Steam applies Trade Protection to some trades: for up to 7 days the customer can reverse the trade. Such deposits get status in_hold and are not credited yet. You receive deposit.in_hold with:

  • hold_until: when protection ends and the deposit settles on its own;
  • user_risk: risk signals about the customer's Steam account, as provided by the network (only while in_hold).

You can wait, or release it early and get credited now. If items come back after an early release, the deposit becomes hold_returned, the credit is reversed (deposit_reversal in your balance history) and you get deposit.hold_returned, so you can take back what you gave the customer.

General

Payment network status

GET/v1/status

Whether deposits can be taken right now. Check it before showing skins as a payment option, or to explain a temporary outage to your customers. Cached for 30 seconds.

Request
curl https://api.skinsflow.com/v1/status \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "status",
    "deposits_available": true,
    "reason": null,
    "checked_at": 1793635200
}

reason is maintenance or no_capacity when deposits_available is false.

Your project

GET/v1/project

The project the credentials belong to: status, defaults, fees and balance.

Request
curl https://api.skinsflow.com/v1/project \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "project",
    "id": "prj_…",
    "name": "Main store",
    "status": "active",
    "live": true,
    "website": "https://shop.example",
    "default_currency": "usd",
    "default_game": "cs2",
    "bonus_multiplier": 1.1,
    "webhook_url": "https://shop.example/webhooks/skinsflow",
    "success_url": "https://shop.example/paid",
    "fail_url": "https://shop.example/cart",
    "fees": {
        "deposit_percent": 2.5,
        "market_percent": 0.0
    },
    "balance": {
        "available_usd": 1250.4,
        "reserved_usd": 45.0
    }
}

Balance

GET/v1/balance

Available and reserved balance in USD. Reserved money is held for market purchases that are still in progress.

ParameterTypeDescription
currency string Also return the balance converted to this currency (display only), e.g. eur.
Request
curl "https://api.skinsflow.com/v1/balance?currency=eur" \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "balance",
    "currency": "usd",
    "available": 1250.4,
    "reserved": 45.0,
    "total": 1295.4,
    "converted": {
        "currency": "eur",
        "available": 1119.11,
        "reserved": 40.28,
        "rate": 0.895
    }
}

Balance history

GET/v1/balance/history

Every movement on the project balance, newest first.

ParameterTypeDescription
type string One of deposit, deposit_reversal, reserve, release, purchase, purchase_refund, adjustment, payout.
from integer Unix timestamp, inclusive.
to integer Unix timestamp, inclusive.
page integer Page number, from 1.
limit integer 1–100, default 25.
Request
curl "https://api.skinsflow.com/v1/balance/history?type=deposit&limit=50" \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "list",
    "data": [
        {
            "id": 18231,
            "type": "deposit",
            "amount_usd": 93.74625,
            "balance_after_usd": 1250.4,
            "reserved_after_usd": 45.0,
            "reference": {
                "object": "deposit",
                "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12"
            },
            "description": "Deposit order-10293",
            "created_at": 1793635340
        }
    ],
    "page": 1,
    "limit": 25,
    "total": 1,
    "has_more": false
}

Currencies

GET/v1/currencies

Currencies accepted in currency fields, with their rate per 1 USD. Balances are always kept in USD.

Request
curl https://api.skinsflow.com/v1/currencies \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "list",
    "data": [
        {
            "code": "usd",
            "per_usd": 1.0
        },
        {
            "code": "eur",
            "per_usd": 0.8951
        },
        {
            "code": "…",
            "per_usd": null
        }
    ]
}

Supported codes: usd, eur, gbp, cad, aud, nzd, brl, cny, mxn, rub, uah, kzt, ars, clp, pen, cop.

Deposits

Create a deposit (hosted checkout)

POST/v1/deposits

Creates a payment and returns a checkout link. Send the customer to pay_url, or load embed_url in an iframe.

ParameterTypeDescription
order_idrequired string Your reference for this payment. Unique per project, 1–128 printable characters, no spaces.
currency string Currency shown to the customer and used for amount fields. Default: project setting.
game string cs2, dota2 or rust: the inventory opened first. Default: project setting.
trade_url string The customer's Steam trade link, if you already have it. Skips that step in the checkout.
steam_id string SteamID64, as an alternative to trade_url.
trade_token string The 8-character token from the trade link, used with steam_id.
min_amount number Lowest total the customer may send, in currency.
max_amount number Highest total the customer may send, in currency.
success_url string https URL the customer returns to after paying. Default: project setting.
fail_url string https URL the customer returns to after a failed or cancelled payment. Default: project setting.
result_url string https URL for this deposit's webhooks. Default: project webhook URL.
metadata object Up to 20 flat key/value pairs (keys ≤ 40 chars, values ≤ 500 chars). Returned in every response and webhook.
expires_in integer Seconds until an unused link expires: 300–604800, default 86400.
Request
curl https://api.skinsflow.com/v1/deposits \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "order-10293",
    "currency": "usd",
    "min_amount": 5,
    "success_url": "https://shop.example/paid",
    "metadata": { "user_id": "4821" }
  }'
Response
{
    "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
    "object": "deposit",
    "order_id": "order-10293",
    "status": "created",
    "flow": "hosted",
    "pay_url": "https://pay.skinsflow.com/d/9c1e…",
    "embed_url": "https://pay.skinsflow.com/d/9c1e…?embed=1",
    "game": "cs2",
    "steam_id": null,
    "currency": "usd",
    "amount": null,
    "amount_with_bonus": null,
    "bonus_multiplier": 1.1,
    "amount_usd": null,
    "fee_usd": null,
    "net_usd": null,
    "credited": false,
    "min_amount": 5.0,
    "max_amount": null,
    "trade_offer_id": null,
    "trade_offer_url": null,
    "fail_reason": null,
    "hold_until": null,
    "user_risk": null,
    "items": [],
    "metadata": {
        "user_id": "4821"
    },
    "expires_at": 1793721600,
    "created_at": 1793635200,
    "updated_at": 1793635200,
    "completed_at": null
}

Returns 201 with a deposit in status created. 409 duplicate_order_id if the order ID was used before: look it up with GET /deposits?order_id=…. 403 project_inactive until your business is verified.

Create a direct deposit

POST/v1/deposits/direct

For your own item picker: load the customer's inventory with GET /inventory, let them choose, then send the asset IDs. We create the Steam trade offer immediately.

ParameterTypeDescription
order_idrequired string Your unique reference, as above.
gamerequired string cs2, dota2 or rust.
trade_urlrequired string The customer's Steam trade link (or steam_id + trade_token).
asset_idsrequired string[] 1–200 inventory asset IDs from GET /inventory.
currency string Default: project setting.
result_url string https URL for this deposit's webhooks.
metadata object As above.
Request
curl https://api.skinsflow.com/v1/deposits/direct \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "order-10294",
    "game": "cs2",
    "trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=39734272&token=AbCdEf12",
    "asset_ids": ["38291029381", "38291029417"]
  }'
Response
{
    "id": "dep_…",
    "object": "deposit",
    "order_id": "order-10294",
    "status": "pending",
    "flow": "direct",
    "pay_url": null,
    "embed_url": null,
    "game": "cs2",
    "steam_id": "76561198000000000",
    "currency": "usd",
    "amount": 96.15,
    "amount_with_bonus": 105.77,
    "bonus_multiplier": 1.1,
    "amount_usd": 96.15,
    "fee_usd": null,
    "net_usd": null,
    "credited": false,
    "min_amount": 5.0,
    "max_amount": null,
    "trade_offer_id": "7123456789",
    "trade_offer_url": "https://steamcommunity.com/tradeoffer/7123456789/",
    "fail_reason": null,
    "hold_until": null,
    "user_risk": null,
    "items": [
        {
            "asset_id": "38291029381",
            "name": "AWP | Asiimov (Battle-Scarred)",
            "image": "https://community.akamai.steamstatic.com/economy/image/…",
            "price": 96.15,
            "price_usd": 96.15
        }
    ],
    "metadata": {
        "user_id": "4821"
    },
    "expires_at": 1793721600,
    "created_at": 1793635200,
    "updated_at": 1793635340,
    "completed_at": null
}

Returns 201 with status pending. Send the customer to trade_offer_url to accept the offer in Steam. Items that cannot be accepted return 422 with a code such as inventory_is_outdated or user_not_tradable.

List deposits

GET/v1/deposits

Deposits of this project, newest first.

ParameterTypeDescription
status string Filter by status.
order_id string Find a deposit by your order ID.
steam_id string Deposits from one Steam account.
from integer Created at or after (unix).
to integer Created at or before (unix).
page integer From 1.
limit integer 1–100, default 25.
Request
curl "https://api.skinsflow.com/v1/deposits?status=success&limit=10" \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "list",
    "data": [
        {
            "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
            "object": "deposit",
            "…": "…"
        }
    ],
    "page": 1,
    "limit": 25,
    "total": 128,
    "has_more": false
}

Retrieve a deposit

GET/v1/deposits/{id}

One deposit by its dep_… ID. Deposits still in flight are refreshed from the payment network when you read them.

ParameterTypeDescription
idrequired string The deposit ID (path).
Request
curl https://api.skinsflow.com/v1/deposits/dep_5f0c2a9e7b1d4c3a8e6f0b12 \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
    "object": "deposit",
    "order_id": "order-10293",
    "status": "success",
    "flow": "hosted",
    "pay_url": "https://pay.skinsflow.com/d/9c1e…",
    "embed_url": "https://pay.skinsflow.com/d/9c1e…?embed=1",
    "game": "cs2",
    "steam_id": "76561198000000000",
    "currency": "usd",
    "amount": 96.15,
    "amount_with_bonus": 105.77,
    "bonus_multiplier": 1.1,
    "amount_usd": 96.15,
    "fee_usd": 2.40375,
    "net_usd": 93.74625,
    "credited": true,
    "min_amount": 5.0,
    "max_amount": null,
    "trade_offer_id": "7123456789",
    "trade_offer_url": "https://steamcommunity.com/tradeoffer/7123456789/",
    "fail_reason": null,
    "hold_until": null,
    "user_risk": null,
    "items": [
        {
            "asset_id": "38291029381",
            "name": "AWP | Asiimov (Battle-Scarred)",
            "image": "https://community.akamai.steamstatic.com/economy/image/…",
            "price": 96.15,
            "price_usd": 96.15
        }
    ],
    "metadata": {
        "user_id": "4821"
    },
    "expires_at": 1793721600,
    "created_at": 1793635200,
    "updated_at": 1793635340,
    "completed_at": 1793635340
}

404 not_found for an unknown ID or a deposit of another project.

Release a held deposit

POST/v1/deposits/{id}/approve

Credits a deposit in status in_hold now instead of waiting for Steam Trade Protection to end. You take the risk: if the customer reverses the trade, the amount is taken back from your balance and you receive deposit.hold_returned.

ParameterTypeDescription
idrequired string The deposit ID (path).
Request
curl -X POST https://api.skinsflow.com/v1/deposits/dep_5f0c2a9e7b1d4c3a8e6f0b12/approve \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
    "object": "deposit",
    "order_id": "order-10293",
    "status": "hold_approved",
    "flow": "hosted",
    "pay_url": "https://pay.skinsflow.com/d/9c1e…",
    "embed_url": "https://pay.skinsflow.com/d/9c1e…?embed=1",
    "game": "cs2",
    "steam_id": "76561198000000000",
    "currency": "usd",
    "amount": 96.15,
    "amount_with_bonus": 105.77,
    "bonus_multiplier": 1.1,
    "amount_usd": 96.15,
    "fee_usd": 2.40375,
    "net_usd": 93.74625,
    "credited": true,
    "min_amount": 5.0,
    "max_amount": null,
    "trade_offer_id": "7123456789",
    "trade_offer_url": "https://steamcommunity.com/tradeoffer/7123456789/",
    "fail_reason": null,
    "hold_until": 1794240000,
    "user_risk": null,
    "items": [
        {
            "asset_id": "38291029381",
            "name": "AWP | Asiimov (Battle-Scarred)",
            "image": "https://community.akamai.steamstatic.com/economy/image/…",
            "price": 96.15,
            "price_usd": 96.15
        }
    ],
    "metadata": {
        "user_id": "4821"
    },
    "expires_at": 1793721600,
    "created_at": 1793635200,
    "updated_at": 1793635340,
    "completed_at": null
}

409 not_in_hold if the deposit is not on hold.

Customer inventory

GET/v1/inventory

A customer's inventory with prices, for direct deposits. price_with_bonus includes your project's bonus multiplier.

ParameterTypeDescription
trade_urlrequired string The customer's Steam trade link (or steam_id + trade_token).
game string Default: project setting.
currency string Default: project setting.
Request
curl -G https://api.skinsflow.com/v1/inventory \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET" \
  --data-urlencode "trade_url=https://steamcommunity.com/tradeoffer/new/?partner=39734272&token=AbCdEf12" \
  -d game=cs2
Response
{
    "object": "inventory",
    "game": "cs2",
    "currency": "usd",
    "steam_id": "76561198000000000",
    "items": [
        {
            "asset_id": "38291029381",
            "name": "AWP | Asiimov (Battle-Scarred)",
            "image": "https://community.akamai.steamstatic.com/economy/image/…",
            "price": 96.15,
            "price_with_bonus": 105.77,
            "price_usd": 96.15,
            "available": true,
            "stickers": []
        }
    ],
    "total_available": 96.15
}

Items with available: false cannot be accepted (not tradable, or not supported). The inventory must be public.

Market

Market price list

GET/v1/market/items

Every skin currently available to buy, grouped by name with the lowest price and the number of offers. Cached for 2 minutes; large for CS2.

ParameterTypeDescription
game string cs2 (default), dota2 or rust.
Request
curl "https://api.skinsflow.com/v1/market/items?game=rust" \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "list",
    "game": "rust",
    "updated_at": 1793635200,
    "data": [
        {
            "name": "Tempered AK47",
            "price_usd": 31.2,
            "count": 4
        }
    ]
}

Buy a skin

POST/v1/market/purchases

Buys one skin with your project balance and sends it to a Steam trade link. We hold max_price from your available balance, charge the real price when the item is bought and release the rest. Failed or returned deliveries are refunded automatically.

ParameterTypeDescription
custom_idrequired string Your unique reference (1–128 printable characters). Sending the same value again returns the existing purchase.
trade_urlrequired string The customer's Steam trade link.
max_pricerequired number The most you are willing to pay, in USD. Protects you from price moves.
item_id integer An exact item from /market/search.
name string Instead of item_id: market hash name. The cheapest matching offer is bought.
game string Required with name.
delivery_types integer[] With name: [1] instant only, [2] slow only, [1,2] both.
Request
curl https://api.skinsflow.com/v1/market/purchases \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "custom_id": "withdraw-5512",
    "trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=39734272&token=AbCdEf12",
    "item_id": 482910233,
    "max_price": 45
  }'
Response
{
    "id": "pur_8a2b4c6d8e0f1a2b3c4d5e6f",
    "object": "purchase",
    "custom_id": "withdraw-5512",
    "status": "creating_trade",
    "game": "cs2",
    "item_name": "AK-47 | Redline (Field-Tested)",
    "item_id": 482910233,
    "delivery_type": 1,
    "steam_id": "76561198000000000",
    "max_price_usd": 45.0,
    "price_usd": 42.8,
    "fee_usd": 0.0,
    "charged_usd": 42.8,
    "refunded_usd": 0.0,
    "trade_offer_id": null,
    "error_reason": null,
    "in_hold": false,
    "in_hold_until": null,
    "created_at": 1793635200,
    "updated_at": 1793635200
}

201 for a new purchase (status creating_trade, or unknown if the network did not answer in time: it settles within minutes). 200 with the existing purchase when custom_id was used before. 402 insufficient_funds. 422 skin_unavailable, skins_not_found_at_specified_price, invalid_token_value and similar, with the failed purchase object included.

Buy several skins

POST/v1/market/purchases/batch

Buys up to 50 items (by item_id) for one trade link in one call.

ParameterTypeDescription
trade_urlrequired string The customer's Steam trade link.
itemsrequired object[] 1–50 of {"item_id": 123, "max_price": 5.5, "custom_id": "r-1"}. All three fields are required.
Request
curl https://api.skinsflow.com/v1/market/purchases/batch \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=39734272&token=AbCdEf12",
    "items": [
      { "item_id": 482910233, "max_price": 45, "custom_id": "r-1" },
      { "item_id": 482910871, "max_price": 3.2, "custom_id": "r-2" }
    ]
  }'
Response
{
    "object": "batch",
    "purchases": [
        {
            "id": "pur_…",
            "object": "purchase",
            "custom_id": "r-1",
            "status": "creating_trade",
            "…": "…"
        }
    ],
    "unavailable": [
        {
            "id": 482910871,
            "error": "skin_unavailable"
        }
    ]
}

Cancel purchases

POST/v1/market/purchases/cancel

Cancels purchases that are still waiting for a trade and at least 30 minutes old. Cancelled purchases are refunded.

ParameterTypeDescription
ids string[] Purchase IDs (pur_…), up to 100.
custom_ids string[] Your custom IDs, up to 100. Pass ids, custom_ids or both.
Request
curl https://api.skinsflow.com/v1/market/purchases/cancel \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "custom_ids": ["withdraw-5512"] }'
Response
{
    "object": "cancel",
    "purchases": [
        {
            "id": "pur_…",
            "object": "purchase",
            "status": "failed",
            "…": "…"
        }
    ],
    "unavailable": {}
}

unavailable lists purchases that could not be cancelled, with the reason (for example cancel_request_too_early).

List purchases

GET/v1/market/purchases

Purchases of this project, newest first.

ParameterTypeDescription
status string Filter by status.
custom_id string Find a purchase by your custom ID.
from, to integer Unix timestamps.
page, limit integer Paging; limit 1–100, default 25.
Request
curl "https://api.skinsflow.com/v1/market/purchases?status=accepted" \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "list",
    "data": [
        {
            "id": "pur_8a2b4c6d8e0f1a2b3c4d5e6f",
            "object": "purchase",
            "…": "…"
        }
    ],
    "page": 1,
    "limit": 25,
    "total": 12,
    "has_more": false
}

Retrieve a purchase

GET/v1/market/purchases/{id}

One purchase by its pur_… ID, refreshed from the network while still in progress.

ParameterTypeDescription
idrequired string The purchase ID (path).
Request
curl https://api.skinsflow.com/v1/market/purchases/pur_8a2b4c6d8e0f1a2b3c4d5e6f \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "id": "pur_8a2b4c6d8e0f1a2b3c4d5e6f",
    "object": "purchase",
    "custom_id": "withdraw-5512",
    "status": "accepted",
    "game": "cs2",
    "item_name": "AK-47 | Redline (Field-Tested)",
    "item_id": 482910233,
    "delivery_type": 1,
    "steam_id": "76561198000000000",
    "max_price_usd": 45.0,
    "price_usd": 42.8,
    "fee_usd": 0.0,
    "charged_usd": 42.8,
    "refunded_usd": 0.0,
    "trade_offer_id": "7123456790",
    "error_reason": null,
    "in_hold": false,
    "in_hold_until": null,
    "created_at": 1793635200,
    "updated_at": 1793635295
}

Webhooks

List deliveries

GET/v1/webhooks

Webhook deliveries we sent to this project, newest first.

ParameterTypeDescription
status string pending, delivered or failed.
page, limit integer Paging; limit 1–100, default 25.
Request
curl "https://api.skinsflow.com/v1/webhooks?status=failed" \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "object": "list",
    "data": [
        {
            "id": "whd_1a2b3c4d5e6f7a8b9c0d1e2f",
            "event": "deposit.success",
            "url": "https://shop.example/webhooks/skinsflow",
            "status": "delivered",
            "attempts": 1,
            "last_http_code": 200,
            "last_error": null,
            "next_attempt_at": null,
            "delivered_at": 1793635341,
            "created_at": 1793635340
        }
    ],
    "page": 1,
    "limit": 25,
    "total": 1,
    "has_more": false
}

Send a test event

POST/v1/webhooks/test

Sends test.ping to your project webhook URL and returns the delivery.

Request
curl -X POST https://api.skinsflow.com/v1/webhooks/test \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "id": "whd_…",
    "event": "test.ping",
    "url": "https://shop.example/webhooks/skinsflow",
    "status": "delivered",
    "attempts": 1,
    "last_http_code": 200,
    "last_error": null,
    "next_attempt_at": null,
    "delivered_at": 1793635341,
    "created_at": 1793635340
}

422 no_webhook_url if the project has no webhook URL yet.

Resend a delivery

POST/v1/webhooks/{id}/resend

Sends a delivery again right away, for example after you fixed your endpoint.

ParameterTypeDescription
idrequired string The delivery ID, whd_… (path).
Request
curl -X POST https://api.skinsflow.com/v1/webhooks/whd_1a2b3c4d5e6f7a8b9c0d1e2f/resend \
  -H "X-Client-Id: $SKINSFLOW_CLIENT_ID" \
  -H "X-Client-Secret: $SKINSFLOW_CLIENT_SECRET"
Response
{
    "id": "whd_1a2b3c4d5e6f7a8b9c0d1e2f",
    "event": "deposit.success",
    "url": "https://shop.example/webhooks/skinsflow",
    "status": "delivered",
    "attempts": 1,
    "last_http_code": 200,
    "last_error": null,
    "next_attempt_at": null,
    "delivered_at": 1793635341,
    "created_at": 1793635340
}

The deposit object

FieldTypeDescription
idstringDeposit ID, dep_….
objectstringAlways deposit.
order_idstringYour reference.
statusstringSee deposit statuses.
flowstringhosted or direct.
pay_url, embed_urlstring|nullCheckout links (hosted flow only).
gamestringcs2, dota2 or rust.
steam_idstring|nullThe customer's SteamID64, once known.
currencystringCurrency of the amount fields.
amountnumber|nullMarket value of the items in currency.
amount_with_bonusnumber|nullValue shown to the customer, including your bonus.
bonus_multipliernumberYour project bonus at the time of the deposit (0.5–2).
amount_usdnumber|nullMarket value in USD.
fee_usdnumber|nullSkinsFlow fee, set when credited.
net_usdnumber|nullAmount credited to your balance.
creditedbooleanWhether the deposit currently counts towards your balance.
min_amount, max_amountnumber|nullLimits you set.
trade_offer_id, trade_offer_urlstring|nullThe Steam trade offer.
fail_reasonstring|nullWhy it failed, e.g. trade_timeout, trade_canceled, timeout.
hold_untilinteger|nullEnd of Steam Trade Protection (unix).
user_riskobject|nullRisk signals, only while in_hold.
itemsarrayItems in the trade: asset_id, name, image, price, price_usd.
metadataobjectYour key/value pairs.
expires_at, created_at, updated_at, completed_atinteger|nullUnix timestamps.
Example
{
    "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
    "object": "deposit",
    "order_id": "order-10293",
    "status": "success",
    "flow": "hosted",
    "pay_url": "https://pay.skinsflow.com/d/9c1e…",
    "embed_url": "https://pay.skinsflow.com/d/9c1e…?embed=1",
    "game": "cs2",
    "steam_id": "76561198000000000",
    "currency": "usd",
    "amount": 96.15,
    "amount_with_bonus": 105.77,
    "bonus_multiplier": 1.1,
    "amount_usd": 96.15,
    "fee_usd": 2.40375,
    "net_usd": 93.74625,
    "credited": true,
    "min_amount": 5.0,
    "max_amount": null,
    "trade_offer_id": "7123456789",
    "trade_offer_url": "https://steamcommunity.com/tradeoffer/7123456789/",
    "fail_reason": null,
    "hold_until": null,
    "user_risk": null,
    "items": [
        {
            "asset_id": "38291029381",
            "name": "AWP | Asiimov (Battle-Scarred)",
            "image": "https://community.akamai.steamstatic.com/economy/image/…",
            "price": 96.15,
            "price_usd": 96.15
        }
    ],
    "metadata": {
        "user_id": "4821"
    },
    "expires_at": 1793721600,
    "created_at": 1793635200,
    "updated_at": 1793635340,
    "completed_at": 1793635340
}

The purchase object

FieldTypeDescription
idstringPurchase ID, pur_….
objectstringAlways purchase.
custom_idstringYour reference.
statusstringSee purchase statuses.
game, item_name, item_idstring, string, integer|nullWhat was bought.
delivery_typeinteger|null1 instant, 2 slow.
steam_idstringRecipient SteamID64.
max_price_usdnumberYour price limit.
price_usdnumber|nullActual item price.
fee_usdnumber|nullMarket fee (currently 0).
charged_usdnumber|nullTotal taken from your balance.
refunded_usdnumberTotal given back after a failure or return.
trade_offer_idstring|nullThe Steam trade offer to the customer.
error_reasonstring|nullWhy it failed.
in_hold, in_hold_untilboolean, integer|nullTrade Protection on the delivered item.
created_at, updated_atintegerUnix timestamps.
Example
{
    "id": "pur_8a2b4c6d8e0f1a2b3c4d5e6f",
    "object": "purchase",
    "custom_id": "withdraw-5512",
    "status": "accepted",
    "game": "cs2",
    "item_name": "AK-47 | Redline (Field-Tested)",
    "item_id": 482910233,
    "delivery_type": 1,
    "steam_id": "76561198000000000",
    "max_price_usd": 45.0,
    "price_usd": 42.8,
    "fee_usd": 0.0,
    "charged_usd": 42.8,
    "refunded_usd": 0.0,
    "trade_offer_id": "7123456790",
    "error_reason": null,
    "in_hold": false,
    "in_hold_until": null,
    "created_at": 1793635200,
    "updated_at": 1793635295
}

Purchase statuses

StatusMeaning
pendingAccepted by us, being processed.
unknownThe network did not answer in time. We are checking; it settles within minutes.
creating_tradeItem bought, trade offer being created.
waiting_acceptTrade offer sent to the customer.
acceptedDelivered. Final.
canceledCustomer declined the offer. Refunded.
timeoutOffer not accepted in time. Refunded.
invalid_trade_tokenBad trade link. Refunded.
user_not_tradableCustomer cannot trade. Refunded.
trade_create_errorThe trade could not be created. Refunded.
rollback_userReturned by the customer (Trade Protection). Refunded.
rollback_supplierReturned by the seller. Refunded.
failedFailed before a trade was made, or cancelled by you. Refunded.

The webhook delivery object

Returned by the webhook endpoints. It describes one event we sent you and how delivery went.

FieldTypeDescription
idstringDelivery ID, whd_…. Same as id in the payload and the SkinsFlow-Delivery header.
eventstringEvent name.
urlstringWhere we sent it.
statusstringpending (will retry), delivered or failed (gave up).
attemptsintegerAttempts so far.
last_http_code, last_errorinteger|null, string|nullResult of the last attempt.
next_attempt_atinteger|nullNext retry (unix), while pending.
delivered_at, created_atinteger|nullUnix timestamps.

Webhooks

Set a webhook URL (public, https://) for each project in the dashboard, or per deposit with result_url. We POST a JSON event to it whenever a deposit or purchase changes status. Answer with any 2xx within 8 seconds to acknowledge; do slow work after responding.

What we send
POST /webhooks/skinsflow HTTP/1.1
Host: shop.example
Content-Type: application/json
SkinsFlow-Event: deposit.success
SkinsFlow-Delivery: whd_1a2b3c4d5e6f7a8b9c0d1e2f
SkinsFlow-Signature: t=1793635340,v1=5d2c1f0e9b…

{
    "id": "whd_1a2b3c4d5e6f7a8b9c0d1e2f",
    "event": "deposit.success",
    "created": 1793635340,
    "livemode": true,
    "data": {
        "id": "dep_5f0c2a9e7b1d4c3a8e6f0b12",
        "object": "deposit",
        "order_id": "order-10293",
        "status": "success",
        "…": "…"
    }
}

data is the full deposit or purchase object at the time of the event.

Events

EventWhen
deposit.pendingA trade offer was sent to the customer.
deposit.in_holdThe customer accepted; Steam Trade Protection applies.
deposit.hold_approvedA held deposit was released early and credited.
deposit.successThe deposit completed and was credited.
deposit.failThe trade failed, was declined or timed out.
deposit.hold_returnedItems were returned during protection; any early credit was reversed.
deposit.expiredThe checkout link expired unused.
purchase.creating_tradeA market purchase was made; the trade is being created.
purchase.waiting_acceptThe trade offer was sent to the customer.
purchase.acceptedThe skin was delivered.
purchase.failedThe purchase failed or was cancelled; the money was refunded.
purchase.rolled_backThe delivered skin was returned; the money was refunded.
test.pingSent by POST /webhooks/test and the dashboard test button.

Events can arrive more than once and, rarely, out of order. Use the delivery id to ignore duplicates, and compare data.status / data.updated_at with what you stored before acting.

Verify signatures

Every webhook is signed with your project's webhook secret (whsec_…, in the dashboard next to your API keys). The SkinsFlow-Signature header holds a timestamp and an HMAC-SHA256:

SkinsFlow-Signature: t=<unix timestamp>,v1=<hex HMAC-SHA256(webhook_secret, "<t>.<raw body>")>
  1. Read the raw request body, before any JSON parsing.
  2. Split the header into t and v1.
  3. Compute HMAC-SHA256 of t + "." + body with your webhook secret as the key, hex-encoded.
  4. Compare it with v1 in constant time, and reject timestamps older than 5 minutes.
PHP
<?php
$secret    = getenv('SKINSFLOW_WEBHOOK_SECRET');           // whsec_…
$body      = file_get_contents('php://input');
$header    = $_SERVER['HTTP_SKINSFLOW_SIGNATURE'] ?? '';

parse_str(str_replace(',', '&', $header), $sig);            // ['t' => …, 'v1' => …]
$expected  = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);

if (!isset($sig['t'], $sig['v1'])
    || !hash_equals($expected, $sig['v1'])
    || abs(time() - (int) $sig['t']) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
if ($event['event'] === 'deposit.success' || $event['event'] === 'deposit.hold_approved') {
    // credit $event['data']['order_id'] once (check you haven't already)
}
http_response_code(200);
Node.js
// Node.js + Express
const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/webhooks/skinsflow', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('SkinsFlow-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', process.env.SKINSFLOW_WEBHOOK_SECRET)   // whsec_…
    .update(`${parts.t}.${req.body}`)
    .digest('hex');

  const valid = parts.v1 && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)) &&
    Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  if (!valid) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  // handle event.event / event.data …
  res.sendStatus(200);
});

Retries

We send every event right away. If your endpoint does not answer with a 2xx (or times out), we retry with growing delays:

Retry12345678
After1 min5 min15 min1 h3 h6 h12 h24 h

That is 9 attempts over about two days. After the last one the delivery is marked failed. You can see every delivery in the dashboard and with GET /webhooks, and send any of them again with POST /webhooks/{id}/resend.

Testing tips

  • Check your setup: GET /project confirms your keys, and live: true means the project can take payments.
  • Test your webhook endpoint with POST /webhooks/test. It sends a signed test.ping exactly like a real event, so your signature check runs too.
  • Make a small real payment. Create a deposit with a low max_amount, open pay_url with your own Steam account and send a cheap item. You'll see every status and webhook end to end.
  • Try the failure paths: decline the trade offer in Steam (deposit.fail), or let a short link expire (expires_in=300 → deposit.expired).
  • Expose a local endpoint with a tunnel (for example ngrok or Cloudflare Tunnel) so webhooks can reach your machine. URLs must be public https://.
  • Replay events from the dashboard or with POST /webhooks/{id}/resend to make sure your handler ignores duplicates.
  • Check availability with GET /status before showing the skins option, so customers don't start a payment during maintenance.

Support

Stuck? Write to [email protected] with your project ID, the request you sent (without your secret) and the dep_…, pur_… or whd_… ID involved. For volume pricing or a deeper integration, contact [email protected]. Current system status is on the status page.