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 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": 30,
"effective": {
"steam_session_idle_timeout_minutes": 60,
"steam_session_max_warm": 100,
"boost_max_concurrent": 30
},
"limits": {
"idle_timeout_minutes_min": 5,
"idle_timeout_minutes_max": 1440,
"max_warm_ceiling": 100,
"boost_max_concurrent_ceiling": 250
}
}| 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 | How many accounts may boost at once across all your plans. 0 parks your boosting without touching your plans. Defaults to 25 |
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 |
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.
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 | 0 to limits.boost_max_concurrent_ceiling |
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.
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.