Endpoints
Trading
Read and answer Steam trade offers, clear mobile confirmations, and manage saved routing templates.
Everything the Trades page and an account's Trade Offers panel do, over HTTP: read the offer history the way the table reads it, accept, decline and cancel, work through Steam mobile confirmations, and manage the saved routing rulesets that distribution runs apply. Two scopes cover the domain: trading.read for the listings, trading.write for anything that answers an offer or a confirmation, and for template edits.
List trade offers
The trades table, as a query. Every tab, filter and search box on the page is a parameter here.
/api/v1/trade-offersYour trade offers, paginated and filterable.
Requires trading.read.
| Parameter | Type | Description |
|---|---|---|
tab |
string | all (default), open (still awaiting someone's action), protected (accepted CS2 trades still in trade protection) |
account_id |
uuid | One of your Steam accounts. Matches either side, so you get what it sent and what it was sent |
partner_account_id |
uuid | Only offers whose partner is this account of yours |
flow |
string | incoming, outgoing (both external only), or internal for transfers between two of your own accounts |
state |
integer | One Steam offer state. See the table below |
game |
array | cs2, tf2, steam. Repeat it (game[]=) to match any of several |
search |
string | Matches the Steam offer id, the external partner's Steam64 id, or either account's username |
include_mirrors |
boolean | true returns both rows of an internal transfer instead of collapsing them |
sort |
string | state_updated_at (default), sent_at, expires_at |
order |
string | asc, desc (default) |
per_page |
integer | Page size. See Pagination and filtering |
An unknown value is a 422 rather than an ignored parameter, so tab=oepn fails instead of quietly returning everything.
curl "https://dashboard.steamlabs.dev/api/v1/trade-offers?tab=open&flow=incoming&per_page=1" \
-H "Authorization: Bearer $STEAMLABS_API_KEY"$offers = Http::withToken($apiKey)
->get('https://dashboard.steamlabs.dev/api/v1/trade-offers', [
'tab' => 'open',
'flow' => 'incoming',
])
->json('data');const response = await fetch(
'https://dashboard.steamlabs.dev/api/v1/trade-offers?tab=open&flow=incoming',
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
const { data } = await response.json();import requests
offers = requests.get(
"https://dashboard.steamlabs.dev/api/v1/trade-offers",
params={"tab": "open", "flow": "incoming"},
headers={"Authorization": f"Bearer {api_key}"},
).json()["data"]{
"data": [
{
"id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
"offer_id": "9211449039",
"trade_id": null,
"task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
"direction": "received",
"flow": "incoming",
"state": 2,
"state_label": "Active",
"is_open": true,
"is_awaiting_settlement": false,
"game": "cs2",
"message": "swap?",
"steam_account": {
"id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
"username": "farm_017",
"persona_name": "Ada",
"steam64_id": "76561198000000017"
},
"partner_steam_account": null,
"partner_steam64_id": "76561199202970980",
"give_count": 0,
"receive_count": 2,
"items_to_give": [],
"items_to_receive": [
{
"asset_id": "15744253393",
"appid": 730,
"context_id": 2,
"class_id": "3946324",
"instance_id": "11040671",
"market_hash_name": "AK-47 | Slate (Field-Tested)",
"name": "AK-47 | Slate (Field-Tested)",
"icon_url": "https://community.cloudflare.steamstatic.com/economy/image/…",
"amount": 1,
"est_usd": 372
}
],
"estimated_give_cents": null,
"estimated_receive_cents": 4310,
"is_delayed_settlement": false,
"settlement_at": null,
"escrow_end_at": null,
"sent_at": "2026-07-30T09:14:22+00:00",
"state_updated_at": "2026-07-30T14:02:11+00:00",
"expires_at": "2026-08-13T09:14:22+00:00"
}
],
"meta": { "page": 1, "per_page": 1, "total": 1, "last_page": 1 }
}States
state is Steam's own ETradeOfferState integer. state_label is the same value in the dashboard's words.
| Value | Label | Open |
|---|---|---|
1 |
Invalid | no |
2 |
Active | yes |
3 |
Accepted | no |
4 |
Countered | no |
5 |
Expired | no |
6 |
Canceled | no |
7 |
Declined | no |
8 |
Invalid Items | no |
9 |
Needs Confirmation | yes |
10 |
Canceled via Mobile | no |
11 |
In Escrow | yes |
is_open is that last column. An open offer can still change through someone's action; a closed one only ever changes when a fresh fetch finds Steam disagrees.
tab=protected is narrower than state=3. It is the accepted CS2 trades whose items have left the sender, have not reached the receiver, and are still reversible: is_delayed_settlement true, state Accepted, and settlement_at in the future. is_awaiting_settlement says the same thing per row.
Get one trade offer
/api/v1/trade-offers/{id}One trade offer, with both item lists in full.
Requires trading.read.
Same object as a listing row. {id} is the SteamLabs uuid, not the Steam offer id, and it names one perspective: this endpoint never collapses an internal transfer, so a mirror uuid returns the mirror.
curl https://dashboard.steamlabs.dev/api/v1/trade-offers/019fb42e-9a61-70d2-818a-f6a56593f3a5 \
-H "Authorization: Bearer $STEAMLABS_API_KEY"Accept an offer
Queues an accept on the account that received the offer.
/api/v1/trade-offers/{id}/acceptAccept one incoming offer.
Requires trading.write.
This route carries the idempotent middleware, so an Idempotency-Key header is required. Accepting moves real items, and a retry after a timeout must replay rather than accept twice. See Bulk operations.
No body.
curl -X POST https://dashboard.steamlabs.dev/api/v1/trade-offers/019fb42e-9a61-70d2-818a-f6a56593f3a5/accept \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"$result = Http::withToken($apiKey)
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->post("https://dashboard.steamlabs.dev/api/v1/trade-offers/{$offerId}/accept")
->json();const response = await fetch(
`https://dashboard.steamlabs.dev/api/v1/trade-offers/${offerId}/accept`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Idempotency-Key': crypto.randomUUID(),
},
},
);
const result = await response.json();import requests, uuid
result = requests.post(
f"https://dashboard.steamlabs.dev/api/v1/trade-offers/{offer_id}/accept",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": str(uuid.uuid4()),
},
).json()202 Accepted, with the worker task to follow and the Steam offer id it will answer:
{
"task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
"accounts_affected": 1,
"offer_id": "9211449039"
}Accepting resolves the receiving side first, so you can call this on a collapsed internal-transfer row and the task lands on the account that can actually answer.
Decline an offer
The same call, the other answer.
/api/v1/trade-offers/{id}/declineDecline one incoming offer.
Requires trading.write. An Idempotency-Key header is required.
No body. Same 202 shape, same receiving-side resolution, and the same 422 offer_not_actionable when the offer is not an active offer awaiting one of your accounts.
curl -X POST https://dashboard.steamlabs.dev/api/v1/trade-offers/019fb42e-9a61-70d2-818a-f6a56593f3a5/decline \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"Cancel an offer
Withdraws an offer one of your accounts sent.
/api/v1/trade-offers/{id}/cancelCancel an open offer this account sent.
Requires trading.write. An Idempotency-Key header is required.
No body. Only the sending side can withdraw an offer, and only while it is still open, so the row you name must have direction: "sent" and is_open: true. Anything else is 422 offer_not_cancellable, carrying the state it actually has:
{
"message": "Only an offer this account sent, and that is still open, can be cancelled.",
"code": "offer_not_cancellable",
"state": 7
}A success is the usual 202 with task_id, accounts_affected and offer_id.
Refresh an account's offers
Asks Steam what this account's offers look like right now.
/api/v1/accounts/{account}/trade-offers/refreshQueue a fresh fetch of one account's trade offers.
Requires trading.write.
No Idempotency-Key needed. This only reads from Steam, and the single-flight guard already collapses a burst of calls into one task.
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/trade-offers/refresh \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
"accounts_affected": 1
}The offers land asynchronously. Poll the task, then re-read GET /api/v1/trade-offers.
Answer an account's offers in bulk
The panel's Accept all and Decline all, plus everything in between.
/api/v1/accounts/{account}/trade-offers/respondAccept or decline several of one account's incoming offers, or all of them.
Requires trading.write. An Idempotency-Key header is required.
| Field | Type | Description |
|---|---|---|
action |
string | accept or decline |
offer_ids |
array | The Steam offer ids to answer, as strings of up to 32 characters. Omit the field to answer every incoming offer on the account |
Steam answers offers per account session, so N offers on one account become one worker task carrying N ids, never N tasks. That is why this is an account sub-resource rather than a flat bulk endpoint. Duplicate ids are collapsed. The list is capped at 1,000 ids (422 bulk_limit_exceeded, carrying max); if you need more than that, omit the field.
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/trade-offers/respond \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"action": "accept", "offer_ids": ["9211449039", "9211449102"]}'$result = Http::withToken($apiKey)
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->post("https://dashboard.steamlabs.dev/api/v1/accounts/{$accountId}/trade-offers/respond", [
'action' => 'accept',
'offer_ids' => ['9211449039', '9211449102'],
])
->json();import requests, uuid
result = requests.post(
f"https://dashboard.steamlabs.dev/api/v1/accounts/{account_id}/trade-offers/respond",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"action": "accept", "offer_ids": ["9211449039", "9211449102"]},
).json(){
"task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
"accounts_affected": 1,
"offers_requested": 2
}offers_requested is null when you omitted offer_ids, which is how you can tell a fan-out from a list in your own logs.
List an account's confirmations
Steam mobile confirmations waiting on one account.
/api/v1/accounts/{account}/confirmationsThe latest confirmation snapshot for one account.
Requires trading.read.
Takes page and per_page only.
curl https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/confirmations \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"data": [
{
"id": "14958203481",
"nonce": "6821994733170518043",
"type": 2,
"type_name": "Trade Offer",
"creator_id": "9211449039",
"headline": "Sent a trade offer",
"summary": ["To: TradeBot_04"],
"icon": "https://community.cloudflare.steamstatic.com/economy/image/…",
"creation_time": 1785412342,
"accept_label": "Confirm",
"cancel_label": "Cancel"
}
],
"meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 },
"has_identity_secret": true,
"fetched_at": "2026-07-30T14:02:11+00:00",
"pending_task_id": null,
"last_error": null
}| Field | Type | Description |
|---|---|---|
has_identity_secret |
boolean | Whether this account can answer confirmations at all |
fetched_at |
string | When the snapshot was taken, or null if none ever was |
pending_task_id |
uuid | The confirmation task currently holding the account, if any |
last_error |
string | The error from the last confirmation task, when that task failed |
creation_time is Steam's raw Unix timestamp in seconds, not an ISO 8601 string like the rest of the API. Reading is allowed on an account with no identity secret: you get an empty list and has_identity_secret: false, which is the useful answer.
Refresh an account's confirmations
/api/v1/accounts/{account}/confirmations/refreshQueue a fresh confirmation snapshot for one account.
Requires trading.write.
No Idempotency-Key needed, and no body. Answers 202 with task_id and accounts_affected. Poll the task, then re-read the listing.
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/confirmations/refresh \
-H "Authorization: Bearer $STEAMLABS_API_KEY"An account with no identity secret cannot fetch either, and is refused with 422 identity_secret_missing. Add the secret through Steam accounts first.
Answer an account's confirmations
/api/v1/accounts/{account}/confirmations/respondConfirm or cancel some or all of an account's pending confirmations.
Requires trading.write. An Idempotency-Key header is required.
| Field | Type | Description |
|---|---|---|
accept |
boolean | true confirms, false cancels |
confirmation_ids |
array | The confirmation ids to answer, as strings of up to 64 characters. Omit the field to answer every confirmation in the latest snapshot |
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/confirmations/respond \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"accept": true, "confirmation_ids": ["14958203481"]}'const response = await fetch(
`https://dashboard.steamlabs.dev/api/v1/accounts/${accountId}/confirmations/respond`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ accept: true, confirmation_ids: ['14958203481'] }),
},
);{
"task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
"accounts_affected": 1,
"confirmations_requested": 1
}Ids are matched against the latest snapshot before anything is queued, so an id that has since vanished is dropped rather than failing the batch. If none of your ids survive that match you get 422 no_matching_confirmations with known, the number of confirmations the snapshot does hold. Refresh and try again.
confirmations_requested is null when you omitted confirmation_ids.
List routing templates
A routing template is a named, ordered ruleset you reuse when distributing items or sending an inventory selection (routing_template_id on POST /api/v1/inventory/send).
/api/v1/trade-routing-templatesYour saved routing templates, with their full rulesets.
Requires trading.read.
| Parameter | Type | Description |
|---|---|---|
search |
string | Matches the template name |
sort |
string | name (default), created_at, updated_at |
order |
string | asc (default), desc |
per_page |
integer | Page size |
curl "https://dashboard.steamlabs.dev/api/v1/trade-routing-templates?search=vault" \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"data": [
{
"id": "019fb432-77c1-71bd-9a04-4f0e2c9a8d31",
"name": "Knives to the vault",
"rules_count": 3,
"rules": [
{
"action": "skip",
"games": [],
"price_min": null,
"price_max": 0.03,
"item_ids": [],
"categories": [],
"origins": [],
"target_account_ids": [],
"target_tag_ids": [],
"target_trade_urls": [],
"strategy": "round_robin",
"target_value": null,
"items_limit_per_target": null
}
],
"created_at": "2026-07-12T10:20:00+00:00",
"updated_at": "2026-07-30T14:02:11+00:00"
}
],
"meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}Rules always come back in canonical shape: every key present, conditions you left out materialized as empty lists or null. Read them with that in mind rather than checking whether a key exists.
Create a routing template
The richest body in the API. It is the panel's rules builder, persisted.
/api/v1/trade-routing-templatesSave a named routing ruleset.
Requires trading.write.
| Field | Type | Description |
|---|---|---|
name |
string | Up to 80 characters, unique among your own templates |
rules |
array | 1 to 50 rule objects, in evaluation order |
How rules are evaluated
Rules run top to bottom, first match wins. Each item is tested against every rule's conditions in order, and the first rule that matches decides what happens to it: routed onto that rule's targets, or skipped. An item matching no rule stays where it is. A rule with no conditions matches everything, so put your narrow rules first and your catch-all last.
A rule
Conditions are ANDed, and each one left out or empty means "any".
| Field | Type | Description |
|---|---|---|
action |
string | route (send matched items to the targets) or skip (leave them alone, and stop later rules seeing them) |
games |
array | cs2, tf2, steam. The item's game must be one of these |
price_min |
number | Minimum Steam market price, in US dollars. Unpriced items count as $0 |
price_max |
number | Maximum price, in US dollars. Must be at least price_min |
item_ids |
array | Catalog item uuids. The item must be one of them. Up to 1,000 |
categories |
array | Coarse item types: weapon, knife, gloves, container, sticker, graffiti, charm, patch, agent, music_kit, collectible, tool, key, pass, gift, trading_card, booster_pack, emoticon |
origins |
array | CS2 item origins as integers, for example 0 (timed drop), 2 (purchased), 3 (traded), 8 (found in crate) |
strategy |
string | How matched items spread over the targets. Required when action is route |
target_value |
number | US dollars per target. Required for fill_value and top_up_value |
items_limit_per_target |
integer | At most this many items per target. Omit for no limit |
target_account_ids |
array | Your own Steam account uuids to route onto. Up to 1,000 |
target_tag_ids |
array | Account tag uuids. Every account carrying any of them becomes a target. Up to 1,000 |
target_trade_urls |
array | External Steam trade URLs. Up to 1,000, each up to 255 characters |
A route rule needs a strategy and at least one destination, in target_account_ids, target_tag_ids or target_trade_urls, in any combination. A skip rule needs none of them, and its target and strategy fields are ignored.
Strategies
| Value | What it does |
|---|---|
round_robin |
Spread matched items evenly across the targets |
random |
Each item goes to a random target that still has room |
fill_value |
Pack priciest first, up to target_value per target |
top_up_value |
Like fill_value, but counts what an account target already holds toward target_value |
Worked example
Skip the near-worthless items, park knives and gloves on the vault account, spread everything else over two accounts and one external trade URL.
curl -X POST https://dashboard.steamlabs.dev/api/v1/trade-routing-templates \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d @template.json$template = Http::withToken($apiKey)
->post('https://dashboard.steamlabs.dev/api/v1/trade-routing-templates', [
'name' => 'Knives to the vault',
'rules' => $rules,
])
->json();import requests
template = requests.post(
"https://dashboard.steamlabs.dev/api/v1/trade-routing-templates",
headers={"Authorization": f"Bearer {api_key}"},
json={"name": "Knives to the vault", "rules": rules},
).json(){
"name": "Knives to the vault",
"rules": [
{
"action": "skip",
"price_max": 0.03
},
{
"action": "route",
"games": ["cs2"],
"categories": ["knife", "gloves"],
"strategy": "top_up_value",
"target_value": 2500,
"items_limit_per_target": 40,
"target_account_ids": ["019fb42e-9a7e-728d-b960-8b4c2162898c"]
},
{
"action": "route",
"games": ["cs2"],
"price_min": 1.5,
"origins": [0, 8],
"strategy": "round_robin",
"target_account_ids": [
"019fb42e-9c10-7233-8f7a-5b2d90c14a77",
"019fb42e-9c22-70ab-b3c4-118e6f2d0e59"
],
"target_trade_urls": [
"https://steamcommunity.com/tradeoffer/new/?partner=39734289&token=AbCd1234"
]
}
]
}201 Created with the saved template, rules normalized:
{
"id": "019fb432-77c1-71bd-9a04-4f0e2c9a8d31",
"name": "Knives to the vault",
"rules_count": 3,
"rules": [
{
"action": "skip",
"games": [],
"price_min": null,
"price_max": 0.03,
"item_ids": [],
"categories": [],
"origins": [],
"target_account_ids": [],
"target_tag_ids": [],
"target_trade_urls": [],
"strategy": "round_robin",
"target_value": null,
"items_limit_per_target": null
},
{
"action": "route",
"games": ["cs2"],
"price_min": null,
"price_max": null,
"item_ids": [],
"categories": ["knife", "gloves"],
"origins": [],
"target_account_ids": ["019fb42e-9a7e-728d-b960-8b4c2162898c"],
"target_tag_ids": [],
"target_trade_urls": [],
"strategy": "top_up_value",
"target_value": 2500,
"items_limit_per_target": 40
},
{
"action": "route",
"games": ["cs2"],
"price_min": 1.5,
"price_max": null,
"item_ids": [],
"categories": [],
"origins": [0, 8],
"target_account_ids": [
"019fb42e-9c10-7233-8f7a-5b2d90c14a77",
"019fb42e-9c22-70ab-b3c4-118e6f2d0e59"
],
"target_tag_ids": [],
"target_trade_urls": [
"https://steamcommunity.com/tradeoffer/new/?partner=39734289&token=AbCd1234"
],
"strategy": "round_robin",
"target_value": null,
"items_limit_per_target": null
}
],
"created_at": "2026-07-30T14:02:11+00:00",
"updated_at": "2026-07-30T14:02:11+00:00"
}Validation is strict, because a bad ruleset would otherwise move items to the wrong place:
| Error field | Cause |
|---|---|
name |
Missing, over 80 characters, or already used by one of your templates |
rules |
Empty, over 50 rules, or naming a destination account, account tag or catalog item that does not exist |
rules.N.action |
Missing, or not route / skip |
rules.N.strategy |
A route rule with no strategy, or an unknown one |
rules.N.target_value |
fill_value or top_up_value with no target value |
rules.N.target_account_ids |
A route rule with no destination at all, in any of the three target fields |
rules.N.price_max |
Lower than price_min |
rules.N.target_trade_urls.N |
Not a https://steamcommunity.com/tradeoffer/new/?partner=…&token=… URL |
An account belonging to someone else fails exactly like an id that never existed, so a template cannot be used to probe for other people's accounts.
Get one routing template
/api/v1/trade-routing-templates/{id}One routing template, with its full ruleset.
Requires trading.read.
curl https://dashboard.steamlabs.dev/api/v1/trade-routing-templates/019fb432-77c1-71bd-9a04-4f0e2c9a8d31 \
-H "Authorization: Bearer $STEAMLABS_API_KEY"Replace a routing template
/api/v1/trade-routing-templates/{id}Replace a template's name and its whole ruleset. PATCH does the same.
Requires trading.write.
Same body as create, same validation, and name and rules are both required. This is a full replacement, not a patch: the rules are an ordered first-match-wins list, so "change rule 3" has no meaning without the rules above it. Read the template, edit the array, send the whole thing back.
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/trade-routing-templates/019fb432-77c1-71bd-9a04-4f0e2c9a8d31 \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Knives to the vault", "rules": [{"action": "skip", "price_max": 0.05}]}'Answers 200 with the saved template. Sending a template back under its own name is fine; the uniqueness check ignores the record being edited.
Delete a routing template
/api/v1/trade-routing-templates/{id}Delete a routing template. Returns 204.
Requires trading.write.
Work already queued is untouched. Applying a template copies its rules into the task, so a running distribution keeps the ruleset it started with.
Errors
On top of the universal codes, this group returns:
| Code | Status | Meaning |
|---|---|---|
offer_not_actionable |
422 |
The offer is not an active offer awaiting one of your accounts. Adds state |
offer_not_cancellable |
422 |
Not an offer you sent, or no longer open. Adds state |
trade_task_pending |
409 |
A trade task is already queued or running for that account. Adds pending_task_id |
identity_secret_missing |
422 |
That account has no identity secret, so it cannot answer confirmations |
confirmation_task_pending |
409 |
A confirmation task is already queued or running for that account. Adds pending_task_id |
no_matching_confirmations |
422 |
None of your ids are in the latest snapshot. Adds known |
maintenance_mode |
503 |
Platform maintenance pauses sending offers. Answering incoming offers, confirmations and cancels keep working. Adds reason |
The two _pending codes are the one-task-per-account guard, and they carry the task holding the account:
{
"message": "A trade task is already queued or running for this account.",
"code": "trade_task_pending",
"pending_task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2"
}Poll that task until it finishes, then retry. Do not spin on the endpoint: it will keep saying the same thing until the worker is done with the account.
Two plan entitlements gate this group, and GET /api/v1/me reports both up front. allows_incoming_trades covers fetching and answering incoming offers (the refresh, respond, accept and decline endpoints); allows_confirmations covers fetching and answering mobile confirmations. When your plan lacks one, those endpoints answer 403 plan_limit_reached with the upgrade path attached. Cancelling an offer you already sent is never plan-gated: withdrawing your own trade always works.