Endpoints
Settings
Read and change your own account settings: display preferences, session and boost limits, notification routing, connected destinations, and billing details.
Everything the Your account menu opens, over HTTP: timezone and display currency, the Steam session and hour boosting limits, which events reach you and where, and the name and address printed on your invoices.
One scope pair covers the whole domain. account.read for every read, account.write for every write. These are all preferences on your own row, and a key trusted to set a timezone is already trusted to flip a notification toggle.
What is not here
Password changes, email changes, MFA enrolment, browser session revocation and account deletion are not on this API, and will not be added.
Connected marketplaces and Steam Web API keys are also not here. They live on Connected services under integrations.read and integrations.write. A Discord webhook is a notification setting, not a trading integration, so it stays on this page.
Read your account preferences
Your display preferences: the timezone every time is rendered in, and the currency money is additionally shown converted into.
/api/v1/settings/accountYour timezone and display currency.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/account \
-H "Authorization: Bearer $STEAMLABS_API_KEY"import requests
settings = requests.get(
"https://dashboard.steamlabs.dev/api/v1/settings/account",
headers={"Authorization": f"Bearer {api_key}"},
).json(){
"timezone": "Europe/Amsterdam",
"effective_timezone": "Europe/Amsterdam",
"preferred_currency": "EUR",
"available_currencies": ["USD", "GBP", "EUR", "CHF", "RUB", "PLN", "BRL", "JPY", "…"]
}| Field | Type | Description |
|---|---|---|
timezone |
string, null | Your stored choice. null means you have never set one |
effective_timezone |
string | What times actually render in. Falls back to the app timezone when timezone is null, so you never have to know the fallback rule |
preferred_currency |
string, null | The currency money is also shown converted into. null shows amounts in their original currency with no conversion line |
available_currencies |
array | Every currency code preferred_currency accepts. Build your picker from this rather than a hard-coded list |
Update your account preferences
Change either preference. Both PUT and PATCH behave the same way: a field you do not send keeps its stored value.
/api/v1/settings/accountSet your timezone or display currency.
Requires account.write.
| Field | Type | Description |
|---|---|---|
timezone |
string, null | An IANA zone this deployment can resolve, for example America/New_York. Send null (or "") to clear it back to the app default |
preferred_currency |
string, null | One of available_currencies. Send null (or "") to stop converting |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/account \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"timezone": "America/New_York", "preferred_currency": "GBP"}'$settings = Http::withToken($apiKey)
->patch('https://dashboard.steamlabs.dev/api/v1/settings/account', [
'timezone' => 'America/New_York',
'preferred_currency' => 'GBP',
])
->json();const response = await fetch('https://dashboard.steamlabs.dev/api/v1/settings/account', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ timezone: 'America/New_York', preferred_currency: 'GBP' }),
});{
"timezone": "America/New_York",
"effective_timezone": "America/New_York",
"preferred_currency": "GBP",
"available_currencies": ["USD", "GBP", "EUR", "CHF", "RUB", "PLN", "BRL", "JPY", "…"]
}An unknown zone or currency is refused with 422, never stored and never silently ignored.
Read your Steam Market sync settings
Read how SteamLabs keeps your Steam Community Market listings and buy orders current.
/api/v1/settings/steam-marketYour automatic Steam Market sync cadence and targeting.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/steam-market \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"enabled": false,
"target_mode": "hybrid",
"online_interval_minutes": 15,
"offline_interval_minutes": 240,
"discovery_interval_minutes": 1440,
"exclusions": {
"account_ids": [],
"tag_ids": [],
"account_group_ids": []
},
"options": {
"target_modes": ["known_open", "hybrid", "all_eligible"],
"online_interval_minutes": [null, 5, 10, 15, 30, 60],
"offline_interval_minutes": [null, 60, 120, 240, 480, 720, 1440],
"discovery_interval_minutes": [null, 360, 720, 1440, 2880, 10080]
}
}| Field | Type | Description |
|---|---|---|
enabled |
boolean | Whether automatic Steam Market sync runs |
target_mode |
string | known_open, hybrid, or all_eligible |
online_interval_minutes |
integer, null | Routine cadence while you are online. null turns this cadence off |
offline_interval_minutes |
integer, null | Routine cadence while you are offline. null turns this cadence off |
discovery_interval_minutes |
integer, null | How often hybrid mode checks every eligible account. null turns discovery off |
exclusions |
object | Account, tag, and account-group IDs skipped by automatic sync only |
options |
object | Every accepted mode and interval. Build client pickers from these values |
known_open routinely syncs accounts that already have an open listing or buy order. hybrid does the same, then periodically checks all eligible accounts to discover market activity created outside SteamLabs. all_eligible routinely syncs every eligible account.
Update your Steam Market sync settings
/api/v1/settings/steam-marketChange automatic Steam Market sync cadence or targeting.
Requires account.write. This is a partial update, so omitted fields keep their current values.
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/steam-market \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"target_mode": "hybrid", "online_interval_minutes": 15, "offline_interval_minutes": 240}'$settings = Http::withToken($apiKey)
->patch('https://dashboard.steamlabs.dev/api/v1/settings/steam-market', [
'target_mode' => 'hybrid',
'online_interval_minutes' => 15,
'offline_interval_minutes' => 240,
])
->json();The response uses the full read shape. Intervals only accept the values in options; send null to turn one off. When enabled is true, at least one of the online or offline intervals must remain on.
discovery_interval_minutes only applies to hybrid. A non-null discovery interval with another mode returns 422. Switching from hybrid to another mode clears the stored discovery interval.
exclusions is partial too. Omit a nested list to leave it unchanged, or send an empty list to clear that category. IDs must belong to the acting user. Accounts matching any selected account, tag, or group are skipped; manual syncs are unaffected.
Read your trade-offer sync settings
/api/v1/settings/trade-offersYour automatic trade-offer sync cadence, targeting, and exclusions.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/trade-offers \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"enabled": false,
"target_mode": "hybrid",
"online_interval_minutes": 10,
"offline_interval_minutes": 120,
"discovery_interval_minutes": 720,
"exclusions": {
"account_ids": [],
"tag_ids": [],
"account_group_ids": []
},
"options": {
"target_modes": ["known_open", "hybrid", "all_accounts"],
"online_interval_minutes": [null, 5, 10, 15, 30, 60],
"offline_interval_minutes": [null, 60, 120, 240, 480, 720, 1440],
"discovery_interval_minutes": [null, 360, 720, 1440, 2880, 10080]
}
}known_open includes active offers, offers awaiting confirmation, and trades in protection. hybrid adds a discovery pass over other accounts. all_accounts routinely refreshes every account. Automatic runs are incremental after their first successful sync; manual refreshes keep the broader repair window.
Update your trade-offer sync settings
/api/v1/settings/trade-offersChange automatic trade-offer sync cadence, targeting, or exclusions.
Requires account.write. This is a partial update, including inside exclusions.
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/trade-offers \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "target_mode": "hybrid", "exclusions": {"tag_ids": ["0198abc0-1234-7000-8000-123456789abc"]}}'The response uses the full read shape. Send [] to clear an exclusion category and null to turn an interval off. At least one routine interval is required while enabled. Discovery applies only to hybrid and is cleared when switching to another mode. Foreign account, tag, or group IDs return 422.
Read your inventory refresh settings
/api/v1/settings/inventory-refreshYour automatic inventory refresh cadence and exclusions.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/inventory-refresh \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"enabled": false,
"online_interval_minutes": 120,
"offline_interval_minutes": 720,
"exclusions": {
"account_ids": [],
"tag_ids": [],
"account_group_ids": []
},
"options": {
"online_interval_minutes": [null, 30, 60, 120, 240, 360],
"offline_interval_minutes": [null, 120, 240, 480, 720, 1440]
}
}Simpler than the two sync settings above on purpose: there is no target mode and no discovery pass, because every account owns an inventory, so scheduled runs cover every account minus the exclusions. Each run queues the same lightweight task as POST /api/v1/accounts/refresh-inventory. The interval menus are coarser than the market and trade-offer ones because a full inventory fetch is the heaviest sync work an account can do.
Update your inventory refresh settings
/api/v1/settings/inventory-refreshChange automatic inventory refresh cadence or exclusions.
Requires account.write. This is a partial update, including inside exclusions.
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/inventory-refresh \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "online_interval_minutes": 60, "exclusions": {"tag_ids": ["0198abc0-1234-7000-8000-123456789abc"]}}'The response uses the full read shape. Intervals only accept the values in options; send null to turn one off. At least one of the online or offline intervals must remain on while enabled. Send [] to clear an exclusion category; omit a nested list to leave it unchanged. Foreign account, tag, or group IDs return 422. Exclusions apply only to scheduled runs; manual refreshes are unaffected.
Read your banned-count exclusions
The accounts left out of the Banned figure on your accounts page, the dashboard Accounts card, and GET /api/v1/stats. Write-offs you have already dealt with and no longer want counted.
/api/v1/settings/banned-stat-exclusionsThe accounts hidden from your banned account count.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/banned-stat-exclusions \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"account_ids": ["0198abc0-1234-7000-8000-123456789abc"],
"excluded_count": 1,
"banned_count": 4
}| Field | Type | Description |
|---|---|---|
account_ids |
array | The excluded account IDs |
excluded_count |
integer | How many accounts are excluded |
banned_count |
integer | Banned accounts after the exclusions, the figure those cards and GET /api/v1/stats show |
Replace your banned-count exclusions
/api/v1/settings/banned-stat-exclusionsReplace the set of accounts hidden from your banned account count.
Requires account.write. PATCH is accepted as an alias, but this is not a partial update either way: account_ids is required and replaces the whole set. Send [] to stop excluding everything.
curl -X PUT https://dashboard.steamlabs.dev/api/v1/settings/banned-stat-exclusions \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"account_ids": ["0198abc0-1234-7000-8000-123456789abc"]}'The response uses the read shape. Foreign or unknown account IDs return 422. Accounts that are not banned are accepted (they simply have nothing to hide yet), and an excluded account that you delete drops out of the set on its own.
Read your CSFloat repricing defaults
/api/v1/settings/csfloat-repricingDefaults and master switch for automatic CSFloat listing repricing.
Requires account.read.
{
"enabled": true,
"interval_minutes": 30,
"undercut_cents": 1,
"floor_percent": 70,
"options": { "interval_minutes": [15, 30, 60, 240] }
}These values are copied into a listing when you opt it in. Later default changes do not silently rewrite existing rules. enabled is the master switch: turning it off pauses scheduled checks without deleting per-listing rules. It starts out false and flips on automatically the first time you explicitly opt a listing in, so you only need to touch it here to pause or resume everything at once.
Update your CSFloat repricing defaults
/api/v1/settings/csfloat-repricingChange CSFloat repricing defaults or pause all scheduled checks.
Requires account.write. Omitted fields keep their current values.
| Field | Type | Description |
|---|---|---|
enabled |
boolean | Master scheduled-check switch |
interval_minutes |
integer | 15, 30, 60, or 240 |
undercut_cents |
number | Default undercut, 0.1 to 10,000 cents. Market.CSGO prices in tenths of a cent, so it accepts one decimal place; other venues take whole cents |
floor_percent |
integer | Default fixed-floor percentage, 1 to 100 |
apply_to_existing |
boolean | Also copy the three defaults into every existing rule. Default false |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/csfloat-repricing \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"interval_minutes":60,"floor_percent":75,"apply_to_existing":true}'Read or update your market.csgo repricing defaults
market.csgo has the same defaults and master switch as CSFloat, stored separately for the venue.
/api/v1/settings/marketcsgo-repricingDefaults and master switch for automatic market.csgo listing repricing.
Requires account.read. The response uses the same fields and interval options shown above.
/api/v1/settings/marketcsgo-repricingChange market.csgo repricing defaults or pause all scheduled checks.
Requires account.write. It accepts enabled, interval_minutes, undercut_cents, floor_percent, and apply_to_existing. Applying defaults only updates existing market.csgo rules.
Read or update your DMarket repricing defaults
DMarket has the same defaults and master switch, stored separately for the venue. The floor is computed from the Steam market reference price, or from the listing's current DMarket price when that is missing.
/api/v1/settings/dmarket-repricingDefaults and master switch for automatic DMarket listing repricing.
Requires account.read. The response uses the same fields and interval options as CSFloat.
/api/v1/settings/dmarket-repricingChange DMarket repricing defaults or pause all scheduled checks.
Requires account.write. It accepts enabled, interval_minutes, undercut_cents, floor_percent, and apply_to_existing. Applying defaults only updates existing DMarket rules.
Read your AssetPay instant sell unsold rule
Each AssetPay pool listing carries its own instant sell deadline, set when it is listed (instant_sell_after_days on the sell call or task) or changed later with Schedule an instant sell. When it passes, the listing comes down and its item is sold to AssetPay at the instant price. This is the master switch for all of those deadlines, the default a new listing is prefilled with, and the price floor. It is off until you turn it on.
/api/v1/settings/assetpay-instant-sell-unsoldWhether unsold AssetPay listings are sold at the instant price after a limit, and that limit.
Requires account.read.
{
"enabled": false,
"after_days": 14,
"min_cents": 100
}The check runs hourly. Only listings AssetPay is buying right now, at or above min_cents, are sold; a listing AssetPay does not take stays up at its old price with its deadline pushed out a day. Each run that sells something leaves a notification. Turning enabled off pauses every listing's deadline without clearing it.
Update your AssetPay instant sell unsold rule
/api/v1/settings/assetpay-instant-sell-unsoldTurn the rule on or off, or change its limit and minimum.
Requires account.write. Omitted fields keep their current values.
| Field | Type | Description |
|---|---|---|
enabled |
boolean | Whether the rule runs |
after_days |
integer | Default deadline new listings are prefilled with, 1 to 365 days |
min_cents |
integer | Smallest instant price worth selling for, in cents. 0 sells everything |
apply_to_existing |
boolean | Also set every open AssetPay listing to sell after_days days after it was listed. Default false |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/assetpay-instant-sell-unsold \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled":true,"after_days":7,"min_cents":200}'Read your DMarket sync interval
DMarket has no live feed, so SteamLabs refreshes each connected account on a schedule: listings, closed offers, and the USD balance. This is the cadence of that background pass. Actions you take (list, delist, reprice, instant sell, Sync now) always refresh immediately.
/api/v1/settings/dmarket-syncHow often connected DMarket accounts are refreshed in the background.
Requires account.read.
{
"sync_interval_minutes": 30,
"options": { "sync_interval_minutes": [5, 10, 15, 30, 60, 120, 240] }
}Update your DMarket sync interval
/api/v1/settings/dmarket-syncChange how often connected DMarket accounts are refreshed in the background.
Requires account.write.
| Field | Type | Description |
|---|---|---|
sync_interval_minutes |
integer | 5, 10, 15, 30, 60, 120, or 240 |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/dmarket-sync \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sync_interval_minutes":60}'Read your Skinport sync interval
Skinport has no live feed, so SteamLabs refreshes each connected account on a schedule: listings, custody inventory, balance, and sales. This is the cadence of that background pass. Actions you take (list, cancel, relist, withdraw, Sync now) always refresh immediately.
/api/v1/settings/skinport-syncHow often connected Skinport accounts are refreshed in the background.
Requires account.read.
{
"sync_interval_minutes": 30,
"options": { "sync_interval_minutes": [15, 30, 60, 120, 240, 480] }
}Update your Skinport sync interval
/api/v1/settings/skinport-syncChange how often connected Skinport accounts are refreshed in the background.
Requires account.write.
| Field | Type | Description |
|---|---|---|
sync_interval_minutes |
integer | 15, 30, 60, 120, 240, or 480 |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/skinport-sync \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sync_interval_minutes":60}'Read your session and boost limits
The two cards on the Steam Sessions page: Session lifetime (how long a session lingers after its last task, how many may linger at once) and Hour boosting (how many accounts may boost at the same time).
/api/v1/settings/sessionsYour stored session and boost limits, what the fleet honours, and the bounds you may write.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/sessions \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"steam_session_idle_timeout_minutes": 60,
"steam_session_max_warm": 500,
"boost_max_concurrent": null,
"effective": {
"steam_session_idle_timeout_minutes": 60,
"steam_session_max_warm": 100,
"boost_max_concurrent": 125
},
"limits": {
"idle_timeout_minutes_min": 5,
"idle_timeout_minutes_max": 1440,
"max_warm_ceiling": 100,
"boost_max_concurrent_ceiling": 250,
"boost_max_concurrent_default": 125
}
}| Field | Type | Description |
|---|---|---|
steam_session_idle_timeout_minutes |
integer | How long a signed-in account stays open after its last task finishes. Defaults to 30 |
steam_session_max_warm |
integer | How many of your accounts may stay signed in while idle. 0 signs out after every task. Defaults to 25 |
boost_max_concurrent |
integer or null | How many accounts may boost at once across all your plans. 0 parks your boosting without touching your plans. null means you have not chosen and follows limits.boost_max_concurrent_default |
effective |
object | The same three numbers as the fleet honours them, after the ceilings are applied |
limits |
object | The bounds a write is validated against, plus what a null boost setting resolves to |
Stored and effective are both reported because they can legitimately differ. A ceiling clamps on read and never rewrites what you stored, so an admin lowering your ceiling leaves your preference intact and raising it back restores your original number verbatim. In the example above the account asked for 500 warm sessions and the fleet is honouring 100.
The boost number differs for the other reason: it was never set, so it follows the default of half your ceiling and moves with your plan. Half rather than all of it because the ceiling is what your tier sells, not a recommendation, and a fleet that starts pinned at it has no headroom left to turn boosting up when you want more.
Update your session and boost limits
/api/v1/settings/sessionsSet the idle timeout, the warm session cap, or the boost concurrency cap.
Requires account.write.
| Field | Type | Description |
|---|---|---|
steam_session_idle_timeout_minutes |
integer | Minutes, between limits.idle_timeout_minutes_min (5) and limits.idle_timeout_minutes_max (1440). Any value in range works, the dashboard's preset list is only there to keep a select short |
steam_session_max_warm |
integer | 0 to limits.max_warm_ceiling |
boost_max_concurrent |
integer or null | 0 to limits.boost_max_concurrent_ceiling. Send null to go back to the default (half your ceiling), which then tracks your plan again. 0 is not the same thing: it parks your boosting |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/sessions \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"steam_session_idle_timeout_minutes": 45, "steam_session_max_warm": 40}'sessions = requests.patch(
"https://dashboard.steamlabs.dev/api/v1/settings/sessions",
headers={"Authorization": f"Bearer {api_key}"},
json={"steam_session_idle_timeout_minutes": 45, "steam_session_max_warm": 40},
).json()The response is the full read shape again, so one call tells you both what was stored and what the fleet will honour.
The ceilings are refused, not trimmed
steam_session_max_warm is bounded by a ceiling an admin sets on your account. Asking for more than it returns 422, and nothing is written:
{
"message": "The given data was invalid.",
"code": "validation_failed",
"errors": {
"steam_session_max_warm": ["Your warm session ceiling is 100. Read it from GET /api/v1/settings/sessions."]
}
}max_warm_ceiling is per user, set by an admin, and capped at 10,000 platform-wide.
boost_max_concurrent_ceiling resolves in this order, first answer wins: a per-user override an admin typed by hand, then your subscription plan's boost cap, then 10,000. Because the override can sit above or below the plan number, limits.boost_max_concurrent_ceiling on this endpoint is the authority for a write, not plan.max_boost_concurrent on GET /api/v1/me, which reports the plan's own cap.
boost_max_concurrent_default is half that ceiling, rounded down and never below 1 unless the ceiling itself is 0. It is what a stored null resolves to, so it moves the moment your plan does. When no ceiling comes from a plan or an override at all, there is nothing to take half of and the default is a flat 25.
Neither ceiling produces a 403 plan_limit_reached. Over the bound is a 422, whichever source the bound came from.
Read your notification preferences
The master switch, the full event by channel routing matrix, and the chat unread mode stored beside it. This is what the Notifications page renders.
/api/v1/settings/notificationsThe master switch and every event x channel routing cell.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/notifications \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"notifications": true,
"chat_unread_mode": "mentions",
"channels": ["dashboard", "discord", "telegram", "custom_webhook"],
"connected_channels": ["discord"],
"preferences": {
"task_failed": { "dashboard": true, "discord": true, "telegram": false, "custom_webhook": false },
"market_listing_sold": { "dashboard": true, "discord": false, "telegram": false, "custom_webhook": false },
"billing_invoice_issued": { "dashboard": true, "discord": false, "telegram": false, "custom_webhook": false }
}
}| Field | Type | Description |
|---|---|---|
notifications |
boolean | The master switch (All notifications). Off means nothing goes out on any channel, whatever the matrix says |
chat_unread_mode |
string | What counts toward your chat unread badges: all, mentions, or mute |
channels |
array | Every channel the matrix has columns for |
connected_channels |
array | Which external channels have a destination connected and turned on |
preferences |
object | Every event, with every channel, as a boolean |
preferences always comes back complete, every event crossed with every channel. Your stored row only holds the cells that differ from an event's default, which is what lets a changed default reach everyone who never touched that row, but you never have to know that: read the merged matrix, patch the cells you care about.
Channels
| Channel | Delivers to |
|---|---|
dashboard |
The bell in the dashboard, plus the live toast |
discord |
Your connected Discord webhook |
telegram |
Your connected Telegram bot and chat |
custom_webhook |
Your own https endpoint, as a JSON POST |
Every event defaults to dashboard on and the three external channels off.
Events
| Event | Fires when |
|---|---|
task_failed |
A task ends Failed |
workflow_run_completed |
An automation run finishes |
workflow_run_failed |
An automation run fails |
market_listing_sold |
One of your listings sold, on any marketplace |
market_sale_delivered |
A sale reached its buyer and the payout is on its way |
market_sale_settled |
A sale cleared its hold and the money moved to your marketplace balance |
market_sale_needs_attention |
A sale needs manual delivery, failed to deliver, or is near the end of its window |
marketcsgo_presence_broken |
An account with live Market.CSGO listings cannot be kept online |
trade_up_completed |
A trade-up contract completed |
trade_up_failed |
A trade-up contract failed |
trade_up_dry_run |
The trade-up dry run digest is ready |
chat_mentioned |
Someone @mentions you in chat |
billing_deposit_credited |
A top-up went through and landed on your balance |
billing_deposit_failed |
A top-up did not go through |
billing_invoice_issued |
An invoice is ready to download |
subscription_started |
You subscribed to a plan, or switched plans |
subscription_renewed |
Your plan renewed itself and the money came off your balance |
subscription_renewal_failed |
Your balance could not cover a renewal |
subscription_expired |
Your paid period ran out and your plan ended |
Sale events are venue-agnostic. Selling on CSFloat and on market.csgo raises the same situations, so the marketplace is in the message body rather than in a toggle per venue.
Update your notification preferences
A real PATCH: the cells you send are applied over the current matrix and everything else is left alone. Turning one event's Discord routing on does not mean restating sixty booleans.
/api/v1/settings/notificationsFlip the master switch, individual routing cells, or the chat unread mode.
Requires account.write.
| Field | Type | Description |
|---|---|---|
notifications |
boolean | The master switch |
chat_unread_mode |
string | all, mentions, or mute |
preferences |
object | Event keys, each holding channel keys, each a boolean. Only the cells you send are changed |
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/notifications \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"preferences": {"task_failed": {"discord": true}}}'const response = await fetch('https://dashboard.steamlabs.dev/api/v1/settings/notifications', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
preferences: { task_failed: { discord: true, telegram: true } },
}),
});$preferences = Http::withToken($apiKey)
->patch('https://dashboard.steamlabs.dev/api/v1/settings/notifications', [
'preferences' => ['task_failed' => ['discord' => true]],
])
->json('preferences');The response is the full read shape, with your change merged in.
An unknown event key or channel key is refused with 422 rather than stored. A typo would otherwise write a row nothing ever reads and raise no error anyone ever sees:
{
"message": "The given data was invalid.",
"code": "validation_failed",
"errors": {
"preferences.task_failed": ["Unknown channel: carrier_pigeon."]
}
}Read your billing details
The name and address printed on every invoice, plus the optional EU VAT number.
/api/v1/settings/billing-detailsYour invoice name, address, VAT number, and whether the profile is complete.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/billing-details \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"billing_is_business": true,
"billing_first_name": "Ada",
"billing_last_name": "Lovelace",
"billing_company": "Analytical Engines BV",
"billing_address_line_1": "12 Analytical Way",
"billing_address_line_2": null,
"billing_postal_code": "1011AB",
"billing_city": "Amsterdam",
"billing_state": null,
"billing_country": "NL",
"billing_vat_number": "NL123456789B01",
"vat_number_valid": true,
"vat_number_validated_at": "2026-07-30T14:02:11+00:00",
"is_complete": true
}| Field | Type | Description |
|---|---|---|
vat_number_valid |
boolean, null | The cached VIES verdict. Read only. null means "not answered", either there was nothing to check or VIES was unreachable, and VAT is charged in both cases |
vat_number_validated_at |
timestamp, null | When that verdict was last refreshed |
is_complete |
boolean | Whether this profile carries everything an invoice needs. The top-up flow asks the same question before starting a checkout |
Read is_complete rather than reimplementing the rule. It is the only question that actually matters, and a top-up refuses to start while it is false.
This is the one billing thing the API may write, and it is address data, not payment data. Balances, deposits, ledger and invoices are read only, on Billing. No endpoint anywhere can spend your balance or start a payment.
Update your billing details
/api/v1/settings/billing-detailsSet the name, address, and VAT number your invoices are issued to.
Requires account.write.
| Field | Type | Description |
|---|---|---|
billing_is_business |
boolean | Declares who is buying. It decides which fields apply, never what VAT is charged |
billing_first_name |
string, max 100 | Invoice name. May be left out, may not be blanked |
billing_last_name |
string, max 100 | Invoice name. May be left out, may not be blanked |
billing_company |
string, max 150 | Business customers only |
billing_address_line_1 |
string, max 150 | Street and number |
billing_address_line_2 |
string, max 150 | Optional second line |
billing_postal_code |
string, max 20 | Postal code |
billing_city |
string, max 100 | City |
billing_state |
string, max 100 | State or province, where one applies |
billing_country |
string | ISO 3166 alpha-2. Decides the VAT treatment |
billing_vat_number |
string, max 20 | EU VAT number with its country prefix, for example NL123456789B01 |
Bold fields are the ones an invoice cannot be issued without. Every field is optional in the request (this is a PATCH, so leaving one out keeps it), but a bold field sent as "" or null is refused with 422. An invoice is a legal document: it has to name someone and place them somewhere.
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/billing-details \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"billing_first_name": "Ada",
"billing_last_name": "Lovelace",
"billing_address_line_1": "12 Analytical Way",
"billing_postal_code": "1011AB",
"billing_city": "Amsterdam",
"billing_country": "NL"
}'details = requests.patch(
"https://dashboard.steamlabs.dev/api/v1/settings/billing-details",
headers={"Authorization": f"Bearer {api_key}"},
json={"billing_city": "Rotterdam", "billing_postal_code": "3011AA"},
).json()
print(details["is_complete"])Three things happen on save that are worth knowing about:
- Empty optional fields become
null. A stored blank would print as an empty line on an invoice and read as present to every completeness check. - Turning
billing_is_businessoff clearsbilling_companyandbilling_vat_number. A stale company name would keep printing as your legal name, and a stale VAT number would keep being offered to VIES. - The VAT number is re-checked against VIES on every save, not trusted from the previous verdict. The number and the country can both move in one request, and a stale "valid" would zero-rate a customer who no longer qualifies.
The VAT number's shape is validated first: it must parse, and its country prefix must match the billing country this patch leaves you with, not the one you had before it.
{
"message": "The given data was invalid.",
"code": "validation_failed",
"errors": {
"billing_vat_number": ["The VAT number must be registered in your billing country."]
}
}List notification destinations
Your connected external destinations: the Discord webhook, the Telegram bot, and your own webhook endpoint. This is the Notification destinations card, as a collection.
/api/v1/settings/notification-integrationsYour connected destinations, with every secret masked.
Requires account.read.
| Parameter | Type | Description |
|---|---|---|
type |
string | discord, telegram, or custom_webhook |
is_enabled |
boolean | true or false |
per_page |
integer | Defaults to 50, capped at 200 |
curl "https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations?is_enabled=true" \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"data": [
{
"id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
"type": "discord",
"channel": "discord",
"is_enabled": true,
"config": { "webhook_url": "••••tail" },
"verified_at": "2026-07-28T11:20:04+00:00",
"last_tested_at": "2026-07-30T14:02:11+00:00",
"last_test_status": "completed",
"last_test_error": null,
"created_at": "2026-07-01T09:15:00+00:00",
"updated_at": "2026-07-30T14:02:11+00:00"
}
],
"meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}| Field | Type | Description |
|---|---|---|
type |
string | Which service this row is |
channel |
string | The matrix channel it feeds, so you can line a row up with preferences without knowing the mapping |
is_enabled |
boolean | Whether this destination is turned on. A row that is off delivers nothing and cannot be tested |
config |
object | The stored settings, with every credential masked |
verified_at |
timestamp, null | When a test send first succeeded. null means never proven to work |
last_test_status |
string, null | completed or failed, from the last test send |
last_test_error |
string, null | Why the last test failed, when it did |
There is at most one row per type, so this collection is three rows on its worst day. It paginates anyway, because every collection on this API answers the same envelope. See Pagination and filtering.
Secrets are write-only
No response ever returns a stored credential in full. webhook_url, bot_token and url come back as •••• plus their last four characters, which is enough to recognize a value and not enough to use one. A Telegram chat_id reads back verbatim, because it is an address, not a secret.
The dashboard reveals a stored webhook URL to a session that just passed an interactive login. This surface answers a bearer token that may itself be the thing that leaked, and a readable webhook URL is a way to post as you into your own Discord forever. To change a value, send a new one.
Connect a notification destination
/api/v1/settings/notification-integrationsConnect Discord, Telegram, or your own webhook endpoint.
Requires account.write.
| Field | Type | Description |
|---|---|---|
type |
string | discord, telegram, or custom_webhook |
config |
object | The settings for that type, from the table below |
is_enabled |
boolean | Defaults to true, so a destination works the moment it is connected |
Config keys per type:
| Type | Key | Rules |
|---|---|---|
discord |
webhook_url |
A URL, max 500 characters. Create it in your Discord server settings |
telegram |
bot_token |
Max 255 characters. From @BotFather |
telegram |
chat_id |
Max 64 characters. Message your bot once to find it |
custom_webhook |
url |
Must start with https://, max 500 characters |
Keys the type does not define are ignored rather than rejected, so nothing extra can be smuggled into the stored config.
curl -X POST https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "telegram",
"config": { "bot_token": "8012345678:AAG_exampletoken", "chat_id": "99887766" }
}'$integration = Http::withToken($apiKey)
->post('https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations', [
'type' => 'custom_webhook',
'config' => ['url' => 'https://hooks.example.net/steamlabs'],
])
->json();Answers 201:
{
"id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44",
"type": "telegram",
"channel": "telegram",
"is_enabled": true,
"config": { "bot_token": "••••oken", "chat_id": "99887766" },
"verified_at": null,
"last_tested_at": null,
"last_test_status": null,
"last_test_error": null,
"created_at": "2026-07-30T14:02:11+00:00",
"updated_at": "2026-07-30T14:02:11+00:00"
}You get one row per type. A second POST for a type you already have is 409 integration_already_exists rather than a silent overwrite, because the caller either meant to PATCH the existing row or is about to destroy a webhook URL it cannot read back.
Read one destination
/api/v1/settings/notification-integrations/{id}One connected destination.
Requires account.read.
curl https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44 \
-H "Authorization: Bearer $STEAMLABS_API_KEY"Same shape as one row of the listing, secrets masked the same way. An id that is not yours returns 404 not_found, exactly as an id that does not exist does.
Update a destination
/api/v1/settings/notification-integrations/{id}Replace a destination's settings, or turn it on and off.
Requires account.write.
| Field | Type | Description |
|---|---|---|
is_enabled |
boolean | Turn the destination on or off |
config |
object | Any of the keys that type defines. Merged over what is stored |
config keys are merged, so a patch that only flips is_enabled keeps the credentials. Sending a key as "" removes it: an empty webhook URL is not a webhook URL.
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44 \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"is_enabled": false}'await fetch(`https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ config: { webhook_url: newWebhookUrl } }),
});Sending type is refused with 422, not ignored. There is one row per type, so "change the type" is really "delete this one and connect the other", and quietly dropping the field would let you believe you had done something.
Disconnect a destination
/api/v1/settings/notification-integrations/{id}Disconnect a destination. Returns 204.
Requires account.write.
curl -X DELETE https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44 \
-H "Authorization: Bearer $STEAMLABS_API_KEY"Your routing matrix is untouched. Cells pointing at that channel stay set and simply deliver nothing until you connect a destination again, which is what connected_channels on the notification settings endpoint reports.
Send a test notification
Sends the same test message the Send test button does, so you can prove a destination works before an event depends on it.
/api/v1/settings/notification-integrations/{id}/testQueue a test send to one destination.
Requires account.write.
curl -X POST https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44/test \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"import uuid
queued = requests.post(
f"https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations/{integration_id}/test",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": str(uuid.uuid4()),
},
).json()
print(queued["task_id"])Answers 202, because the fleet does the sending:
{
"task_id": "019fb457-d631-71a5-9042-d663a53bc51e",
"accounts_affected": 0,
"integration_id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44"
}accounts_affected is 0 because no Steam account is involved. The envelope is the house 202 shape all the same, so you parse one thing everywhere.
Follow the outcome either through GET /api/v1/tasks/{id} or by re-reading the destination: last_test_status becomes completed or failed, last_test_error carries the reason on a failure, and verified_at is stamped on the first success.
Two refusals are specific to this endpoint. A destination that is turned off answers 409 integration_disabled: enable it first. And when the shared egress pool is momentarily empty you get 503 shared_pool_empty, which is worth retrying shortly. There is deliberately no fallback to sending from our own address, because that would leak the platform's IP to whatever endpoint you configured.
Errors
Beyond the universal codes, this group returns:
| Code | Status | Meaning |
|---|---|---|
integration_already_exists |
409 |
You already have a destination of that type. Update it instead |
integration_disabled |
409 |
The destination is turned off, so it cannot be tested |
shared_pool_empty |
503 |
No shared egress proxy was free to send the test from. Retry shortly |
maintenance_mode |
503 |
Platform maintenance pauses test sends (they ride the fleet). Adds reason, see Errors |
No endpoint on this page returns 403 plan_limit_reached. The boost concurrency ceiling is plan-derived, but exceeding it is a 422 on boost_max_concurrent, not a plan refusal, because you are being told about a bound you can read rather than sold an upgrade.
Everything else follows the standard shapes: 422 validation_failed with errors keyed by field, 404 not_found for an id that is not yours, 403 missing_scope for a key without account.read or account.write. See Errors.