# 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.

> [!IMPORTANT]
> Those five are the identity surface: the levers an account is taken over with. This API authenticates with a long-lived bearer token that may itself be the thing that leaked, so it is exactly the wrong credential to authorize them with. They stay behind an interactive login, where a password (and a second factor) can be demanded at the moment of use.

Connected marketplaces and Steam Web API keys are also not here. They live on [Connected services](/docs/api/en/endpoints/integrations) 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.

```endpoint
method: GET
path: /api/v1/settings/account
description: Your timezone and display currency.
auth: bearer
```

Requires `account.read`.

```bash tab=curl
curl https://dashboard.steamlabs.dev/api/v1/settings/account \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```python tab=Python
import requests

settings = requests.get(
    "https://dashboard.steamlabs.dev/api/v1/settings/account",
    headers={"Authorization": f"Bearer {api_key}"},
).json()
```

```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.

```endpoint
method: PATCH
path: /api/v1/settings/account
description: Set your timezone or display currency.
auth: bearer
```

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 |

```bash tab=curl
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"}'
```

```php tab=PHP
$settings = Http::withToken($apiKey)
    ->patch('https://dashboard.steamlabs.dev/api/v1/settings/account', [
        'timezone' => 'America/New_York',
        'preferred_currency' => 'GBP',
    ])
    ->json();
```

```javascript tab=Node
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' }),
});
```

```json
{
    "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).

```endpoint
method: GET
path: /api/v1/settings/sessions
description: Your stored session and boost limits, what the fleet honours, and the bounds you may write.
auth: bearer
```

Requires `account.read`.

```bash tab=curl
curl https://dashboard.steamlabs.dev/api/v1/settings/sessions \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "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.

> [!NOTE]
> The two budgets are separate on purpose and are never collapsed into one. A warm session is idle lingering and can be evicted; a boosting session is doing the work you asked for and is pinned against every reclaim path.

## Update your session and boost limits

```endpoint
method: PATCH
path: /api/v1/settings/sessions
description: Set the idle timeout, the warm session cap, or the boost concurrency cap.
auth: bearer
```

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` |

```bash tab=curl
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}'
```

```python tab=Python
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:

```json
{
    "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."]
    }
}
```

> [!WARNING]
> Nothing here silently clamps a write. A client that stored 500 and read back 100 could not tell that from a write it lost, so an out-of-bounds value is refused instead. Read `limits.max_warm_ceiling` and `limits.boost_max_concurrent_ceiling` from `GET /api/v1/settings/sessions` and validate locally before you send.

`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`](/docs/api/en/concepts/overview), 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.

```endpoint
method: GET
path: /api/v1/settings/notifications
description: The master switch and every event x channel routing cell.
auth: bearer
```

Requires `account.read`.

```bash tab=curl
curl https://dashboard.steamlabs.dev/api/v1/settings/notifications \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "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.

> [!TIP]
> A cell routing to a channel that is not in `connected_channels` is stored happily and delivers nothing. Check that list before telling a user their Telegram alerts are on.

### 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 `status: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.

```endpoint
method: PATCH
path: /api/v1/settings/notifications
description: Flip the master switch, individual routing cells, or the chat unread mode.
auth: bearer
```

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 |

```bash tab=curl
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}}}'
```

```javascript tab=Node
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 } },
    }),
});
```

```php tab=PHP
$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:

```json
{
    "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.

```endpoint
method: GET
path: /api/v1/settings/billing-details
description: Your invoice name, address, VAT number, and whether the profile is complete.
auth: bearer
```

Requires `account.read`.

```bash tab=curl
curl https://dashboard.steamlabs.dev/api/v1/settings/billing-details \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "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](/docs/api/en/endpoints/billing). No endpoint anywhere can spend your balance or start a payment.

## Update your billing details

```endpoint
method: PATCH
path: /api/v1/settings/billing-details
description: Set the name, address, and VAT number your invoices are issued to.
auth: bearer
```

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.

```bash tab=curl
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"
      }'
```

```python tab=Python
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_business` off clears `billing_company` and `billing_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.

```json
{
    "message": "The given data was invalid.",
    "code": "validation_failed",
    "errors": {
        "billing_vat_number": ["The VAT number must be registered in your billing country."]
    }
}
```

> [!NOTE]
> Some countries are refused outright with `422` and the message "We cannot invoice customers in that country." That is a server-side rule, not a short picker, so check the response rather than assuming any ISO code is acceptable.

## 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.

```endpoint
method: GET
path: /api/v1/settings/notification-integrations
description: Your connected destinations, with every secret masked.
auth: bearer
```

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 |

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/settings/notification-integrations?is_enabled=true" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "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](/docs/api/en/concepts/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

```endpoint
method: POST
path: /api/v1/settings/notification-integrations
description: Connect Discord, Telegram, or your own webhook endpoint.
auth: bearer
```

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.

```bash tab=curl
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" }
      }'
```

```php tab=PHP
$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`:

```json
{
    "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.

> [!WARNING]
> A custom webhook must be `https`. The worker delivers over TLS only, so a plain `http://` URL is refused here instead of failing on every send.

## Read one destination

```endpoint
method: GET
path: /api/v1/settings/notification-integrations/{id}
description: One connected destination.
auth: bearer
```

Requires `account.read`.

```bash
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

```endpoint
method: PATCH
path: /api/v1/settings/notification-integrations/{id}
description: Replace a destination's settings, or turn it on and off.
auth: bearer
```

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.

```bash tab=curl
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}'
```

```javascript tab=Node
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

```endpoint
method: DELETE
path: /api/v1/settings/notification-integrations/{id}
description: Disconnect a destination. Returns 204.
auth: bearer
```

Requires `account.write`.

```bash
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.

```endpoint
method: POST
path: /api/v1/settings/notification-integrations/{id}/test
description: Queue a test send to one destination.
auth: bearer
```

Requires `account.write`.

> [!IMPORTANT]
> This endpoint requires an `Idempotency-Key` header. It is the only write on this page that costs anything: the send runs on the worker fleet through a shared egress proxy, so a client retrying after a timeout would otherwise take fleet capacity twice. Without the header you get `400 idempotency_key_required`. See [Bulk operations](/docs/api/en/concepts/bulk-operations).

```bash tab=curl
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)"
```

```python tab=Python
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:

```json
{
    "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](/docs/api/en/concepts/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](/docs/api/en/concepts/errors).
