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.
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:
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"
}
}
HTTP
code
Meaning
401
unauthenticated
Credentials missing.
401
invalid_credentials
Client ID or secret is wrong.
402
insufficient_funds
Balance too low for a purchase.
403
ip_not_allowed
Request IP is not on the project allow-list.
403
project_disabled
The project or business is disabled.
403
project_inactive
The project cannot take payments yet (verification pending).
404
not_found
Unknown endpoint or object.
409
duplicate_order_id
A deposit with this order_id exists.
409
not_in_hold
Only in_hold deposits can be approved.
422
invalid_request
A parameter is missing or invalid; see param.
422
(network codes)
e.g. user_inventory_is_hidden, inventory_is_outdated, user_not_tradable, skin_unavailable.
429
rate_limited
Too many requests; see retry_after.
500
server_error
Our 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.
You send the customer to pay_url (or open embed_url in an iframe).
The customer pastes their trade link, picks items and sees the value, bonus included.
We send a Steam trade offer: status pending, webhook deposit.pending.
The customer accepts in Steam. The deposit becomes success (credited), or in_hold when Steam Trade Protection applies.
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.
Declined, cancelled or timed out. See fail_reason. Nothing credited.
Yes
hold_returned
Steam returned the items during protection. If it was released early, the credit is reversed.
Yes
expired
The 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:
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.
Returns 201 with a deposit in status created. 409duplicate_order_id if the order ID was used before: look it up with GET /deposits?order_id=…. 403project_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.
Parameter
Type
Description
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).
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.
404not_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.
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.
Parameter
Type
Description
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.
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. 402insufficient_funds. 422skin_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.
Parameter
Type
Description
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.
The network did not answer in time. We are checking; it settles within minutes.
creating_trade
Item bought, trade offer being created.
waiting_accept
Trade offer sent to the customer.
accepted
Delivered. Final.
canceled
Customer declined the offer. Refunded.
timeout
Offer not accepted in time. Refunded.
invalid_trade_token
Bad trade link. Refunded.
user_not_tradable
Customer cannot trade. Refunded.
trade_create_error
The trade could not be created. Refunded.
rollback_user
Returned by the customer (Trade Protection). Refunded.
rollback_supplier
Returned by the seller. Refunded.
failed
Failed 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.
Field
Type
Description
id
string
Delivery ID, whd_…. Same as id in the payload and the SkinsFlow-Delivery header.
event
string
Event name.
url
string
Where we sent it.
status
string
pending (will retry), delivered or failed (gave up).
attempts
integer
Attempts so far.
last_http_code, last_error
integer|null, string|null
Result of the last attempt.
next_attempt_at
integer|null
Next retry (unix), while pending.
delivered_at, created_at
integer|null
Unix 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.
data is the full deposit or purchase object at the time of the event.
Events
Event
When
deposit.pending
A trade offer was sent to the customer.
deposit.in_hold
The customer accepted; Steam Trade Protection applies.
deposit.hold_approved
A held deposit was released early and credited.
deposit.success
The deposit completed and was credited.
deposit.fail
The trade failed, was declined or timed out.
deposit.hold_returned
Items were returned during protection; any early credit was reversed.
deposit.expired
The checkout link expired unused.
purchase.creating_trade
A market purchase was made; the trade is being created.
purchase.waiting_accept
The trade offer was sent to the customer.
purchase.accepted
The skin was delivered.
purchase.failed
The purchase failed or was cancelled; the money was refunded.
purchase.rolled_back
The delivered skin was returned; the money was refunded.
test.ping
Sent 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:
We send every event right away. If your endpoint does not answer with a 2xx (or times out), we retry with growing delays:
Retry
1
2
3
4
5
6
7
8
After
1 min
5 min
15 min
1 h
3 h
6 h
12 h
24 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.