# Profiles

> Manage the persona library a Set Profile task dresses accounts in: profiles, groups, and both generators.


Everything the **Profiles** page does, over HTTP: build the persona library by hand, organize it into groups, and fill it with either generator.

Two scopes cover the domain. `profiles.read` for the listings, `profiles.write` for everything that changes a profile, a group, or asks for a batch. Groups share them, because a group is just a bucket of profiles.

A profile is a **Profile name**, a **Summary**, a **Country** and an **Avatar**. A Set Profile task takes one profile (or picks from a group) and writes it onto a Steam account. See [Steam accounts](/docs/api/en/endpoints/accounts) for the task side.

## Check your generation allowance

Read the month's headroom before you ask for a batch, so you size the request instead of discovering the ceiling by being refused.

```endpoint
method: GET
path: /api/v1/profiles/generate/allowance
description: AI allowance, batch ceilings, and whether a batch is already running.
auth: bearer
```

Requires `profiles.read`.

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

```json
{
    "enabled": true,
    "monthly_allowance": 500,
    "remaining_this_month": 187,
    "max_batch": 187,
    "random_max_batch": 1000,
    "resets_at": "2026-08-01T00:00:00+00:00",
    "generation_in_flight": false
}
```

| Field | Type | Description |
| --- | --- | --- |
| `enabled` | bool | Whether AI generation is switched on for this deployment at all. `false` means the AI endpoint refuses everyone |
| `monthly_allowance` | int or null | AI profiles you may generate per calendar month. `null` is unlimited, `0` is none |
| `remaining_this_month` | int or null | What is left of that allowance. `null` is unlimited |
| `max_batch` | int | The largest `count` the AI endpoint accepts right now: the server's per-batch bound clamped to `remaining_this_month` |
| `random_max_batch` | int | The largest `count` the random endpoint accepts. Not affected by the allowance |
| `resets_at` | string | When the allowance refills: midnight on the first of next month, UTC |
| `generation_in_flight` | bool | `true` while one of your batches is pending or processing. Both generators refuse a second one |

> [!NOTE]
> `null` means unlimited and `0` means none. Check for `null` explicitly rather than treating a falsy value as "no limit".

## Generate profiles with AI

Queue a batch of AI-written personas. This is the only call in the domain that spends something you cannot get back.

```endpoint
method: POST
path: /api/v1/profiles/generate/ai
description: Queue an AI generation batch. Returns 202.
auth: bearer
```

Requires `profiles.write`.

An `Idempotency-Key` header is **required**. A timed-out request that you retry without one would generate (and charge for) a second batch with no way to tell. See [Bulk operations](/docs/api/en/concepts/bulk-operations).

| Field | Type | Description |
| --- | --- | --- |
| **`count`** | int | How many profiles to generate. Minimum `1`, maximum is the `max_batch` from the allowance endpoint. Above it you get `422` on `count` |
| `group_ids` | array | Groups every generated profile joins. Must be your own group ids |
| `include_summary` | bool | Write a profile description. Default `true` |
| `include_country` | bool | Give each profile a country. Default `true` |
| `generate_avatars` | bool | Render an avatar image per profile. Default `false` |
| `custom_prompt` | string | Replaces the default text prompt. Up to 4,000 characters |
| `style_hints` | string | Steers the avatar look, for example `neon portraits`. Up to 1,000 characters |

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles/generate/ai \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"count": 25, "generate_avatars": true, "style_hints": "neon portraits"}'
```

```php tab=PHP
$batch = Http::withToken($apiKey)
    ->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
    ->post('https://dashboard.steamlabs.dev/api/v1/profiles/generate/ai', [
        'count' => 25,
        'generate_avatars' => true,
        'style_hints' => 'neon portraits',
    ])
    ->json();
```

```javascript tab=Node
const response = await fetch('https://dashboard.steamlabs.dev/api/v1/profiles/generate/ai', {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({ count: 25, generate_avatars: true }),
});

const batch = await response.json();
```

```python tab=Python
batch = requests.post(
    "https://dashboard.steamlabs.dev/api/v1/profiles/generate/ai",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"count": 25, "generate_avatars": True},
).json()
```

The answer is `202`, with the batch attached:

```json
{
    "task_id": "019fb44a-3d10-71c8-9c07-4a2f1b6d9e33",
    "accounts_affected": 0,
    "profiles_requested": 25,
    "generation": {
        "id": "019fb44a-3d10-71c8-9c07-4a2f1b6d9e33",
        "method": "ai",
        "status": "pending",
        "is_finished": false,
        "requested_count": 25,
        "generated_count": 0,
        "generate_avatars": true,
        "options": {
            "include_summary": true,
            "include_country": true,
            "group_ids": [],
            "style_hints": "neon portraits"
        },
        "error": null,
        "created_at": "2026-07-30T14:02:11+00:00",
        "updated_at": "2026-07-30T14:02:11+00:00"
    }
}
```

`accounts_affected` is `0` because this queues no per-account work. `profiles_requested` is the count that was actually accepted, which can be lower than you asked for: the server re-clamps to your headroom at dispatch time.

> [!IMPORTANT]
> `task_id` is the generation's id, not a task. Follow it with `GET /api/v1/profiles/generations/{id}`, not the tasks API.

### The monthly cap

AI generation is capped **per calendar month**, and the allowance refills at midnight on the first. It is not a rolling 30 days and not your subscription anniversary.

The number comes from your plan's AI profile allowance, and an admin can override it per user (support does this to park or extend an account). Whichever applies, `GET /api/v1/profiles/generate/allowance` reports the one that binds.

Four refusals sit in front of a batch, and only one of them is a validation error:

| Situation | Answer |
| --- | --- |
| AI generation is off for the deployment | `403 ai_generation_disabled` |
| You hold no plan, or your plan carries no AI allowance | `403 plan_limit_reached` with `plan.upgrade_url` |
| The allowance exists and this month's is spent | `403 plan_limit_reached`, message naming the refill date |
| One of your batches is already pending or processing | `409 generation_in_progress` |
| `count` is above this month's headroom | `422` on `count` |

> [!WARNING]
> The one-batch-at-a-time guard is per user and covers **both** generators. A random batch in flight blocks an AI batch, and the other way round. Wait for `is_finished`, then queue the next.

## Generate random profiles

Queue a batch from the local wordlist engine. Same pipeline, same batch record, no AI.

```endpoint
method: POST
path: /api/v1/profiles/generate/random
description: Queue an algorithmic generation batch. Returns 202.
auth: bearer
```

Requires `profiles.write`.

`Idempotency-Key` is accepted and honoured, but not required. This call is free, so a mandatory header would only cost you a `400` for no benefit.

| Field | Type | Description |
| --- | --- | --- |
| **`count`** | int | How many profiles to generate. `1` to `random_max_batch` (1,000 by default) |
| `group_ids` | array | Groups every generated profile joins. Must be your own group ids |
| `include_summary` | bool | Write a short bio. Default `true`. Some profiles stay blank on purpose, like real ones |
| `include_country` | bool | Give each profile a country. Default `true` |
| `generate_avatars` | bool | Render a simple geometric avatar per profile, locally. Default `false` |

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles/generate/random \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"count": 500, "generate_avatars": true}'
```

```json
{
    "task_id": "019fb44b-8e51-7220-a1d4-6f9c02b7e884",
    "accounts_affected": 0,
    "profiles_requested": 500,
    "generation": {
        "id": "019fb44b-8e51-7220-a1d4-6f9c02b7e884",
        "method": "random",
        "status": "pending",
        "is_finished": false,
        "requested_count": 500,
        "generated_count": 0,
        "generate_avatars": true,
        "options": {
            "include_summary": true,
            "include_country": true,
            "group_ids": [],
            "style_hints": null
        },
        "error": null,
        "created_at": "2026-07-30T14:06:40+00:00",
        "updated_at": "2026-07-30T14:06:40+00:00"
    }
}
```

> [!TIP]
> Random generation is deliberately free and uncounted. It costs nothing to run, so it never touches your AI allowance, works on a plan with an allowance of zero, and works while your month is spent. If you only need names, countries and plain avatars, use this one and keep the AI allowance for personas you actually want written.

The only refusals are `409 generation_in_progress` and a `422` on `count`.

## List generations

Your generation batches, newest first. This is how you follow a batch you just queued.

```endpoint
method: GET
path: /api/v1/profiles/generations
description: Your generation batches, paginated.
auth: bearer
```

Requires `profiles.read`.

| Parameter | Values |
| --- | --- |
| `status` | `pending`, `processing`, `completed`, `partial`, `failed` |
| `method` | `ai`, `random` |
| `page`, `per_page` | See [Pagination and filtering](/docs/api/en/concepts/pagination-and-filtering) |

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/profiles/generations?status=processing" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb44a-3d10-71c8-9c07-4a2f1b6d9e33",
            "method": "ai",
            "status": "processing",
            "is_finished": false,
            "requested_count": 25,
            "generated_count": 11,
            "generate_avatars": true,
            "options": {
                "include_summary": true,
                "include_country": true,
                "group_ids": ["019fb430-1c22-73a4-9f0e-2b7c5d1e8a44"],
                "style_hints": "neon portraits"
            },
            "error": null,
            "created_at": "2026-07-30T14:02:11+00:00",
            "updated_at": "2026-07-30T14:03:58+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

A batch ends at `completed`, `partial` (some profiles were written, the rest failed) or `failed`. `is_finished` is true for all three, so poll that rather than comparing status strings. `error` carries the reason a batch stopped short.

## Get one generation

```endpoint
method: GET
path: /api/v1/profiles/generations/{id}
description: One generation batch, with its live progress.
auth: bearer
```

Requires `profiles.read`.

```bash tab=curl
curl https://dashboard.steamlabs.dev/api/v1/profiles/generations/019fb44a-3d10-71c8-9c07-4a2f1b6d9e33 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

Same shape as one row of the listing. Profiles appear in your library as the batch writes them, so `generated_count` climbs while it runs.

> [!TIP]
> To read what a batch produced, filter the profiles listing by it: `GET /api/v1/profiles?generation_id={id}`. Paging, sorting and the rest of the filters then work exactly as they do everywhere else.

## Reset use counts in bulk

Put a selection of profiles back in the pool a Set Profile task picks from.

```endpoint
method: POST
path: /api/v1/profiles/bulk/reset-use-counts
description: Set times_used back to zero across a selection.
auth: bearer
```

Requires `profiles.write`. `Idempotency-Key` is optional and honoured if sent.

Every bulk endpoint here takes a **selection**, in one of two shapes.

```json
{ "profile_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5", "019fb42e-9a7e-728d-b960-8b4c2162898c"] }
```

An explicit list, capped at 1,000 ids. Above the cap you get `422`:

```json
{ "message": "Send at most 1000 profile ids in one request…", "code": "bulk_limit_exceeded", "max": 1000 }
```

```json
{ "filters": { "group_ids": ["019fb430-1c22-73a4-9f0e-2b7c5d1e8a44"], "used": true } }
```

The same filter keys the profiles listing accepts, resolved on the server. Narrow the list with a `GET` until it looks right, then post that same filter object.

> [!CAUTION]
> Send one shape or the other, never both and never neither: either mistake is a `422`. An empty `"filters": {}` is refused too, rather than read as "every profile I own". Spell an everything-selection with a filter you wrote on purpose.

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles/bulk/reset-use-counts \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": {"group_ids": ["019fb430-1c22-73a4-9f0e-2b7c5d1e8a44"]}}'
```

```json
{ "profiles_affected": 240 }
```

> [!NOTE]
> Both shapes run inline and answer `200`. Unlike the account bulk endpoints, a filter selection here is not queued and never returns `202`: there is no per-account work behind it, so one statement covers the whole selection however large it is. See [Bulk operations](/docs/api/en/concepts/bulk-operations) for the shared contract.

## Delete profiles in bulk

```endpoint
method: POST
path: /api/v1/profiles/bulk/delete
description: Delete a selection of profiles, and their avatar files.
auth: bearer
```

Requires `profiles.write`. `Idempotency-Key` is optional and honoured if sent.

Takes the same selection as the reset endpoint: `profile_ids` (up to 1,000) or `filters`, exactly one of them, and never an empty filter object.

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles/bulk/delete \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": {"generation_id": "019fb44b-8e51-7220-a1d4-6f9c02b7e884"}}'
```

```javascript tab=Node
const response = await fetch('https://dashboard.steamlabs.dev/api/v1/profiles/bulk/delete', {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({ filters: { used: false, country: 'NL' } }),
});

const { profiles_affected: deleted } = await response.json();
```

```json
{ "profiles_affected": 118 }
```

Deleting a profile also deletes its avatar file. Accounts that already wear the profile keep what was written to Steam: a profile is a preset, not a live link.

> [!CAUTION]
> There is no undo and no confirmation step. Run the same filters through `GET /api/v1/profiles?per_page=1` first and read `meta.total`, which is what the selection will delete.

## List profiles

```endpoint
method: GET
path: /api/v1/profiles
description: Your profile library, paginated and filterable.
auth: bearer
```

Requires `profiles.read`.

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/profiles?used=false&country=NL&per_page=1" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```python tab=Python
profiles = requests.get(
    "https://dashboard.steamlabs.dev/api/v1/profiles",
    headers={"Authorization": f"Bearer {api_key}"},
    params={"used": "false", "country": "NL", "per_page": 200},
).json()["data"]
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "persona_name": "Ada",
            "country": "NL",
            "summary": "Mostly playing at night. Add me before you trade.",
            "avatar_path": "profile-avatars/pQ7x2mKe91ZbV0aD.jpg",
            "avatar_url": "https://dashboard.steamlabs.dev/storage/profile-avatars/pQ7x2mKe91ZbV0aD.jpg",
            "times_used": 0,
            "generation_id": "019fb44b-8e51-7220-a1d4-6f9c02b7e884",
            "groups": [
                { "id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44", "name": "EU personas" }
            ],
            "created_at": "2026-07-01T09:15:00+00:00",
            "updated_at": "2026-07-30T14:02:11+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 1, "total": 1, "last_page": 1 }
}
```

`times_used` counts how many times a Set Profile task has worn this profile. It is a plain counter, not a ledger: nothing records which account got which profile.

### Filters

| Parameter | Values |
| --- | --- |
| `search` | Substring of the profile name |
| `group_ids` | Group ids, comma separated (`?group_ids=a,b`) or repeated. Matches a profile in **any** of them |
| `country` | An ISO 3166-1 alpha-2 code, for example `NL`. An unknown code is a `422`, not an empty result |
| `used` | `true` for profiles used at least once, `false` for never used |
| `generation_id` | Only the profiles one generation batch produced |
| `sort` | `persona_name` (default), `times_used`, `created_at`, `updated_at`. Prefix with `-` for descending |

Anything else in `sort` is a `422`, so a typo tells you instead of quietly falling back to the default. The same filter keys are what the bulk endpoints accept inside `filters`.

## Create a profile

```endpoint
method: POST
path: /api/v1/profiles
description: Add one profile to your library. Returns 201.
auth: bearer
```

Requires `profiles.write`. `Idempotency-Key` is optional and honoured if sent.

| Field | Type | Description |
| --- | --- | --- |
| **`persona_name`** | string | The Steam profile name, 2 to 32 characters |
| `summary` | string | The profile description shown on the Steam profile page. Up to 8,000 characters |
| `country` | string | ISO 3166-1 alpha-2 code, from the list Steam's own country dropdown offers |
| `group_ids` | array | Groups this profile joins. Must be your own group ids, or you get a `422` |
| `avatar` | file | JPEG, PNG or GIF, up to 1 MB, at least 184x184 pixels |

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"persona_name": "Ada", "country": "NL", "summary": "Mostly playing at night."}'
```

```php tab=PHP
$profile = Http::withToken($apiKey)
    ->attach('avatar', file_get_contents('ada.jpg'), 'ada.jpg')
    ->post('https://dashboard.steamlabs.dev/api/v1/profiles', [
        'persona_name' => 'Ada',
        'country' => 'NL',
    ])
    ->json();
```

Returns `201` with the profile, in the same shape the listing returns.

> [!IMPORTANT]
> `avatar_path` is readable but never writable. Send the image itself as a `multipart/form-data` upload and the path is derived from it. A client-supplied path would let a profile point at any file on the public disk, including someone else's avatar, and take it down with the profile when deleted.

Steam downscales avatars to 184x184, so anything below that uploads visibly blurry. That is why the minimum is enforced rather than warned about.

## Get one profile

```endpoint
method: GET
path: /api/v1/profiles/{id}
description: One profile.
auth: bearer
```

Requires `profiles.read`.

```bash tab=curl
curl https://dashboard.steamlabs.dev/api/v1/profiles/019fb42e-9a61-70d2-818a-f6a56593f3a5 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

Returned bare, with no envelope. A profile that is not yours is a `404`, never a `403`: a `403` would confirm the id exists.

## Update a profile

```endpoint
method: PATCH
path: /api/v1/profiles/{id}
description: Change a profile. Every field is optional.
auth: bearer
```

Requires `profiles.write`. `PUT` is accepted and behaves identically.

| Field | Type | Description |
| --- | --- | --- |
| `persona_name` | string | 2 to 32 characters |
| `summary` | string | Up to 8,000 characters. `null` clears it |
| `country` | string | ISO 3166-1 alpha-2 code. `null` clears it |
| `group_ids` | array | **Replaces** the profile's groups. `[]` removes it from all of them |
| `avatar` | file | A new image, replacing the old one (the old file is deleted) |
| `remove_avatar` | bool | `true` deletes the current avatar and leaves the profile with none |

Omitted fields are left alone. `remove_avatar` exists because "sent no avatar" and "take the avatar away" are different intentions, and a `PATCH` cannot tell them apart from a missing field.

```bash tab=curl
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/profiles/019fb42e-9a61-70d2-818a-f6a56593f3a5 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"persona_name": "Ada L.", "group_ids": []}'
```

> [!TIP]
> To upload an avatar on an update, `POST` a `multipart/form-data` body with `_method=PATCH` instead. PHP does not parse multipart bodies on a real `PATCH`, so the file would arrive empty.

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles/019fb42e-9a61-70d2-818a-f6a56593f3a5 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Accept: application/json" \
  -F "_method=PATCH" \
  -F "avatar=@ada.jpg"
```

## Delete a profile

```endpoint
method: DELETE
path: /api/v1/profiles/{id}
description: Delete one profile and its avatar file. Returns 204.
auth: bearer
```

Requires `profiles.write`.

Its group memberships go with it. Accounts already wearing the profile are untouched.

## Reset one use count

```endpoint
method: POST
path: /api/v1/profiles/{id}/reset-use-count
description: Set this profile's times_used back to zero.
auth: bearer
```

Requires `profiles.write`.

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profiles/019fb42e-9a61-70d2-818a-f6a56593f3a5/reset-use-count \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

Returns the updated profile with `"times_used": 0`. Worth doing when a Set Profile task is configured not to reuse profiles and you want this one back in the pool.

## List profile groups

Groups are named buckets, and a profile can be in several. Point a Set Profile task at a group and it draws from the whole bucket as one pool.

```endpoint
method: GET
path: /api/v1/profile-groups
description: Your groups, with member counts.
auth: bearer
```

Requires `profiles.read`.

| Parameter | Values |
| --- | --- |
| `search` | Substring of the group name |
| `sort` | `name` (default), `profiles_count`, `created_at`. Prefix with `-` for descending |
| `page`, `per_page` | See [Pagination and filtering](/docs/api/en/concepts/pagination-and-filtering) |

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/profile-groups?sort=-profiles_count" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44",
            "name": "EU personas",
            "profiles_count": 240,
            "created_at": "2026-07-01T09:15:00+00:00",
            "updated_at": "2026-07-20T11:00:00+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

## Create a profile group

```endpoint
method: POST
path: /api/v1/profile-groups
description: Create a group, optionally with members. Returns 201.
auth: bearer
```

Requires `profiles.write`. `Idempotency-Key` is optional and honoured if sent.

| Field | Type | Description |
| --- | --- | --- |
| **`name`** | string | Up to 255 characters. Unique across your own groups, so a repeat is a `422` |
| `profile_ids` | array | Profiles to put in the group at creation. Must be your own profile ids |

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profile-groups \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "EU personas", "profile_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5"]}'
```

Names are unique per account holder, not globally: two customers can both have a group called "farm".

## Get one profile group

```endpoint
method: GET
path: /api/v1/profile-groups/{id}
description: One group, with its member count.
auth: bearer
```

Requires `profiles.read`.

Returned bare, with no envelope. The members are a separate endpoint, because a group can hold tens of thousands of profiles.

## Update a profile group

```endpoint
method: PATCH
path: /api/v1/profile-groups/{id}
description: Rename a group, or replace its membership wholesale.
auth: bearer
```

Requires `profiles.write`. `PUT` is accepted and behaves identically.

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Up to 255 characters, still unique across your groups |
| `profile_ids` | array | **Replaces** the group's members. `[]` empties it. Must be your own profile ids |

```bash tab=curl
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/profile-groups/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "EU personas (2026)"}'
```

`profile_ids` here is the declarative half: it states what the group contains. To add or remove a few without touching the rest, use the membership endpoints below.

## Delete a profile group

```endpoint
method: DELETE
path: /api/v1/profile-groups/{id}
description: Delete the group. Its profiles are untouched. Returns 204.
auth: bearer
```

Requires `profiles.write`.

A group is a label, and deleting a label is not a reason to destroy what it labelled. Members survive, ungrouped.

## List a group's profiles

```endpoint
method: GET
path: /api/v1/profile-groups/{id}/profiles
description: The profiles in one group, paginated.
auth: bearer
```

Requires `profiles.read`.

Takes the same filters as the profiles listing (`search`, `group_ids`, `country`, `used`, `generation_id`) plus `page` and `per_page`. Rows are ordered by profile name.

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/profile-groups/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44/profiles?used=false" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "persona_name": "Ada",
            "country": "NL",
            "avatar_path": "profile-avatars/pQ7x2mKe91ZbV0aD.jpg",
            "avatar_url": "https://dashboard.steamlabs.dev/storage/profile-avatars/pQ7x2mKe91ZbV0aD.jpg",
            "times_used": 0,
            "created_at": "2026-07-01T09:15:00+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

A lighter row than the main listing: no `summary` and no `groups`, since you already know one of them.

## Attach profiles to a group

```endpoint
method: POST
path: /api/v1/profile-groups/{id}/profiles
description: Add profiles to the group, leaving existing members alone.
auth: bearer
```

Requires `profiles.write`.

| Field | Type | Description |
| --- | --- | --- |
| **`profile_ids`** | array | At least one profile id. Must be your own, or you get a `422` naming the offending index |

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/profile-groups/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44/profiles \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"profile_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5", "019fb42e-9a7e-728d-b960-8b4c2162898c"]}'
```

```json
{ "profiles_attached": 1, "profiles_count": 241 }
```

`profiles_attached` counts only the ids that were not already in the group, so re-sending a list is safe and never duplicates a membership. `profiles_count` is the group's size afterwards.

## Detach profiles from a group

```endpoint
method: DELETE
path: /api/v1/profile-groups/{id}/profiles
description: Remove profiles from the group. The profiles themselves are kept.
auth: bearer
```

Requires `profiles.write`. Send `profile_ids` in the request body, same rules as attaching.

```bash tab=curl
curl -X DELETE https://dashboard.steamlabs.dev/api/v1/profile-groups/019fb430-1c22-73a4-9f0e-2b7c5d1e8a44/profiles \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"profile_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5"]}'
```

```json
{ "profiles_detached": 1, "profiles_count": 240 }
```

`200` with a count rather than `204`, because this deletes memberships rather than the thing you addressed. Ids that were not in the group simply do not count.

## Errors

Beyond the universal codes, this group returns:

| Status | Code | When |
| --- | --- | --- |
| `403` | `ai_generation_disabled` | AI generation is switched off for the deployment. Nothing you can buy or wait for changes it |
| `403` | `plan_limit_reached` | Your plan carries no AI profile allowance, or this calendar month's is spent. Carries `plan.upgrade_url` |
| `409` | `generation_in_progress` | One of your batches is pending or processing. Wait for `is_finished`, then retry |
| `422` | `bulk_limit_exceeded` | More than 1,000 ids in a bulk selection. Carries `max` |

Everything else follows the shared contract: `404` for a profile, group or generation that is not yours, `422` for validation, `400 idempotency_key_required` on the AI generator without its header. See [Errors](/docs/api/en/concepts/errors).
