<!-- Generated from openapi/v2.json (md/characters.md) — do not edit. Run: npm run generate -->

[Interactive reference](https://api-docs.syllaby.io/) · [OpenAPI 3.1 spec](https://api-docs.syllaby.io/openapi/v2.json) · [Docs index (llms.txt)](https://api-docs.syllaby.io/llms.txt)

# Characters

Build reusable AI characters: browse genres, create from a reference photo, generate previews, and train a consistent character.

## GET /characters/genres

**List character genres** (operationId: `listCharacterGenres`)

Lists the visual genres a character can be generated in. Use a genre `id` as `genre_id` when generating a preview (`POST /characters/{id}/preview`). Only character-capable genres are returned.

### Response `200`

```json
{
  "message": "Success.",
  "status": 200,
  "data": [
    {
      "id": 3,
      "name": "Realistic",
      "slug": "realistic",
      "preview": {
        "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
        "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
        "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
      }
    },
    {
      "id": 4,
      "name": "Anime",
      "slug": "anime",
      "preview": {
        "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
        "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
        "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
      }
    }
  ]
}
```

### Errors

- `401` — Unauthenticated
- `403` — No active **paid** subscription. The v2 public API is paid-only — every faceless and preset endpoint (reads included) requires an active paid subscription, and trials are not eligible. Two codes distinguish the cause on this `403`: `PAID-SUBSCRIPTION-REQUIRED` (subscribed but on a trial — access unlocks automatically once the subscription becomes paid) and `SUBSCRIPTION-REQUIRED` (never subscribed, expired, or canceled). Rejected before any ownership, credit, or validation check. Only `GET /me`, `GET /credits/costs`, and `GET /credits/history` are reachable without a paid subscription.
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).

## GET /characters

**List characters** (operationId: `listCharacters`)

Lists characters available to you — Syllaby's built-in `system` characters plus the `custom` characters you have created (your own sorted first). Filter with `genre` (a genre slug), `type` (`system` or `custom`), and/or `status`.

**By default the list excludes `draft` and `preview-failed` characters**, so a character you just created with `POST /characters` will not appear until it has been previewed. Pass `type=custom` to see everything you own including drafts, or `status=draft` to see only those.

An unknown `type` or `status` value returns `422`. A `genre` slug that matches nothing returns `200` with an empty list — a genre is a lookup, not an enum.

### Parameters

- `genre` (query, string) — Filter by genre slug (see `GET /characters/genres`). Example: `realistic`
- `type` (query, string) — Filter by ownership: `system` (Syllaby's built-in characters) or `custom` (characters you created). Omit to return both. `custom` also widens the status filter: it returns **every** character you own, including `draft` ones that are not yet previewed. The unfiltered list excludes `draft` and `preview-failed`. Any other value returns `422` — an unknown value used to be ignored and returned the full unfiltered list, which looked like a successful filter. Example: `custom`
- `status` (query, string) — Return only characters in this state. Overrides the default exclusion of `draft` and `preview-failed`, and applies with or without `type`. Any value outside the list returns `422`. Example: `ready`

### Response `200`

```json
{
  "message": "Success.",
  "status": 200,
  "data": [
    {
      "id": 1,
      "name": "Aria",
      "status": "ready",
      "genre": {
        "id": 3,
        "name": "Realistic",
        "slug": "realistic",
        "preview": {
          "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
          "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
          "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
        }
      },
      "gender": "female",
      "age": "25-34",
      "traits": [
        "confident",
        "friendly"
      ],
      "preview_url": "https://cdn.syllaby.dev/characters/1/preview.png",
      "previews": [
        {
          "id": 501,
          "url": "https://cdn.syllaby.dev/characters/1/sandbox-501.png"
        },
        {
          "id": 502,
          "url": "https://cdn.syllaby.dev/characters/1/sandbox-502.png"
        },
        {
          "id": 503,
          "url": "https://cdn.syllaby.dev/characters/1/sandbox-503.png"
        },
        {
          "id": 504,
          "url": "https://cdn.syllaby.dev/characters/1/sandbox-504.png"
        }
      ],
      "poses": [
        "https://cdn.syllaby.dev/characters/1/pose-1.png",
        "https://cdn.syllaby.dev/characters/1/pose-2.png"
      ],
      "created_at": "2026-01-01T12:00:00.000000Z"
    }
  ]
}
```

### Errors

- `401` — Unauthenticated
- `403` — No active **paid** subscription. The v2 public API is paid-only — every faceless and preset endpoint (reads included) requires an active paid subscription, and trials are not eligible. Two codes distinguish the cause on this `403`: `PAID-SUBSCRIPTION-REQUIRED` (subscribed but on a trial — access unlocks automatically once the subscription becomes paid) and `SUBSCRIPTION-REQUIRED` (never subscribed, expired, or canceled). Rejected before any ownership, credit, or validation check. Only `GET /me`, `GET /credits/costs`, and `GET /credits/history` are reachable without a paid subscription.
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).

## POST /characters

**Create a character** (operationId: `createCharacter`)

Creates a custom character from a reference photo. Send as `multipart/form-data` with an `image` file (plus optional `name`, `gender`, `age`, `traits`, `genre_id`).

Returns `201` with the character in an early state. Next, generate preview candidates with `POST /characters/{id}/preview`, then train the one you like with `POST /characters/{id}/train`.

### Request body

Required.

- `image` (string, required) — Required — a reference photo of the character to build (image file, max 10 MB). Send as `multipart/form-data`.
- `genre_id` (integer|null) — Optional — id of a character-capable genre (see `GET /characters/genres`).
- `name` (string|null) — Optional — display name for the character (max 250 characters). Omit it and the name is derived from your account name.
- `description` (string|null) — Optional — free-text description of the character (max 500 characters).
- `gender` (string|null) — Optional — character gender. Accepted values: `male`, `female`, `neutral`. **Omitting it stores `unknown`**, which is what the character then returns — `unknown` is a response value only and is rejected if you send it. Allowed values: `male`, `female`.
- `traits` (array|null) — Optional — list of descriptive traits for the character.
- `age` (string|null) — Optional — age or age-range label for the character (e.g. "25-34").

Example request:

```json
{
  "image": "<binary image file>",
  "name": "Aria",
  "gender": "female",
  "age": "25-34",
  "genre_id": 3
}
```

### Response `201`

```json
{
  "message": "Success.",
  "status": 201,
  "data": {
    "id": 1,
    "name": "Aria",
    "status": "draft",
    "genre": {
      "id": 3,
      "name": "Realistic",
      "slug": "realistic",
      "preview": {
        "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
        "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
        "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
      }
    },
    "gender": "female",
    "age": "25-34",
    "traits": [
      "confident",
      "friendly"
    ],
    "preview_url": "https://cdn.syllaby.dev/characters/1/preview.png",
    "previews": [],
    "poses": [],
    "created_at": "2026-01-01T12:00:00.000000Z"
  }
}
```

### Errors

- `401` — Unauthenticated
- `403` — No active **paid** subscription. The v2 public API is paid-only — every faceless and preset endpoint (reads included) requires an active paid subscription, and trials are not eligible. Two codes distinguish the cause on this `403`: `PAID-SUBSCRIPTION-REQUIRED` (subscribed but on a trial — access unlocks automatically once the subscription becomes paid) and `SUBSCRIPTION-REQUIRED` (never subscribed, expired, or canceled). Rejected before any ownership, credit, or validation check. Only `GET /me`, `GET /credits/costs`, and `GET /credits/history` are reachable without a paid subscription.
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `413` — The request body exceeded the server upload limit. This is checked before validation, so the response names no specific field. Send fewer or smaller files — a single file over the documented per-field limit returns `422` on that key instead.
- `422` — Validation error
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).

## GET /characters/{id}

**Get a character** (operationId: `getCharacter`)

Returns a single character (a `system` character or one you own). Poll this to track preview and training progress via `data.status`. When previews are ready, `data.previews[]` holds the candidate ids — pass the chosen `previews[].id` as `preview_id` to `POST /characters/{id}/train`. Responds with `404` when the id does not exist or belongs to another account.

### Parameters

- `id` (path, integer, required) — Identifier of the faceless video. Example: `1`

### Response `200`

```json
{
  "message": "Success.",
  "status": 200,
  "data": {
    "id": 1,
    "name": "Aria",
    "status": "preview-ready",
    "genre": {
      "id": 3,
      "name": "Realistic",
      "slug": "realistic",
      "preview": {
        "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
        "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
        "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
      }
    },
    "gender": "female",
    "age": "25-34",
    "traits": [
      "confident",
      "friendly"
    ],
    "preview_url": "https://cdn.syllaby.dev/characters/1/preview.png",
    "previews": [
      {
        "id": 501,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-501.png"
      },
      {
        "id": 502,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-502.png"
      },
      {
        "id": 503,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-503.png"
      },
      {
        "id": 504,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-504.png"
      }
    ],
    "poses": [],
    "created_at": "2026-01-01T12:00:00.000000Z"
  }
}
```

### Errors

- `401` — Unauthenticated
- `403` — No active **paid** subscription. The v2 public API is paid-only — every faceless and preset endpoint (reads included) requires an active paid subscription, and trials are not eligible. Two codes distinguish the cause on this `403`: `PAID-SUBSCRIPTION-REQUIRED` (subscribed but on a trial — access unlocks automatically once the subscription becomes paid) and `SUBSCRIPTION-REQUIRED` (never subscribed, expired, or canceled). Rejected before any ownership, credit, or validation check. Only `GET /me`, `GET /credits/costs`, and `GET /credits/history` are reachable without a paid subscription.
- `404` — Not found
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).

## DELETE /characters/{id}

**Delete a character** (operationId: `deleteCharacter`)

Deletes a custom character you own. Returns `204 No Content`, and the character is gone — a follow-up `GET` returns `404`, as does a repeat `DELETE`.

**System characters cannot be deleted** — a built-in character responds with `403`; a character owned by another account responds with `404`. A character that is mid-preview or mid-training also responds with `403`; wait for it to settle.

Deleting a character that a scheduler or an existing video referenced does not delete those — they simply lose the character reference.

### Parameters

- `id` (path, integer, required) — Identifier of the faceless video. Example: `1`

### Response `204`

Empty body (`204 No Content`).

### Errors

- `401` — Unauthenticated
- `403` — Forbidden — two distinct causes share this status: 1. **No active subscription** (code `SUBSCRIPTION-REQUIRED`, message "An active subscription is required.") — checked first by the active-subscription gate, before any ownership or precondition logic. The v2 public API has no free tier. 2. **Authorization / ownership or precondition failure** — the subscription is active but the action is not allowed: the resource belongs to another account, or a render precondition is unmet (e.g. `A script is required before rendering.`, `Voice was not provided.`, the video is busy, or storage is full).
- `404` — Not found
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).

## POST /characters/{id}/preview

**Generate character previews** (operationId: `previewCharacter`)

Generates preview candidates for a character in the chosen genre, using the reference image supplied at creation (you never handle its media id). This call is **synchronous** and can take up to **~90 seconds**; it is throttled to **5 requests per minute**.

Returns `201` with the character; the candidate images appear under `data.previews[]`, each with an `id` you can pass as `preview_id` to `POST /characters/{id}/train`.

**Candidates accumulate.** Every call appends to `previews[]` — earlier candidates are never pruned, and the number produced per call varies. Read the ids from the response you just received rather than assuming a fixed count or that the array holds only the latest batch.

**System characters cannot be previewed** — a built-in character resolves but is rejected with `403`.

**Two distinct `422` shapes can come back, and they mean different things:**
- **Validation error** — a bad request field (e.g. a `genre_id` that is not a character-capable genre). Standard validation shape: `{ message, errors: { genre_id: [...] } }`. Fix the input and retry.
- **Generation failure** — the request was valid but preview generation failed downstream. Error-envelope shape: `{ message, error: { code, status } }` with no `errors` bag. Poll `GET /characters/{id}` to check status, then retry.

### Parameters

- `id` (path, integer, required) — Identifier of the faceless video. Example: `1`

### Request body

Required.

- `genre_id` (integer, required) — Required — id of the character-capable genre to render the preview in. Must be one of the genres returned by `GET /characters/genres`; any other id fails validation with `422`.

Example request:

```json
{
  "genre_id": 3
}
```

### Response `201`

```json
{
  "message": "Success.",
  "status": 201,
  "data": {
    "id": 1,
    "name": "Aria",
    "status": "preview-ready",
    "genre": {
      "id": 3,
      "name": "Realistic",
      "slug": "realistic",
      "preview": {
        "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
        "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
        "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
      }
    },
    "gender": "female",
    "age": "25-34",
    "traits": [
      "confident",
      "friendly"
    ],
    "preview_url": "https://cdn.syllaby.dev/characters/1/preview.png",
    "previews": [
      {
        "id": 501,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-501.png"
      },
      {
        "id": 502,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-502.png"
      },
      {
        "id": 503,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-503.png"
      },
      {
        "id": 504,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-504.png"
      }
    ],
    "poses": [],
    "created_at": "2026-01-01T12:00:00.000000Z"
  }
}
```

### Errors

- `401` — Unauthenticated
- `403` — Forbidden — two distinct causes share this status: 1. **No active subscription** (code `SUBSCRIPTION-REQUIRED`, message "An active subscription is required.") — checked first by the active-subscription gate, before any ownership or precondition logic. The v2 public API has no free tier. 2. **Authorization / ownership or precondition failure** — the subscription is active but the action is not allowed: the resource belongs to another account, or a render precondition is unmet (e.g. `A script is required before rendering.`, `Voice was not provided.`, the video is busy, or storage is full).
- `404` — Not found
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `422` — Validation error
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).

## POST /characters/{id}/train

**Train a character** (operationId: `trainCharacter`)

💳 **Charges 10 credits.** The response `meta.credits` reports the charge and your remaining balance.

Trains a consistent character from a chosen preview candidate and kicks off pose generation. Pass `preview_id` (one of the `previews[].id` values from `POST /characters/{id}/preview`) plus `name`, `age`, and `gender`. Training runs asynchronously — poll `GET /characters/{id}` until `data.status` is `ready`, at which point `data.poses[]` holds the generated pose images.

**System characters cannot be trained** — a built-in character responds with `403`.

### Parameters

- `id` (path, integer, required) — Identifier of the faceless video. Example: `1`

### Request body

Required.

- `preview_id` (integer, required) — Required — id of the chosen preview candidate (an entry from the character's `previews[].id`, produced by `POST /characters/{id}/preview`).
- `name` (string, required) — Required — display name for the trained character.
- `age` (string, required) — Required — age or age-range label for the character (e.g. "25-34").
- `gender` (string, required) — Required — character gender: `male`, `female`, or `neutral`. Allowed values: `male`, `female`.

Example request:

```json
{
  "preview_id": 501,
  "name": "Aria",
  "age": "25-34",
  "gender": "female"
}
```

### Response `200`

```json
{
  "message": "Success.",
  "status": 200,
  "data": {
    "id": 1,
    "name": "Aria",
    "status": "pose-generating",
    "genre": {
      "id": 3,
      "name": "Realistic",
      "slug": "realistic",
      "preview": {
        "16:9": "https://cdn.syllaby.dev/faceless/genres/realistic/16:9.webp",
        "9:16": "https://cdn.syllaby.dev/faceless/genres/realistic/9:16.webp",
        "1:1": "https://cdn.syllaby.dev/faceless/genres/realistic/1:1.webp"
      }
    },
    "gender": "female",
    "age": "25-34",
    "traits": [
      "confident",
      "friendly"
    ],
    "preview_url": "https://cdn.syllaby.dev/characters/1/preview.png",
    "previews": [
      {
        "id": 501,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-501.png"
      },
      {
        "id": 502,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-502.png"
      },
      {
        "id": 503,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-503.png"
      },
      {
        "id": 504,
        "url": "https://cdn.syllaby.dev/characters/1/sandbox-504.png"
      }
    ],
    "poses": [],
    "created_at": "2026-01-01T12:00:00.000000Z"
  },
  "meta": {
    "credits": {
      "cost": 10,
      "remaining": 890
    }
  }
}
```

### Errors

- `401` — Unauthenticated
- `402` — Insufficient credits. The request is authenticated and valid, but the account balance is too low — top up and retry.
- `403` — Forbidden — two distinct causes share this status: 1. **No active subscription** (code `SUBSCRIPTION-REQUIRED`, message "An active subscription is required.") — checked first by the active-subscription gate, before any ownership or precondition logic. The v2 public API has no free tier. 2. **Authorization / ownership or precondition failure** — the subscription is active but the action is not allowed: the resource belongs to another account, or a render precondition is unmet (e.g. `A script is required before rendering.`, `Voice was not provided.`, the video is busy, or storage is full).
- `404` — Not found
- `405` — The URL exists but not for this HTTP method — e.g. `GET /presets/faceless/{preset}`, which is only defined for `PATCH` and `DELETE`. Check the verb against the endpoint reference; the `Allow` header lists the methods this path accepts.
- `409` — Email address not verified. The token is valid and the account exists, but the user has not confirmed their email address. This gate fronts every v2 endpoint. Have the user complete verification, then retry.
- `422` — Validation error
- `429` — Rate limit exceeded — requests are limited per API token (30 per minute by default). The response carries `Retry-After` (seconds to wait) and `X-RateLimit-Reset` (Unix timestamp when the window resets); back off until then and retry. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining` so you can pace requests proactively.

Error shapes and examples are shared across endpoints — see the **Error responses** section in [getting-started](getting-started.md).
