# API reference

_Generated by `tools/gen_api_docs.py` from `app/api`. Do not edit by hand._

Base URL: `https://app.callqa.dev/api/v1`. Version: `1.0.0`. 124 endpoints.

- **Try every endpoint in your browser: [API explorer](https://app.callqa.dev/api/v1/docs).** Paste your API key once, then send real requests and copy the curl of each one.
- Machine readable: [`api/openapi.json`](api/openapi.json) (OpenAPI 3.1, import it in Postman, Insomnia or a client generator). Live schema: `/api/v1/openapi.json`.
- Every request needs `Authorization: Bearer <API key>`. See [Authentication](authentication.md).
- Every endpoint can also answer `401`, `422` (invalid input) and `429`; endpoints that need a scope answer `403` when the key lacks it. See [Errors](errors.md).

**Scope** is the API key scope the endpoint needs (`admin` implies all). **Roles** are the dashboard roles allowed to do the same thing in the UI.

## Index

- [Organization](#organization)
- [Targets](#targets)
- [Scenarios](#scenarios)
- [Runs](#runs)
- [Calls](#calls)
- [Tags](#tags)
- [Versions](#versions)
- [Environments](#environments)
- [Webhooks](#webhooks)
- [Alerts](#alerts)
- [API keys & logs](#api-keys--logs)
- [Exports for LLMs](#exports-for-llms)
- [Billing](#billing)
- [Support](#support)

## Organization

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/audit-log` | Audit log (admin) | `admin` | admin | 200 |
| `GET` | `/api/v1/exports` | List exports | `admin` | admin | 200 |
| `POST` | `/api/v1/exports` | Request an export | `admin` | admin | 200, 201 |
| `GET` | `/api/v1/exports/{export_id}` | Get an export | `admin` | admin | 200 |
| `POST` | `/api/v1/invitations` | Invite a member | `admin` | admin | 201 |
| `GET` | `/api/v1/me` | Who am I | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/members` | List members | `read` | admin, member, guest | 200 |
| `PATCH` | `/api/v1/members/{member_id}` | Change a member's role | `admin` | admin | 200 |
| `DELETE` | `/api/v1/members/{member_id}` | Remove a member | `admin` | admin | 204 |

### GET /api/v1/audit-log

Audit log (admin).

Who did what in your organization, newest first: members, keys, webhooks, alert channels, billing, number verification, deletions. Never contains secrets. Filter by `action` (exact, or a prefix ending with `.` such as `webhook.`), `actor_api_key_prefix` and `since` / `until`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `action` | query | string | no |  |
| `actor_api_key_prefix` | query | string | no |  |
| `since` | query | string (date-time) | no |  |
| `until` | query | string (date-time) | no |  |
| `limit` | query | integer | no | 50 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_AuditOut_](#page_auditout_), `401`, `403`, `422`, `429`.

### GET /api/v1/exports

List exports.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `limit` | query | integer | no | 20 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_ExportOut_](#page_exportout_), `401`, `403`, `422`, `429`.

### POST /api/v1/exports

Request an export.

A full export of your organization (admin scope): a zip of JSON files with the organization and members, numbers and attestations, scenarios and test cases, runs, one file per call (trace, transcript, result), releases, tags, audit log, legal acceptances, usage and wallet ledger. Secrets are never exported. The archive is built in the background: poll `GET /exports/{id}` until `status` is `done`, then download `download_url` (signed, valid 24 hours, no API key needed). Archives are deleted after 7 days. If an export is already being built, it is returned (200).

Responses: `200` [ExportOut](#exportout), `201` [ExportOut](#exportout), `401`, `403`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/exports/{export_id}

Get an export.

`download_url` is set when `status` is `done` (a fresh 24 h link on every call).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `export_id` | path | string (uuid) | yes |  |

Responses: `200` [ExportOut](#exportout), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/invitations

Invite a member.

Sends an invitation email valid 7 days. Seats are limited by the plan: when they are all taken (pending invitations count), 403 `feature_not_in_plan` with `required_plan` and `upgrade_url`.

Body (`application/json`): [InvitationIn](#invitationin).

Responses: `201` object, `400` [Error](#error), `401`, `403` [Error](#error), `422`, `429`.

### GET /api/v1/me

Who am I.

The organization, plan and scopes of the API key used for the call.

Responses: `200` [MeOut](#meout), `401`, `403`, `422`, `429`.

### GET /api/v1/members

List members.

Responses: `200` list of [MemberOut](#memberout), `401`, `403`, `422`, `429`.

### PATCH /api/v1/members/{member_id}

Change a member's role.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `member_id` | path | integer | yes |  |

Body (`application/json`): [RoleIn](#rolein).

Responses: `200` [MemberOut](#memberout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/members/{member_id}

Remove a member.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `member_id` | path | integer | yes |  |

Responses: `204`, `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

## Targets

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/targets` | List target numbers | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/targets` | Register a target number | `write` | admin, member | 201 |
| `GET` | `/api/v1/targets/{target_id}` | Get a target | `read` | admin, member, guest | 200 |
| `PATCH` | `/api/v1/targets/{target_id}` | Update a target | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/targets/{target_id}` | Delete a target (admin) | `admin` | admin | 204 |
| `GET` | `/api/v1/targets/{target_id}/verification` | Verification status | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/targets/{target_id}/verification/attest` | Sign the attestation | `write` | admin, member | 200 |
| `POST` | `/api/v1/targets/{target_id}/verification/call` | Place the verification call | `run` | admin, member | 202 |
| `POST` | `/api/v1/targets/{target_id}/verification/retry` | Retry with a new code | none |  | 201 |
| `POST` | `/api/v1/targets/{target_id}/verification/start` | Start a verification (new code) | none |  | 201 |
| `POST` | `/api/v1/targets/{target_id}/verify` | Sign the attestation (legacy) | `write` | admin, member | 200 |

### GET /api/v1/targets

List target numbers.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `include_archived` | query | boolean | no | false |

Responses: `200` list of [TargetOut](#targetout), `401`, `403`, `422`, `429`.

### POST /api/v1/targets

Register a target number.

The number is classified (country, line type). Premium-rate, shared-cost and emergency numbers are refused. A new number starts `unverified`: verify it (`POST /targets/{id}/verification/start`) before running tests on it.

Body (`application/json`): [TargetIn](#targetin).

Responses: `201` [TargetOut](#targetout), `400` [Error](#error), `401`, `403`, `422`, `429`.

### GET /api/v1/targets/{target_id}

Get a target.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Responses: `200` [TargetOut](#targetout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/targets/{target_id}

Update a target.

The phone number itself cannot change: register a new target.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Body (`application/json`): [TargetPatch](#targetpatch).

Responses: `200` [TargetOut](#targetout), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/targets/{target_id}

Delete a target (admin).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/targets/{target_id}/verification

Verification status.

**Why**: CallQA only calls numbers you control. Verification proves it; until then a number
with a signed attestation only gets the *sandbox* (3 calls per 24 h, one at a time, 3 minutes
max, no schedule or CI run).

**Default flow (`spoken_code`)**:
1. `POST /targets/{id}/verification/start` (sign the attestation in the same request the
   first time). The response holds a 6-digit `code`, shown **once** and never stored in clear.
2. Add the `phrase` ("Verification code 4 8 2 1 9 3.") to the line's greeting, or to the
   agent prompt ("if someone asks for a verification code, answer 4 8 2 1 9 3").
3. `POST /targets/{id}/verification/call`: we call the number, say "Verification, please."
   if the line waits for the caller to speak, and listen for 20 s.

**IVR menu in front of the code**: give `dtmf_gate` to `start` (for example `"1w2"`): after
the answer we wait 1 s, press the keys (`w` = 0.5 s pause), then listen. The keys are
remembered on the number for the next verification. `renew: true` verifies a `verified`
number again without leaving `verified`.
4. Poll `GET /targets/{id}/verification` until `status` is `verified`. On failure it stays
   `pending`: `last_attempt.failure_hint` says what to fix, `last_attempt.heard_digits` what
   we understood, `last_attempt.sip_status` why a line that never answered did not (480
   unavailable, 486 busy, 603 declined...); `POST .../verification/retry` issues a new code.

**Limits**: a code lives 24 h and is used by a single call; 3 failed attempts in 24 h lock the
number for 24 h (a call the line does not answer counts: `attempts_left`, and
`attempts_reset_at` tells when the oldest one stops counting); 10 verification calls per
organization and 24 h, one at a time. A verification is valid 180 days, then the number is
`expired` and must be verified again.

**Attestation** (version `2026-09-26-v2-draft`), signed once per number and
again when a new version becomes mandatory (`attestation_outdated`); the placeholders are
filled in when you sign:

> I, {signer_name}, confirm that I am authorized to act on behalf of {organization_name} (the "Organization") and I certify, on behalf of the Organization, that:
> 
> 1. The Organization is the subscriber of the phone number {phone_number}, or holds a prior written authorization from its subscriber to have it called by automated systems using artificial or synthetic voices, for testing purposes. The Organization will keep that authorization and provide it to CallQA on request.
> 2. This number is not an emergency number, a premium rate or special number, or the personal line of a person who has not agreed in writing to receive these calls.
> 3. The calls will only be used to test the telephone system that answers this number. They will not be used for marketing, sales, surveys, or to reach any third party.
> 4. Where a person may answer or listen to these calls, the Organization has informed that person in advance and obtained any consent required by law for automated calls, for the use of an AI voice, and for the recording and transcription of the calls.
> 5. The calls, their content and any test data comply with the laws that apply to them, including laws on automated and artificial voice calls (such as the US Telephone Consumer Protection Act), telemarketing, call recording and data protection, and with the CallQA Terms of Service and Acceptable Use Policy.
> 6. If the Organization stops controlling this number, it will stop calling it and remove it from CallQA without delay.
> 
> I understand that CallQA relies on this attestation to place the calls, that it records my name, email, IP address and the date and time of this signature as evidence, that it may suspend calls to this number at any time, and that the Organization is responsible for any claim resulting from calls placed at its request, as set out in the Terms of Service.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Responses: `200` [VerificationOut](#verificationout), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/targets/{target_id}/verification/attest

Sign the attestation.

Required once per number before verifying, and again when a new attestation version becomes mandatory. On its own it only opens the sandbox. The signed text, your name, email or API key, IP and time are kept as evidence. Text: see `GET /targets/{id}/verification`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Body (`application/json`): [AttestIn](#attestin).

Responses: `200` [VerificationOut](#verificationout), `400` [Error](#error), `401`, `403` [Error](#error), `404`, `409` [Error](#error), `422`, `429` [Error](#error).

### POST /api/v1/targets/{target_id}/verification/call

Place the verification call.

Calls the number once for the active `spoken_code` code (after pressing its `dtmf_gate` keys, if any). The call is free. Poll `GET .../verification` (or the call) for the result.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Body (`application/json`): VerificationCallIn.

Responses: `202` [VerificationCallOut](#verificationcallout), `400` [Error](#error), `401`, `403` [Error](#error), `404`, `409` [Error](#error), `422`, `429` [Error](#error).

### POST /api/v1/targets/{target_id}/verification/retry

Retry with a new code.

After a failed attempt (or to replace the active code): issues a new code and cancels the previous one. Put the new phrase in the greeting or prompt before calling again.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Body (`application/json`): VerificationStartIn.

Responses: `201` [VerificationCodeOut](#verificationcodeout), `400` [Error](#error), `401`, `403` [Error](#error), `404`, `409` [Error](#error), `422`, `429` [Error](#error).

### POST /api/v1/targets/{target_id}/verification/start

Start a verification (new code).

**Why**: CallQA only calls numbers you control. Verification proves it; until then a number
with a signed attestation only gets the *sandbox* (3 calls per 24 h, one at a time, 3 minutes
max, no schedule or CI run).

**Default flow (`spoken_code`)**:
1. `POST /targets/{id}/verification/start` (sign the attestation in the same request the
   first time). The response holds a 6-digit `code`, shown **once** and never stored in clear.
2. Add the `phrase` ("Verification code 4 8 2 1 9 3.") to the line's greeting, or to the
   agent prompt ("if someone asks for a verification code, answer 4 8 2 1 9 3").
3. `POST /targets/{id}/verification/call`: we call the number, say "Verification, please."
   if the line waits for the caller to speak, and listen for 20 s.

**IVR menu in front of the code**: give `dtmf_gate` to `start` (for example `"1w2"`): after
the answer we wait 1 s, press the keys (`w` = 0.5 s pause), then listen. The keys are
remembered on the number for the next verification. `renew: true` verifies a `verified`
number again without leaving `verified`.
4. Poll `GET /targets/{id}/verification` until `status` is `verified`. On failure it stays
   `pending`: `last_attempt.failure_hint` says what to fix, `last_attempt.heard_digits` what
   we understood, `last_attempt.sip_status` why a line that never answered did not (480
   unavailable, 486 busy, 603 declined...); `POST .../verification/retry` issues a new code.

**Limits**: a code lives 24 h and is used by a single call; 3 failed attempts in 24 h lock the
number for 24 h (a call the line does not answer counts: `attempts_left`, and
`attempts_reset_at` tells when the oldest one stops counting); 10 verification calls per
organization and 24 h, one at a time. A verification is valid 180 days, then the number is
`expired` and must be verified again.

**Attestation** (version `2026-09-26-v2-draft`), signed once per number and
again when a new version becomes mandatory (`attestation_outdated`); the placeholders are
filled in when you sign:

> I, {signer_name}, confirm that I am authorized to act on behalf of {organization_name} (the "Organization") and I certify, on behalf of the Organization, that:
> 
> 1. The Organization is the subscriber of the phone number {phone_number}, or holds a prior written authorization from its subscriber to have it called by automated systems using artificial or synthetic voices, for testing purposes. The Organization will keep that authorization and provide it to CallQA on request.
> 2. This number is not an emergency number, a premium rate or special number, or the personal line of a person who has not agreed in writing to receive these calls.
> 3. The calls will only be used to test the telephone system that answers this number. They will not be used for marketing, sales, surveys, or to reach any third party.
> 4. Where a person may answer or listen to these calls, the Organization has informed that person in advance and obtained any consent required by law for automated calls, for the use of an AI voice, and for the recording and transcription of the calls.
> 5. The calls, their content and any test data comply with the laws that apply to them, including laws on automated and artificial voice calls (such as the US Telephone Consumer Protection Act), telemarketing, call recording and data protection, and with the CallQA Terms of Service and Acceptable Use Policy.
> 6. If the Organization stops controlling this number, it will stop calling it and remove it from CallQA without delay.
> 
> I understand that CallQA relies on this attestation to place the calls, that it records my name, email, IP address and the date and time of this signature as evidence, that it may suspend calls to this number at any time, and that the Organization is responsible for any claim resulting from calls placed at its request, as set out in the Terms of Service.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Body (`application/json`): VerificationStartIn.

Responses: `201` [VerificationCodeOut](#verificationcodeout), `400` [Error](#error), `401`, `403` [Error](#error), `404`, `409` [Error](#error), `422`, `429` [Error](#error).

### POST /api/v1/targets/{target_id}/verify

Sign the attestation (legacy).

Kept for existing clients: same as `POST .../verification/attest`, returns the target. The attestation does **not** verify the number; see `POST .../verification/start`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `target_id` | path | string (uuid) | yes |  |

Body (`application/json`): [VerifyIn](#verifyin).

Responses: `200` [TargetOut](#targetout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

## Scenarios

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `POST` | `/api/v1/generation-jobs/from-call/{call_id}` | Suggest a regression variant from a call | `write` | admin, member | 202 |
| `GET` | `/api/v1/generation-jobs/{job_id}` | Get a generation job | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/scenarios` | List scenarios | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/scenarios` | Create a scenario | `write` | admin, member | 201 |
| `POST` | `/api/v1/scenarios/generate` | Generate scenarios from a description | `write` | admin, member | 202 |
| `POST` | `/api/v1/scenarios/import` | Import a scenario from YAML | `write` | admin, member | 200, 201 |
| `GET` | `/api/v1/scenarios/schema` | Scenario JSON Schema | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/scenarios/validate` | Validate a scenario without saving | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/scenarios/{scenario_id}` | Get a scenario | `read` | admin, member, guest | 200 |
| `PUT` | `/api/v1/scenarios/{scenario_id}` | Replace a scenario | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/scenarios/{scenario_id}` | Delete a scenario (admin) | `admin` | admin | 204 |
| `POST` | `/api/v1/scenarios/{scenario_id}/archive` | Archive a scenario | `write` | admin, member | 200 |
| `GET` | `/api/v1/scenarios/{scenario_id}/export` | Export as YAML or JSON | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/scenarios/{scenario_id}/variants` | List variants | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/scenarios/{scenario_id}/variants` | Add a variant (test case) | `write` | admin, member | 201 |
| `POST` | `/api/v1/scenarios/{scenario_id}/variants/derive` | Auto-derive variants | `write` | admin, member | 202 |
| `POST` | `/api/v1/scenarios/{scenario_id}/variants/from-bundle` | Turn a call into a regression test | `write` | admin, member | 201 |
| `PATCH` | `/api/v1/variants/{variant_id}` | Update a variant | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/variants/{variant_id}` | Delete a variant (admin) | `admin` | admin | 204 |

### POST /api/v1/generation-jobs/from-call/{call_id}

Suggest a regression variant from a call.

Proposes one MANUAL variant (not saved) that reproduces what went wrong in a judged call. Add it with POST /scenarios/{id}/variants.

Generation takes 10 to 60 seconds, so it runs in the background: the answer is `202` with a job; poll `GET /generation-jobs/{id}` (its `url`) until `status` is `done` (then read `result`) or `error`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |

Responses: `202` [GenerationJobOut](#generationjobout), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`, `503` [Error](#error).

### GET /api/v1/generation-jobs/{job_id}

Get a generation job.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `job_id` | path | string (uuid) | yes |  |

Responses: `200` [GenerationJobOut](#generationjobout), `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/scenarios

List scenarios.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `tag` | query | string | no |  |
| `include_archived` | query | boolean | no | false |
| `q` | query | string | no |  |

Responses: `200` list of [ScenarioSummary](#scenariosummary), `401`, `403`, `422`, `429`.

### POST /api/v1/scenarios

Create a scenario.

Body: a scenario document. The full grammar is published as JSON Schema at `GET /api/v1/scenarios/schema`. The landing-page shorthand (`phone`, `persona` as text, `must_include`, `must_not`, `max_latency_ms` at top level) is accepted. Unknown fields are rejected unless they start with `x_` (kept verbatim).

A target number that does not exist yet is registered as `unverified`.

Body (`application/json`): object.

Responses: `201` [ScenarioOut](#scenarioout), `401`, `403` [Error](#error), `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### POST /api/v1/scenarios/generate

Generate scenarios from a description.

Proposes scenario documents (not saved). Review them, then POST the ones you keep to /scenarios. Invalid proposals are filtered out.

Generation takes 10 to 60 seconds, so it runs in the background: the answer is `202` with a job; poll `GET /generation-jobs/{id}` (its `url`) until `status` is `done` (then read `result`) or `error`.

Body (`application/json`): [GenerateIn](#generatein).

Responses: `202` [GenerationJobOut](#generationjobout), `401`, `403`, `422`, `429`, `503` [Error](#error).

### POST /api/v1/scenarios/import

Import a scenario from YAML.

Send the YAML as the raw body (`Content-Type: application/yaml`). If a non-archived scenario with the same `name` exists it is updated (upsert), otherwise created.

Body (`application/yaml`): string.

Responses: `200` [ScenarioOut](#scenarioout), `201` [ScenarioOut](#scenarioout), `401`, `403` [Error](#error), `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### GET /api/v1/scenarios/schema

Scenario JSON Schema.

JSON Schema of the scenario grammar (generated from callqa_spec).

Responses: `200` object, `401`, `403`, `422`, `429`.

### POST /api/v1/scenarios/validate

Validate a scenario without saving.

Body: a scenario document. The full grammar is published as JSON Schema at `GET /api/v1/scenarios/schema`. The landing-page shorthand (`phone`, `persona` as text, `must_include`, `must_not`, `max_latency_ms` at top level) is accepted. Unknown fields are rejected unless they start with `x_` (kept verbatim).

Body (`application/json`): object.

Responses: `200` object, `401`, `403`, `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### GET /api/v1/scenarios/{scenario_id}

Get a scenario.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Responses: `200` [ScenarioOut](#scenarioout), `401`, `403`, `404`, `422`, `429`.

### PUT /api/v1/scenarios/{scenario_id}

Replace a scenario.

Body: a scenario document. The full grammar is published as JSON Schema at `GET /api/v1/scenarios/schema`. The landing-page shorthand (`phone`, `persona` as text, `must_include`, `must_not`, `max_latency_ms` at top level) is accepted. Unknown fields are rejected unless they start with `x_` (kept verbatim).

Variants are matched by title: existing ones keep their id (and their history).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Body (`application/json`): object.

Responses: `200` [ScenarioOut](#scenarioout), `401`, `403` [Error](#error), `404`, `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### DELETE /api/v1/scenarios/{scenario_id}

Delete a scenario (admin).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### POST /api/v1/scenarios/{scenario_id}/archive

Archive a scenario.

Archived scenarios stop their schedule and are hidden from lists; their history is kept. Members may archive; only admins delete.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Responses: `200` [ScenarioSummary](#scenariosummary), `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/scenarios/{scenario_id}/export

Export as YAML or JSON.

`format=yaml` (default) or `format=json`. Round-trips with /import.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |
| `format` | query | string | no | "yaml" |

Responses: `200`, `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/scenarios/{scenario_id}/variants

List variants.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Responses: `200` list of [VariantOut](#variantout), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/scenarios/{scenario_id}/variants

Add a variant (test case).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Body (`application/json`): [VariantIn](#variantin).

Responses: `201` [VariantOut](#variantout), `401`, `403` [Error](#error), `404`, `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### POST /api/v1/scenarios/{scenario_id}/variants/derive

Auto-derive variants.

Adds AUTO test cases (edge cases, personas, accents, error paths) when the job completes. Requires the auto-derived coverage feature (Growth and up).

Generation takes 10 to 60 seconds, so it runs in the background: the answer is `202` with a job; poll `GET /generation-jobs/{id}` (its `url`) until `status` is `done` (then read `result`) or `error`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Body (`application/json`): [DeriveIn](#derivein).

Responses: `202` [GenerationJobOut](#generationjobout), `401`, `403` [Error](#error), `404`, `422`, `429`, `503` [Error](#error).

### POST /api/v1/scenarios/{scenario_id}/variants/from-bundle

Turn a call into a regression test.

Imports a replay bundle (or a call of yours) as a MANUAL variant: the "Add to regression suite" button.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Body (`application/json`): [FromBundleIn](#frombundlein).

Responses: `201` [VariantOut](#variantout), `400` [Error](#error), `401`, `403`, `404`, `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### PATCH /api/v1/variants/{variant_id}

Update a variant.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `variant_id` | path | string (uuid) | yes |  |

Body (`application/json`): [VariantPatch](#variantpatch).

Responses: `200` [VariantOut](#variantout), `401`, `403`, `404`, `422` [ScenarioInvalid](#scenarioinvalid), `429`.

### DELETE /api/v1/variants/{variant_id}

Delete a variant (admin).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `variant_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

## Runs

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/runs` | List runs | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/runs/{run_id}` | Get a run | `read` | admin, member, guest | 200 |
| `DELETE` | `/api/v1/runs/{run_id}` | Delete a run and its calls | `admin` | admin | 204 |
| `POST` | `/api/v1/runs/{run_id}/cancel` | Cancel the calls not dialed yet | `run` | admin, member | 200 |
| `POST` | `/api/v1/scenarios/{scenario_id}/runs` | Trigger a run (CI friendly) | `run` | admin, member | 200, 201, 202 |

### GET /api/v1/runs

List runs.

Filter by `scenario_id`, `status`, `since`, `environment` (slug, name or id), `version_id` (runs attributed to the version) and `tag`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | query | string (uuid) | no |  |
| `status` | query | string | no |  |
| `since` | query | string (date-time) | no |  |
| `environment` | query | string | no |  |
| `version_id` | query | string (uuid) | no |  |
| `tag` | query | string | no |  |
| `limit` | query | integer | no | 50 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_RunOut_](#page_runout_), `401`, `403`, `422`, `429`.

### GET /api/v1/runs/{run_id}

Get a run.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `run_id` | path | string (uuid) | yes |  |

Responses: `200` [RunOut](#runout), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/runs/{run_id}

Delete a run and its calls.

Admin scope. Deletes the run and all its calls (trace, transcript, result, bundles) for good. Refused (`409 run_not_finished`) while a call of the run is queued or in progress: cancel it first. Billing lines are kept without the calls' content. Audited.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `run_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### POST /api/v1/runs/{run_id}/cancel

Cancel the calls not dialed yet.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `run_id` | path | string (uuid) | yes |  |

Responses: `200` [RunOut](#runout), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/scenarios/{scenario_id}/runs

Trigger a run (CI friendly).

Queues one call per enabled variant (or the listed ones).

**CI usage**: `?wait=true&threshold=80` holds the request until the run finishes (`timeout` seconds, default 600, capped at 900) and answers:
* `200` finished and passed (score >= threshold, no failed, error or blocked call);
* `412` finished and failed;
* `202` **still running** at the timeout: poll `GET /runs/{id}`.

Without `wait` the answer is `201` with the queued run.

`curl --fail` alone is **not** a safe gate: it treats `202` as a success, so a slow run would pass. Check the status code:

```bash
code=$(curl -sS -o run.json -w '%{http_code}' -X POST --max-time 960 \
  -H "Authorization: Bearer $CALLQA_API_KEY" \
  "https://app.callqa.dev/api/v1/scenarios/$SCENARIO/runs?wait=true&timeout=900&threshold=80")
[ "$code" = "200" ] || exit 1
```

The wait is asynchronous on the server (no worker is held), but proxies in front of your runner may cut a request open for minutes: the CLI polls instead.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |
| `wait` | query | boolean | no | false |
| `timeout` | query | integer | no | 600 |
| `threshold` | query | integer | no |  |

Body (`application/json`): TriggerIn.

Responses: `200` [RunOut](#runout), `201` [RunOut](#runout), `202` [RunOut](#runout), `400` [Error](#error), `401`, `403` [Error](#error), `404`, `412` [RunOut](#runout), `422`, `429`.

## Calls

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/calls` | List calls | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/calls/{call_id}` | Get a call | `read` | admin, member, guest | 200 |
| `DELETE` | `/api/v1/calls/{call_id}` | Delete a call | `admin` | admin | 204 |
| `GET` | `/api/v1/calls/{call_id}/bundle` | Export a replay bundle | `read` | admin, member | 200 |
| `GET` | `/api/v1/calls/{call_id}/events` | Call trace | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/calls/{call_id}/recording` | Download the call audio | `read` | admin, member, guest | 200, 206 |
| `POST` | `/api/v1/replays` | Replay a call | `run` | admin, member | 201 |

### GET /api/v1/calls

List calls.

Filter by `status` (e.g. `error`, `blocked_insufficient_credit`), `verdict`, `run_id`, `since`, `environment`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `status` | query | string | no |  |
| `verdict` | query | string | no |  |
| `run_id` | query | string (uuid) | no |  |
| `since` | query | string (date-time) | no |  |
| `environment` | query | string | no |  |
| `limit` | query | integer | no | 50 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_CallOut_](#page_callout_), `401`, `403`, `422`, `429`.

### GET /api/v1/calls/{call_id}

Get a call.

Includes the ResolvedRun, the result (verdicts with evidence, metrics, score) and the transcript.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |

Responses: `200` [CallDetailOut](#calldetailout), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/calls/{call_id}

Delete a call.

Admin scope. Deletes the call, its trace, transcript, result and bundles for good. Refused with `409` while the call is not finished (`call_not_finished`) and for number verification calls, kept as evidence (`verification_record`). Billing lines are kept without the call's content. Audited.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/calls/{call_id}/bundle

Export a replay bundle.

A self-contained JSON file (callqa_spec.ReplayBundle): resolved run, runtime (models, voices, settings), full trace and result. Replay it with `POST /replays` or import it as a regression test with `POST /scenarios/{id}/variants/from-bundle`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |

Responses: `200` object, `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/calls/{call_id}/events

Call trace.

The append-only event trace (callqa_spec.CallEvent), by seq.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |
| `after_seq` | query | integer | no | -1 |
| `limit` | query | integer | no | 200 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_EventOut_](#page_eventout_), `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/calls/{call_id}/recording

Download the call audio.

The stereo WAV of the call (16 bit PCM, 16 kHz): left track our test caller, right track your line. Sample 0 is the `dialing` event: a trace instant `t_ms` is at `(t_ms - recording.start_ms) / 1000` seconds. Supports HTTP `Range`. `404` when the organization does not record audio, or once the recording expired (audio retention).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |

Responses: `200`, `206`, `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/replays

Replay a call.

Creates a new call with exactly the same ResolvedRun (and by default the same runtime). The bundle's target must be a number of your organization; normal checks apply (verification, credit, concurrency).

Body (`application/json`): [ReplayIn](#replayin).

Responses: `201` [CallOut](#callout), `400` [Error](#error), `401`, `403`, `404` [Error](#error), `422`, `429`.

## Tags

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/tags` | List or search tags | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/tags` | Create a tag | `write` | admin, member | 201 |
| `POST` | `/api/v1/tags/bulk` | Tag or untag many objects | `write` | admin, member | 200 |
| `GET` | `/api/v1/tags/{tag_id}` | Get a tag | `read` | admin, member, guest | 200 |
| `PATCH` | `/api/v1/tags/{tag_id}` | Update a tag | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/tags/{tag_id}` | Delete a tag (admin) | `admin` | admin | 204 |

### GET /api/v1/tags

List or search tags.

Tags are free keywords (a code name, a customer, a team) on scenarios, runs, versions and numbers; CallQA gives them no meaning of its own. With `q`, the autocomplete: names starting with `q` first, then containing it, most used first, at most `limit` (default 10, max 50).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `q` | query | string | no |  |
| `limit` | query | integer | no |  |

Responses: `200` list of [TagSuggestion](#tagsuggestion), `401`, `403`, `422`, `429`.

### POST /api/v1/tags

Create a tag.

Body (`application/json`): [TagIn](#tagin).

Responses: `201` [TagOut](#tagout), `401`, `403`, `409` [Error](#error), `422`, `429`.

### POST /api/v1/tags/bulk

Tag or untag many objects.

Adds `add` and removes `remove` on every listed scenario, run, version and number (by id). Tags to add are created when new. An id of another organization answers `404 unknown_object` and nothing is changed.

Body (`application/json`): [TagBulkIn](#tagbulkin).

Responses: `200` [TagBulkOut](#tagbulkout), `400` [Error](#error), `401`, `403`, `404` [Error](#error), `422`, `429`.

### GET /api/v1/tags/{tag_id}

Get a tag.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `tag_id` | path | integer | yes |  |

Responses: `200` [TagOut](#tagout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/tags/{tag_id}

Update a tag.

Renaming a tag keeps it on its scenarios and runs.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `tag_id` | path | integer | yes |  |

Body (`application/json`): [TagPatch](#tagpatch).

Responses: `200` [TagOut](#tagout), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### DELETE /api/v1/tags/{tag_id}

Delete a tag (admin).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `tag_id` | path | integer | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

## Versions

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/releases` | List releases (deprecated) | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/releases` | Tag a release (deprecated) | `write` | admin, member | 201 |
| `GET` | `/api/v1/releases/{release_id}` | Get a release (deprecated) | `read` | admin, member, guest | 200 |
| `PATCH` | `/api/v1/releases/{release_id}` | Update a release (deprecated) | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/releases/{release_id}` | Delete a release (deprecated, admin) | `admin` | admin | 204 |
| `POST` | `/api/v1/releases/{release_id}/promote` | Promote a release (deprecated) | `write` | admin, member | 201 |
| `GET` | `/api/v1/versions` | List versions | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/versions` | Create a version | `write` | admin, member | 201 |
| `GET` | `/api/v1/versions/{version_id}` | Get a version | `read` | admin, member, guest | 200 |
| `PATCH` | `/api/v1/versions/{version_id}` | Update a version | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/versions/{version_id}` | Delete a version (admin) | `admin` | admin | 204 |
| `POST` | `/api/v1/versions/{version_id}/promote` | Promote a version to another environment | `write` | admin, member | 201 |

### GET /api/v1/releases

List releases (deprecated).

**Deprecated** since 2026-09-27, removed on 2027-03-31.

Use `GET /versions`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `environment` | query | string | no |  |

Responses: `200` list of [ReleaseOut](#releaseout), `401`, `403`, `422`, `429`.

### POST /api/v1/releases

Tag a release (deprecated).

**Deprecated** since 2026-09-27, removed on 2027-03-31.

Use `POST /versions`.

Body (`application/json`): [ReleaseIn](#releasein).

Responses: `201` [ReleaseOut](#releaseout), `400` [Error](#error), `401`, `403`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/releases/{release_id}

Get a release (deprecated).

**Deprecated** since 2026-09-27, removed on 2027-03-31.

Use `GET /versions/{id}`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `release_id` | path | string (uuid) | yes |  |

Responses: `200` [ReleaseOut](#releaseout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/releases/{release_id}

Update a release (deprecated).

**Deprecated** since 2026-09-27, removed on 2027-03-31.

Use `PATCH /versions/{id}`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `release_id` | path | string (uuid) | yes |  |

Body (`application/json`): [ReleasePatch](#releasepatch).

Responses: `200` [ReleaseOut](#releaseout), `400` [Error](#error), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### DELETE /api/v1/releases/{release_id}

Delete a release (deprecated, admin).

**Deprecated** since 2026-09-27, removed on 2027-03-31.

Use `DELETE /versions/{id}`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `release_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/releases/{release_id}/promote

Promote a release (deprecated).

**Deprecated** since 2026-09-27, removed on 2027-03-31.

Use `POST /versions/{id}/promote`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `release_id` | path | string (uuid) | yes |  |

Body (`application/json`): [PromoteIn](#promotein).

Responses: `201` [ReleaseOut](#releaseout), `400` [Error](#error), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/versions

List versions.

Newest first. `environment` (slug, name or id) keeps one environment, `tag` the versions carrying a tag, `q` a part of the label, git ref or sha.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `environment` | query | string | no |  |
| `tag` | query | string | no |  |
| `q` | query | string | no |  |
| `limit` | query | integer | no | 50 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_VersionOut_](#page_versionout_), `401`, `403`, `422`, `429`.

### POST /api/v1/versions

Create a version.

A version of your code in one environment, from `deployed_at` (default now). Runs of that environment are attributed to it until the next version. `409 already_exists` when the label exists in the environment. CI can instead send `version` when it triggers a run.

Body (`application/json`): [VersionIn](#versionin).

Responses: `201` [VersionOut](#versionout), `400` [Error](#error), `401`, `403`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/versions/{version_id}

Get a version.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `version_id` | path | string (uuid) | yes |  |

Responses: `200` [VersionOut](#versionout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/versions/{version_id}

Update a version.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `version_id` | path | string (uuid) | yes |  |

Body (`application/json`): [VersionPatch](#versionpatch).

Responses: `200` [VersionOut](#versionout), `400` [Error](#error), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### DELETE /api/v1/versions/{version_id}

Delete a version (admin).

Its runs are kept; they fall back to the previous version of the environment.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `version_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/versions/{version_id}/promote

Promote a version to another environment.

The same code in the next environment (staging to production), from `deployed_at` (default now): label, git fields, notes, number and tags travel with it. `409 already_exists` when the label is already there.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `version_id` | path | string (uuid) | yes |  |

Body (`application/json`): [VersionPromoteIn](#versionpromotein).

Responses: `201` [VersionOut](#versionout), `400` [Error](#error), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

## Environments

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/environments` | List environments | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/environments` | Create an environment | `write` | admin, member | 201 |
| `GET` | `/api/v1/environments/compare` | Compare environments (Growth and up) | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/environments/stability` | Stability per environment (Growth and up) | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/environments/{env_id}` | Get an environment | `read` | admin, member, guest | 200 |
| `PATCH` | `/api/v1/environments/{env_id}` | Update an environment | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/environments/{env_id}` | Delete an environment (admin) | `admin` | admin | 204 |
| `PUT` | `/api/v1/environments/{env_id}/numbers` | Set the number dialed in an environment | `write` | admin, member | 200 |
| `GET` | `/api/v1/environments/{env_id}/scenarios` | Scenarios of an environment | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/environments/{env_id}/scenarios` | Bind, pause or unbind many scenarios | `write` | admin, member | 200 |
| `GET` | `/api/v1/matrix` | What runs where, and what passes | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/scenarios/{scenario_id}/environments` | Environments of a scenario | `read` | admin, member, guest | 200 |
| `PUT` | `/api/v1/scenarios/{scenario_id}/environments/{env_id}` | Activate or schedule a scenario in an environment | `write` | admin, member | 200 |
| `DELETE` | `/api/v1/scenarios/{scenario_id}/environments/{env_id}` | Remove a scenario from an environment | `write` | admin, member | 204 |

### GET /api/v1/environments

List environments.

In promotion order. Every organization has one default environment (`is_default`), used by runs and versions that name none.

Responses: `200` list of [EnvironmentOut](#environmentout), `401`, `403`, `422`, `429`.

### POST /api/v1/environments

Create an environment.

Body (`application/json`): [EnvironmentIn](#environmentin).

Responses: `201` [EnvironmentOut](#environmentout), `400` [Error](#error), `401`, `403`, `409` [Error](#error), `422`, `429`.

### GET /api/v1/environments/compare

Compare environments (Growth and up).

Side `a` vs side `b` (for example `a=staging&b=production`): overall KPIs, per scenario, and the test cases whose last verdict differs. Narrow a side to one version with `a_version` / `b_version` (version id), and to one scenario with `scenario_id`. Without a version, a side covers the last `days` days. `a_release` / `b_release` are the deprecated names of `a_version` / `b_version`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `a` | query | string | yes |  |
| `b` | query | string | yes |  |
| `a_version` | query | string (uuid) | no |  |
| `b_version` | query | string (uuid) | no |  |
| `scenario_id` | query | string (uuid) | no |  |
| `days` | query | integer | no | 30 |
| `a_release` | query | string (uuid) | no |  |
| `b_release` | query | string (uuid) | no |  |

Responses: `200` [CompareOut](#compareout), `400` [Error](#error), `401`, `403` [Error](#error), `422`, `429`.

### GET /api/v1/environments/stability

Stability per environment (Growth and up).

Over the last `days` (7, 30 or 90): tests, pass rate, average score, p95 latency, flaky test cases and last run of each environment.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `days` | query | integer | no | 30 |

Responses: `200` list of [StabilityOut](#stabilityout), `401`, `403` [Error](#error), `422`, `429`.

### GET /api/v1/environments/{env_id}

Get an environment.

By id or slug.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `env_id` | path | string | yes |  |

Responses: `200` [EnvironmentOut](#environmentout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/environments/{env_id}

Update an environment.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `env_id` | path | string | yes |  |

Body (`application/json`): [EnvironmentPatch](#environmentpatch).

Responses: `200` [EnvironmentOut](#environmentout), `400` [Error](#error), `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### DELETE /api/v1/environments/{env_id}

Delete an environment (admin).

Refused (`409 environment_in_use`) while runs or versions belong to it, and for the default environment (`409 default_required`).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `env_id` | path | string | yes |  |

Responses: `204`, `401`, `403`, `404`, `409` [Error](#error), `422`, `429`.

### PUT /api/v1/environments/{env_id}/numbers

Set the number dialed in an environment.

Runs of this environment dial `number_id` instead of `target_id`. Both are numbers of your organization, each verified on its own.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `env_id` | path | string | yes |  |

Body (`application/json`): [NumberIn](#numberin).

Responses: `200` [EnvironmentOut](#environmentout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/environments/{env_id}/scenarios

Scenarios of an environment.

The scenarios activated in (or bound to) this environment.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `env_id` | path | string | yes |  |

Responses: `200` list of [BindingOut](#bindingout), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/environments/{env_id}/scenarios

Bind, pause or unbind many scenarios.

One call for a multi-select or a drag and drop: `enable`, `disable` or `remove` the listed scenarios in this environment, optionally with the same `schedule`, `timezone`, `call_window` for all. An id of another organization answers `404` and nothing changes.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `env_id` | path | string | yes |  |

Body (`application/json`): [BulkBindingIn](#bulkbindingin).

Responses: `200` [BulkBindingOut](#bulkbindingout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/matrix

What runs where, and what passes.

For every scenario x environment: activated or not, schedule, last completed run (verdict, score, version), trend of the last `trend_runs` runs; for the last `versions` versions of each environment: the scenarios that pass, warn, fail or were not tested. Filters: `environment` and `tag` (repeat them), `scenario_id` (repeat), `q` (part of a scenario name), `bound_only`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `environment` | query | list of string | no |  |
| `tag` | query | list of string | no |  |
| `scenario_id` | query | list of string (uuid) | no |  |
| `q` | query | string | no | "" |
| `bound_only` | query | boolean | no | false |
| `trend_runs` | query | integer | no | 5 |
| `versions` | query | integer | no | 5 |

Responses: `200` [MatrixOut](#matrixout), `401`, `403`, `422`, `429`.

### GET /api/v1/scenarios/{scenario_id}/environments

Environments of a scenario.

Where the scenario runs, with the schedule, the call window and the number dialed in each environment.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |

Responses: `200` list of [BindingOut](#bindingout), `401`, `403`, `404`, `422`, `429`.

### PUT /api/v1/scenarios/{scenario_id}/environments/{env_id}

Activate or schedule a scenario in an environment.

Creates the binding when needed. Fields left out keep their value. Each environment has its own schedule and window.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |
| `env_id` | path | string | yes |  |

Body (`application/json`): [BindingIn](#bindingin).

Responses: `200` [BindingOut](#bindingout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/scenarios/{scenario_id}/environments/{env_id}

Remove a scenario from an environment.

Deletes the binding and its schedule. Past runs are kept.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |
| `env_id` | path | string | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

## Webhooks

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/webhooks` | List webhook endpoints | `read` | admin, member | 200 |
| `POST` | `/api/v1/webhooks` | Create a webhook endpoint | `admin` | admin | 201 |
| `GET` | `/api/v1/webhooks/{webhook_id}` | Get a webhook endpoint | `read` | admin, member | 200 |
| `PATCH` | `/api/v1/webhooks/{webhook_id}` | Update a webhook endpoint | `admin` | admin | 200 |
| `DELETE` | `/api/v1/webhooks/{webhook_id}` | Delete a webhook endpoint | `admin` | admin | 204 |
| `GET` | `/api/v1/webhooks/{webhook_id}/deliveries` | Delivery log of an endpoint | `read` | admin, member | 200 |
| `POST` | `/api/v1/webhooks/{webhook_id}/rotate-secret` | Rotate the signing secret | `admin` | admin | 200 |
| `POST` | `/api/v1/webhooks/{webhook_id}/test` | Send a test event now | `admin` | admin | 200 |

### GET /api/v1/webhooks

List webhook endpoints.

Payloads are JSON `{id, type, created, data}` signed with HMAC-SHA256:

`CallQA-Signature: t=<unix ts>,v1=<hex hmac_sha256(secret, "<ts>.<raw body>")>`

During a secret rotation the header carries one `v1` per valid secret: accept the delivery if **any** `v1` matches. Verify in constant time and reject timestamps older than 5 minutes. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h and 6 h.

Events: `call.completed`, `call.failed`, `call.blocked`, `call.regressed`, `call.recovered`, `suite_run.started`, `suite_run.completed`, `suite_run.failed`, `score.dropped`, `version.created`, `version.promoted`, `release.created`, `release.promoted`, `wallet.low_balance`

Responses: `200` list of [WebhookOut](#webhookout), `401`, `403`, `422`, `429`.

### POST /api/v1/webhooks

Create a webhook endpoint.

Payloads are JSON `{id, type, created, data}` signed with HMAC-SHA256:

`CallQA-Signature: t=<unix ts>,v1=<hex hmac_sha256(secret, "<ts>.<raw body>")>`

During a secret rotation the header carries one `v1` per valid secret: accept the delivery if **any** `v1` matches. Verify in constant time and reject timestamps older than 5 minutes. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h and 6 h.

Events: `call.completed`, `call.failed`, `call.blocked`, `call.regressed`, `call.recovered`, `suite_run.started`, `suite_run.completed`, `suite_run.failed`, `score.dropped`, `version.created`, `version.promoted`, `release.created`, `release.promoted`, `wallet.low_balance`

Body (`application/json`): [WebhookIn](#webhookin).

Responses: `201` [WebhookCreated](#webhookcreated), `400` [Error](#error), `401`, `403`, `422`, `429`.

### GET /api/v1/webhooks/{webhook_id}

Get a webhook endpoint.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `webhook_id` | path | string (uuid) | yes |  |

Responses: `200` [WebhookOut](#webhookout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/webhooks/{webhook_id}

Update a webhook endpoint.

Change the URL, the events, the description, or pause it (`enabled: false`). The signing secret does not change: use `rotate-secret`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `webhook_id` | path | string (uuid) | yes |  |

Body (`application/json`): [WebhookPatch](#webhookpatch).

Responses: `200` [WebhookOut](#webhookout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/webhooks/{webhook_id}

Delete a webhook endpoint.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `webhook_id` | path | string (uuid) | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/webhooks/{webhook_id}/deliveries

Delivery log of an endpoint.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `webhook_id` | path | string (uuid) | yes |  |
| `limit` | query | integer | no | 50 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_DeliveryOut_](#page_deliveryout_), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/webhooks/{webhook_id}/rotate-secret

Rotate the signing secret.

Returns the new secret (shown once). During `grace_period_hours` (default 24, at most 168) every delivery is signed with both secrets (two `v1` in `CallQA-Signature`), so you can deploy the new one without losing events.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `webhook_id` | path | string (uuid) | yes |  |

Body (`application/json`): SecretRotateIn.

Responses: `200` [WebhookCreated](#webhookcreated), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/webhooks/{webhook_id}/test

Send a test event now.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `webhook_id` | path | string (uuid) | yes |  |

Responses: `200` [DeliveryOut](#deliveryout), `401`, `403`, `404`, `422`, `429`.

## Alerts

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/alert-channels` | List alert channels | `admin` | admin | 200 |
| `POST` | `/api/v1/alert-channels` | Create an alert channel | `admin` | admin | 201 |
| `GET` | `/api/v1/alert-channels/{channel_id}` | Get an alert channel | `admin` | admin | 200 |
| `PATCH` | `/api/v1/alert-channels/{channel_id}` | Update an alert channel | `admin` | admin | 200 |
| `DELETE` | `/api/v1/alert-channels/{channel_id}` | Delete an alert channel | `admin` | admin | 204 |
| `POST` | `/api/v1/alert-channels/{channel_id}/test` | Send a test alert | `admin` | admin | 200 |

### GET /api/v1/alert-channels

List alert channels.

Alert channels warn people in **Slack** (incoming webhook) or by **email** when a run run completes and one of the channel's rules matches: the run failed (`on_failure`), its score is under `on_score_below`, or a call's p95 latency is above `on_latency_p95_above_ms`. One message per channel and run at most, sent within seconds by the dispatcher and retried after 1, 5 and 30 minutes.

The Slack webhook URL is a secret: it must be an `https://hooks.slack.com/` URL, and it is never returned in full. For machine integrations, use webhooks.

Responses: `200` list of [AlertChannelOut](#alertchannelout), `401`, `403`, `422`, `429`.

### POST /api/v1/alert-channels

Create an alert channel.

Alert channels warn people in **Slack** (incoming webhook) or by **email** when a run run completes and one of the channel's rules matches: the run failed (`on_failure`), its score is under `on_score_below`, or a call's p95 latency is above `on_latency_p95_above_ms`. One message per channel and run at most, sent within seconds by the dispatcher and retried after 1, 5 and 30 minutes.

The Slack webhook URL is a secret: it must be an `https://hooks.slack.com/` URL, and it is never returned in full. For machine integrations, use webhooks.

Body (`application/json`): [AlertChannelIn](#alertchannelin).

Responses: `201` [AlertChannelOut](#alertchannelout), `400` [Error](#error), `401`, `403`, `422`, `429`.

### GET /api/v1/alert-channels/{channel_id}

Get an alert channel.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `channel_id` | path | integer | yes |  |

Responses: `200` [AlertChannelOut](#alertchannelout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/alert-channels/{channel_id}

Update an alert channel.

Only the fields you send change. `null` on a threshold removes that rule. The kind cannot change.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `channel_id` | path | integer | yes |  |

Body (`application/json`): [AlertChannelPatch](#alertchannelpatch).

Responses: `200` [AlertChannelOut](#alertchannelout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/alert-channels/{channel_id}

Delete an alert channel.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `channel_id` | path | integer | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/alert-channels/{channel_id}/test

Send a test alert.

Sends a test message now and tells whether it went through. Limited to 5 per minute per channel.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `channel_id` | path | integer | yes |  |

Responses: `200` [AlertTestOut](#alerttestout), `401`, `403`, `404`, `422`, `429`.

## API keys & logs

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/api-keys` | List API keys | `admin` | admin | 200 |
| `POST` | `/api/v1/api-keys` | Create an API key | `admin` | admin | 201 |
| `GET` | `/api/v1/api-keys/{key_id}` | Get an API key | `admin` | admin | 200 |
| `PATCH` | `/api/v1/api-keys/{key_id}` | Rename or restrict an API key | `admin` | admin | 200 |
| `DELETE` | `/api/v1/api-keys/{key_id}` | Revoke an API key | `admin` | admin | 204 |
| `POST` | `/api/v1/api-keys/{key_id}/rotate` | Rotate an API key | `admin` | admin | 201 |
| `GET` | `/api/v1/api-logs` | API request journal | `read` | admin, member | 200 |

### GET /api/v1/api-keys

List API keys.

Responses: `200` list of [ApiKeyOut](#apikeyout), `401`, `403`, `422`, `429`.

### POST /api/v1/api-keys

Create an API key.

The full key is returned once; only a hash is stored. `allowed_cidrs` restricts where it can be used from.

Body (`application/json`): [ApiKeyIn](#apikeyin).

Responses: `201` [ApiKeyCreated](#apikeycreated), `400` [Error](#error), `401`, `403`, `422`, `429`.

### GET /api/v1/api-keys/{key_id}

Get an API key.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `key_id` | path | integer | yes |  |

Responses: `200` [ApiKeyOut](#apikeyout), `401`, `403`, `404`, `422`, `429`.

### PATCH /api/v1/api-keys/{key_id}

Rename or restrict an API key.

Scopes cannot change: create a new key instead.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `key_id` | path | integer | yes |  |

Body (`application/json`): [ApiKeyPatch](#apikeypatch).

Responses: `200` [ApiKeyOut](#apikeyout), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### DELETE /api/v1/api-keys/{key_id}

Revoke an API key.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `key_id` | path | integer | yes |  |

Responses: `204`, `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/api-keys/{key_id}/rotate

Rotate an API key.

Creates a replacement with the same name, scopes, networks and expiry, returned once. The old key keeps working for `grace_period_hours` (default 24, at most 168, 0 revokes it now), then expires: deploy the new one in between.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `key_id` | path | integer | yes |  |

Body (`application/json`): KeyRotateIn.

Responses: `201` [ApiKeyCreated](#apikeycreated), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/api-logs

API request journal.

Every authenticated API request of your organization (method, path, status, duration, key, IP). Bodies are never stored.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `since` | query | string (date-time) | no |  |
| `status_code` | query | integer | no |  |
| `key_prefix` | query | string | no |  |
| `limit` | query | integer | no | 100 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_ApiLogOut_](#page_apilogout_), `401`, `403`, `422`, `429`.

## Exports for LLMs

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/calls/{call_id}/llm-export` | Export a call for an LLM | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/runs/{run_id}/llm-export` | Export a run for an LLM | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/scenarios/{scenario_id}/llm-export` | Export a scenario for an LLM | `read` | admin, member, guest | 200 |

### GET /api/v1/calls/{call_id}/llm-export

Export a call for an LLM.

A report made to be pasted into an LLM (a chat assistant or a coding agent) that will fix your voice agent: the scenario, the results, every failed check with the judge's quote, a readable transcript (AGENT = your agent, CALLER = our test caller), metrics, the judge's recommendations and a primer asking for a fix.

`format=markdown` (default) answers `text/markdown`; `format=json` answers the same document as compact JSON (`format: "callqa.llm_report"`, `version`, `level`).

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `call_id` | path | string (uuid) | yes |  |
| `format` | query | string | no | "markdown" |

Responses: `200` object, `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/runs/{run_id}/llm-export

Export a run for an LLM.

A report made to be pasted into an LLM (a chat assistant or a coding agent) that will fix your voice agent: the scenario, the results, every failed check with the judge's quote, a readable transcript (AGENT = your agent, CALLER = our test caller), metrics, the judge's recommendations and a primer asking for a fix.

`format=markdown` (default) answers `text/markdown`; `format=json` answers the same document as compact JSON (`format: "callqa.llm_report"`, `version`, `level`). The failed calls (up to 5) are detailed.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `run_id` | path | string (uuid) | yes |  |
| `format` | query | string | no | "markdown" |

Responses: `200` object, `401`, `403`, `404`, `422`, `429`.

### GET /api/v1/scenarios/{scenario_id}/llm-export

Export a scenario for an LLM.

A report made to be pasted into an LLM (a chat assistant or a coding agent) that will fix your voice agent: the scenario, the results, every failed check with the judge's quote, a readable transcript (AGENT = your agent, CALLER = our test caller), metrics, the judge's recommendations and a primer asking for a fix.

`format=markdown` (default) answers `text/markdown`; `format=json` answers the same document as compact JSON (`format: "callqa.llm_report"`, `version`, `level`). Covers the scenario YAML, its last 5 runs, the checks that fail most and the last 3 failed calls.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `scenario_id` | path | string (uuid) | yes |  |
| `format` | query | string | no | "markdown" |

Responses: `200` object, `401`, `403`, `404`, `422`, `429`.

## Billing

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/billing/forecast` | Forecast of the month | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/billing/portal` | Open the billing portal | `admin` | admin | 200 |
| `GET` | `/api/v1/billing/referral` | Your referral link | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/billing/settings` | Overage, spend cap and alerts | `admin` | admin | 200 |
| `PATCH` | `/api/v1/billing/settings` | Change overage, the spend cap or the low balance threshold (admin) | `admin` | admin | 200 |
| `GET` | `/api/v1/billing/subscription` | Current subscription | `admin` | admin | 200 |
| `POST` | `/api/v1/billing/subscription/checkout` | Subscribe to a plan | `admin` | admin | 200 |
| `GET` | `/api/v1/plans` | Public plans | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/usage` | Quota usage this month | `read` | admin, member, guest | 200 |
| `GET` | `/api/v1/wallet` | Wallet balance | `admin` | admin | 200 |
| `POST` | `/api/v1/wallet/checkout` | Top up the wallet | `admin` | admin | 200 |
| `GET` | `/api/v1/wallet/transactions` | Wallet ledger | `admin` | admin | 200 |

### GET /api/v1/billing/forecast

Forecast of the month.

What the enabled schedules will consume until the quota resets (1st of next month, UTC), per schedule and environment: runs left x test cases x destination weight x average 10 minute slices observed over 30 days (maximum duration when a scenario never ran). Compared with the quota, bonus calls, overage settings and cap. Manual, API and CI runs are not forecast.

Responses: `200` [ForecastOut](#forecastout), `401`, `403`, `422`, `429`.

### POST /api/v1/billing/portal

Open the billing portal.

Returns a Stripe billing portal URL: change or cancel the plan, update the payment method, download invoices.

Body (`application/json`): PortalIn.

Responses: `200` [CheckoutOut](#checkoutout), `400` [Error](#error), `401`, `403`, `422`, `429`, `503` [Error](#error).

### GET /api/v1/billing/referral

Your referral link.

Both organizations get the bonus calls when the referred one starts its first paid subscription. Referring yourself (a member of your organization, the same Stripe customer) or an existing customer gives nothing.

Responses: `200` [ReferralOut](#referralout), `401`, `403`, `422`, `429`.

### GET /api/v1/billing/settings

Overage, spend cap and alerts.

Paid overage on/off, its monthly cap (wallet spend), what happens at the limit, and the `wallet.low_balance` threshold.

Responses: `200` [BillingSettingsOut](#billingsettingsout), `401`, `403`, `422`, `429`.

### PATCH /api/v1/billing/settings

Change overage, the spend cap or the low balance threshold (admin).

Only the fields you send change; `null` removes the cap or turns `wallet.low_balance` off. When the cap is reached, new calls are not placed (`blocked_insufficient_credit`, block reason `spend_cap_reached`) until the next month or a higher cap. `allow_paid_overage: false` stops calls once the quota and bonus calls are used (`overage_disabled`); `on_limit: pause_schedules` also pauses the schedules until the 1st of next month.

Body (`application/json`): [BillingSettingsPatch](#billingsettingspatch).

Responses: `200` [BillingSettingsOut](#billingsettingsout), `401`, `403`, `422`, `429`.

### GET /api/v1/billing/subscription

Current subscription.

Responses: `200` [SubscriptionOut](#subscriptionout), `401`, `403`, `422`, `429`.

### POST /api/v1/billing/subscription/checkout

Subscribe to a plan.

Returns a Stripe Checkout URL for a first subscription. If your organization already has an active Stripe subscription the answer is `409` (`subscription_exists`): change plan, interval or payment method in the billing portal (`POST /billing/portal`).

Body (`application/json`): [SubscriptionCheckoutIn](#subscriptioncheckoutin).

Responses: `200` [CheckoutOut](#checkoutout), `400` [Error](#error), `401`, `403`, `409` [Error](#error), `422`, `429`, `503` [Error](#error).

### GET /api/v1/plans

Public plans.

Plans you can subscribe to with `POST /billing/subscription/checkout`.

Responses: `200` list of [PlanPublicOut](#planpublicout), `401`, `403`, `422`, `429`.

### GET /api/v1/usage

Quota usage this month.

Quota periods are calendar months (UTC). `held` = units reserved by calls in progress.

Responses: `200` [UsageOut](#usageout), `401`, `403`, `422`, `429`.

### GET /api/v1/wallet

Wallet balance.

Calls beyond the monthly quota are paid from a **prepaid wallet**, at your plan's overage price times the destination weight. Credit is reserved before a call is dialed and the unused part is released after. When the wallet cannot cover a call, the call is not placed and ends with status `blocked_insufficient_credit`: top up with `POST /wallet/checkout`. Top-ups are between $20 and $5,000 (`amount_cents` 2000 to 500000).

Responses: `200` [WalletOut](#walletout), `401`, `403`, `422`, `429`.

### POST /api/v1/wallet/checkout

Top up the wallet.

Calls beyond the monthly quota are paid from a **prepaid wallet**, at your plan's overage price times the destination weight. Credit is reserved before a call is dialed and the unused part is released after. When the wallet cannot cover a call, the call is not placed and ends with status `blocked_insufficient_credit`: top up with `POST /wallet/checkout`. Top-ups are between $20 and $5,000 (`amount_cents` 2000 to 500000).

Returns a Stripe Checkout URL. The wallet is credited when Stripe confirms the payment (webhook), not before.

Body (`application/json`): [CheckoutIn](#checkoutin).

Responses: `200` [CheckoutOut](#checkoutout), `400` [Error](#error), `401`, `403`, `422`, `429`, `503` [Error](#error).

### GET /api/v1/wallet/transactions

Wallet ledger.

Append-only ledger: every balance change has a line.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `limit` | query | integer | no | 50 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_WalletTxOut_](#page_wallettxout_), `401`, `403`, `422`, `429`.

## Support

| Method | Path | Summary | Scope | Roles | Success |
|---|---|---|---|---|---|
| `GET` | `/api/v1/support/tickets` | List support tickets | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/support/tickets` | Open a support ticket | `read` | admin, member | 201 |
| `GET` | `/api/v1/support/tickets/{ticket_id}` | Get a support ticket | `read` | admin, member, guest | 200 |
| `POST` | `/api/v1/support/tickets/{ticket_id}/close` | Close a support ticket | `read` | admin, member | 200 |
| `POST` | `/api/v1/support/tickets/{ticket_id}/messages` | Reply to a support ticket | `read` | admin, member | 201 |

### GET /api/v1/support/tickets

List support tickets.

Ask the CallQA team: a bug, a question, feedback, a billing matter or an abuse report. A person of the team answers, usually within a few hours on business days; the answer is emailed to the author (and to the organization's admins when an API key opened the ticket) and readable here. Statuses: `open` (the team has to answer), `waiting_customer` (the team waits for you), `answered`, `closed`. A reply reopens a ticket.

Never paste a secret (API key, password, token): anything that looks like one is replaced by `[redacted secret]` before it is stored.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `status` | query | string | no |  |
| `limit` | query | integer | no | 20 |
| `offset` | query | integer | no | 0 |

Responses: `200` [Page_TicketOut_](#page_ticketout_), `401`, `403`, `422`, `429`.

### POST /api/v1/support/tickets

Open a support ticket.

Ask the CallQA team: a bug, a question, feedback, a billing matter or an abuse report. A person of the team answers, usually within a few hours on business days; the answer is emailed to the author (and to the organization's admins when an API key opened the ticket) and readable here. Statuses: `open` (the team has to answer), `waiting_customer` (the team waits for you), `answered`, `closed`. A reply reopens a ticket.

Never paste a secret (API key, password, token): anything that looks like one is replaced by `[redacted secret]` before it is stored.

At most 20 new tickets a day per organization (429 `rate_limited` beyond).

Body (`application/json`): [TicketIn](#ticketin).

Responses: `201` [TicketDetail](#ticketdetail), `400` [Error](#error), `401`, `403`, `422` [Error](#error), `429`.

### GET /api/v1/support/tickets/{ticket_id}

Get a support ticket.

The ticket and every message, oldest first: the answers of the CallQA team have `author` `staff`.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `ticket_id` | path | string (uuid) | yes |  |

Responses: `200` [TicketDetail](#ticketdetail), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/support/tickets/{ticket_id}/close

Close a support ticket.

When the matter is solved. A later reply reopens it.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `ticket_id` | path | string (uuid) | yes |  |

Responses: `200` [TicketDetail](#ticketdetail), `401`, `403`, `404`, `422`, `429`.

### POST /api/v1/support/tickets/{ticket_id}/messages

Reply to a support ticket.

Adds a message and reopens the ticket (status `open`): the team is alerted. Never paste a secret.

| Parameter | In | Type | Required | Default |
|---|---|---|---|---|
| `ticket_id` | path | string (uuid) | yes |  |

Body (`application/json`): [MessageIn](#messagein).

Responses: `201` [TicketDetail](#ticketdetail), `400` [Error](#error), `401`, `403`, `404`, `422`, `429`.

## Schemas

Request and response objects, as named above. Full definitions in [`api/openapi.json`](api/openapi.json) under `components.schemas`.

### ActiveCode

| Field | Type | Required | Description |
|---|---|---|---|
| `attempt_id` | string (uuid) | yes |  |
| `method` | string | yes |  |
| `status` | `pending` \| `calling` | yes |  |
| `expires_at` | string (date-time) | yes |  |
| `call_id` | string (uuid) | yes |  |
| `call_status` | string | yes |  |
| `dtmf_gate` | string | no | Keys pressed after the answer. |

### AlertChannelIn

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `kind` | `slack` \| `email` | yes |  |
| `webhook_url` | string | no | Slack only: incoming webhook URL (https://hooks.slack.com/services/...). |
| `to` | list of string | no | Email only: up to 10 recipients. |
| `on_failure` | boolean | no | Alert when a run fails. |
| `on_score_below` | integer | no | Alert when the run score is under this. |
| `on_latency_p95_above_ms` | integer | no | Alert when a call's p95 latency is above this. |
| `enabled` | boolean | no |  |

### AlertChannelOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `name` | string | yes |  |
| `kind` | `slack` \| `email` | yes |  |
| `config` | object | yes | Slack: `{webhook_url}` masked (it is a secret). Email: `{to}`. |
| `on_failure` | boolean | yes |  |
| `on_score_below` | integer | yes |  |
| `on_latency_p95_above_ms` | integer | yes |  |
| `enabled` | boolean | yes |  |
| `created_at` | string (date-time) | yes |  |

### AlertChannelPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no |  |
| `webhook_url` | string | no |  |
| `to` | list of string | no |  |
| `on_failure` | boolean | no |  |
| `on_score_below` | integer | no | null removes the rule. |
| `on_latency_p95_above_ms` | integer | no | null removes the rule. |
| `enabled` | boolean | no |  |

### AlertTestOut

| Field | Type | Required | Description |
|---|---|---|---|
| `sent` | boolean | yes |  |
| `error` | string | yes |  |

### ApiKeyCreated

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `name` | string | yes |  |
| `prefix` | string | yes |  |
| `scopes` | list of string | yes |  |
| `allowed_cidrs` | list of string | yes |  |
| `created_at` | string (date-time) | yes |  |
| `expires_at` | string (date-time) | yes |  |
| `last_used_at` | string (date-time) | yes |  |
| `last_used_ip` | string | yes |  |
| `revoked_at` | string (date-time) | yes |  |
| `rotated_from_id` | integer | yes |  |
| `key` | string | yes | The API key. Shown only once: store it in your secrets. |

### ApiKeyIn

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `scopes` | list of string | no |  |
| `expires_in_days` | integer | no |  |
| `allowed_cidrs` | list of string | no | Only accept requests from these networks (IPv4 or IPv6, CIDR or single address, at most 20). Empty: any address. |

### ApiKeyOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `name` | string | yes |  |
| `prefix` | string | yes |  |
| `scopes` | list of string | yes |  |
| `allowed_cidrs` | list of string | yes |  |
| `created_at` | string (date-time) | yes |  |
| `expires_at` | string (date-time) | yes |  |
| `last_used_at` | string (date-time) | yes |  |
| `last_used_ip` | string | yes |  |
| `revoked_at` | string (date-time) | yes |  |
| `rotated_from_id` | integer | yes |  |

### ApiKeyPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no |  |
| `allowed_cidrs` | list of string | no | Replaces the list. [] removes the restriction. |

### ApiLogOut

| Field | Type | Required | Description |
|---|---|---|---|
| `method` | string | yes |  |
| `path` | string | yes |  |
| `status_code` | integer | yes |  |
| `duration_ms` | integer | yes |  |
| `api_key_prefix` | string | yes |  |
| `ip` | string | yes |  |
| `request_id` | string | yes |  |
| `created_at` | string (date-time) | yes |  |

### AttestIn

| Field | Type | Required | Description |
|---|---|---|---|
| `accept` | boolean | yes | Must be true: you accept the attestation text. |
| `signer_name` | string | yes | Full name and title of the person attesting. |

### AuditOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `action` | string | yes |  |
| `actor_email` | string | yes | The person, for actions done in the dashboard. |
| `actor_api_key_prefix` | string | yes | The API key, for API actions. |
| `target_type` | string | yes |  |
| `target_id` | string | yes |  |
| `data` | object | yes |  |
| `ip` | string | yes |  |
| `created_at` | string (date-time) | yes |  |

### BillingSettingsOut

| Field | Type | Required | Description |
|---|---|---|---|
| `monthly_spend_cap_cents` | integer | yes | Most the wallet may spend per calendar month (UTC). null: no cap. |
| `spent_this_month_cents` | integer | yes | Wallet spend this month, reservations of calls in progress included. |
| `low_balance_threshold_cents` | integer | yes | `wallet.low_balance` is sent when the balance falls under this. null: never. |
| `allow_paid_overage` | boolean | yes | Calls beyond the monthly quota and bonus calls are paid from the wallet. false: they are not placed (block reason `overage_disabled`). |
| `on_limit` | `block_calls` \| `pause_schedules` | yes | When the limit is reached (overage off, cap reached, no overage on the plan): block each call, or also pause the schedules until the 1st of next month (UTC). |
| `schedules_paused` | boolean | yes | The schedules are paused by this rule now. |

### BillingSettingsPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `monthly_spend_cap_cents` | integer | no |  |
| `low_balance_threshold_cents` | integer | no |  |
| `allow_paid_overage` | boolean | no | Admins only (`billing.overage`). |
| `on_limit` | string | no |  |

### BindingIn

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | no | Run the scenario in this environment. A new binding is enabled unless `false`. |
| `schedule` | string | no | Cron expression or `every day at 09:00`. `""` removes the schedule; absent keeps it. |
| `timezone` | string | no | IANA zone of the schedule. Absent keeps it (or the one written in `schedule`). |
| `call_window` | string | no | `HH:MM-HH:MM Area/City`; scheduled calls wait for it. Without a zone, the schedule's zone. `""` removes it. |
| `schedule_enabled` | boolean | no | Pause (`false`) or resume the schedule without losing it. |

### BindingOut

| Field | Type | Required | Description |
|---|---|---|---|
| `scenario_id` | string (uuid) | yes |  |
| `scenario_name` | string | yes |  |
| `environment` | string | yes | Environment slug. |
| `enabled` | boolean | yes |  |
| `schedule` | string | yes |  |
| `cron` | string | yes | The schedule as a cron expression, in `timezone`. |
| `timezone` | string | yes |  |
| `call_window` | string | yes |  |
| `schedule_enabled` | boolean | yes |  |
| `next_run_at` | string (date-time) | yes |  |
| `last_run_at` | string (date-time) | yes |  |
| `number_phone` | string | yes | The number dialed in this environment. |

### BulkBindingIn

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | no | Run the scenario in this environment. A new binding is enabled unless `false`. |
| `schedule` | string | no | Cron expression or `every day at 09:00`. `""` removes the schedule; absent keeps it. |
| `timezone` | string | no | IANA zone of the schedule. Absent keeps it (or the one written in `schedule`). |
| `call_window` | string | no | `HH:MM-HH:MM Area/City`; scheduled calls wait for it. Without a zone, the schedule's zone. `""` removes it. |
| `schedule_enabled` | boolean | no | Pause (`false`) or resume the schedule without losing it. |
| `scenario_ids` | list of string (uuid) | yes |  |
| `action` | `enable` \| `disable` \| `remove` | yes | `enable`: activate (created if needed); `disable`: keep the binding and its schedule but do not run; `remove`: delete the binding. |

### BulkBindingOut

| Field | Type | Required | Description |
|---|---|---|---|
| `action` | string | yes |  |
| `environment` | string | yes |  |
| `scenarios` | integer | yes |  |
| `bindings` | list of BindingOut | yes |  |

### CallDetailOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `run_id` | string (uuid) | yes |  |
| `variant_id` | string (uuid) | yes |  |
| `title` | string | yes |  |
| `target_phone` | string | yes |  |
| `status` | string | yes |  |
| `block_reason` | string | yes |  |
| `error` | string | yes |  |
| `verdict` | string | yes |  |
| `score` | integer | yes |  |
| `duration_ms` | integer | yes |  |
| `latency_p50_ms` | integer | yes |  |
| `latency_p95_ms` | integer | yes |  |
| `latency_max_ms` | integer | yes |  |
| `cost_cents` | integer | yes |  |
| `quota_units` | integer | yes |  |
| `replay_of` | string (uuid) | yes |  |
| `created_at` | string (date-time) | yes |  |
| `dispatched_at` | string (date-time) | yes |  |
| `completed_at` | string (date-time) | yes |  |
| `content_purged_at` | string (date-time) | no | When the call's content (trace, transcript, detailed evaluation) was deleted at the end of the plan's retention period. Score, verdict, latencies and cost remain. |
| `run` | object | yes | The ResolvedRun that was dialed. |
| `result` | object | yes | CallResult: verdicts, metrics, score. |
| `transcript` | list of TurnOut | yes |  |
| `recording` | object | yes |  |
| `usage` | object | yes |  |
| `cost_breakdown` | object | no | Our measured cost of the call in USD (telephony, STT, TTS, LLM, judge). Not what you are charged: see cost_cents. |

### CallOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `run_id` | string (uuid) | yes |  |
| `variant_id` | string (uuid) | yes |  |
| `title` | string | yes |  |
| `target_phone` | string | yes |  |
| `status` | string | yes |  |
| `block_reason` | string | yes |  |
| `error` | string | yes |  |
| `verdict` | string | yes |  |
| `score` | integer | yes |  |
| `duration_ms` | integer | yes |  |
| `latency_p50_ms` | integer | yes |  |
| `latency_p95_ms` | integer | yes |  |
| `latency_max_ms` | integer | yes |  |
| `cost_cents` | integer | yes |  |
| `quota_units` | integer | yes |  |
| `replay_of` | string (uuid) | yes |  |
| `created_at` | string (date-time) | yes |  |
| `dispatched_at` | string (date-time) | yes |  |
| `completed_at` | string (date-time) | yes |  |
| `content_purged_at` | string (date-time) | no | When the call's content (trace, transcript, detailed evaluation) was deleted at the end of the plan's retention period. Score, verdict, latencies and cost remain. |

### CheckoutIn

| Field | Type | Required | Description |
|---|---|---|---|
| `amount_cents` | integer | yes | Top-up amount in USD cents (2000 to 500000). |
| `success_url` | string | no |  |
| `cancel_url` | string | no |  |

### CheckoutOut

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes | Redirect the user to this Stripe Checkout page. |

### CiOut

| Field | Type | Required | Description |
|---|---|---|---|
| `threshold` | integer | yes |  |
| `passed` | boolean | yes | null while the run is not finished. |

### CompareOut

| Field | Type | Required | Description |
|---|---|---|---|
| `a` | string | yes |  |
| `b` | string | yes |  |
| `kpis_a` | KpisOut | yes |  |
| `kpis_b` | KpisOut | yes |  |
| `scenarios` | list of ScenarioCompareOut | yes |  |
| `verdict_changes` | list of VerdictDiffOut | yes |  |

### ComplianceOut

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `pending` \| `allow` \| `needs_review` \| `block` | yes | Compliance review of the current content: only `allow` can be run. |
| `label` | string | yes |  |
| `categories` | list of string | yes |  |
| `reasons` | list of ComplianceReasonOut | yes |  |
| `reviewed_at` | string | yes |  |
| `decided_by_staff` | boolean | yes |  |
| `message` | string | yes | What it means and what to do next. |
| `enforced` | boolean | yes |  |

### ComplianceReasonOut

| Field | Type | Required | Description |
|---|---|---|---|
| `category` | string | yes |  |
| `quote` | string | yes | Words of the scenario that caused the decision. |
| `explanation` | string | yes |  |

### DeliveryOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `event_type` | string | yes |  |
| `status` | string | yes |  |
| `attempts` | integer | yes |  |
| `last_status_code` | integer | yes |  |
| `last_error` | string | yes |  |
| `created_at` | string (date-time) | yes |  |
| `delivered_at` | string (date-time) | yes |  |

### DeriveIn

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | no |  |

### EnvironmentForecastOut

| Field | Type | Required | Description |
|---|---|---|---|
| `environment` | string | yes |  |
| `units` | integer | yes |  |
| `runs_left` | integer | yes |  |
| `calls` | integer | yes |  |

### EnvironmentIn

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `order` | integer | no | Promotion order, smallest first (development 10, staging 20, production 30). Default: after the last one. |
| `color` | string | no |  |
| `is_default` | boolean | no | Runs that name no environment go here. |

### EnvironmentNumberOut

| Field | Type | Required | Description |
|---|---|---|---|
| `target_id` | string (uuid) | yes |  |
| `target_phone` | string | yes |  |
| `number_id` | string (uuid) | yes |  |
| `number_phone` | string | yes |  |

### EnvironmentOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `slug` | string | yes |  |
| `order` | integer | yes |  |
| `color` | string | yes |  |
| `is_default` | boolean | yes |  |
| `numbers` | list of EnvironmentNumberOut | yes |  |
| `created_at` | string (date-time) | yes |  |

### EnvironmentPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no |  |
| `order` | integer | no |  |
| `color` | string | no |  |
| `is_default` | boolean | no | Only `true` is accepted: make another environment the default to change it. |

### Error

| Field | Type | Required | Description |
|---|---|---|---|
| `detail` | string | yes | Human readable explanation. Can change: do not parse it. |
| `code` | string | yes | Stable machine-readable code, e.g. `missing_scope`, `feature_not_in_plan`, `not_found`. See the errors guide. |
| `feature` | string | no | `feature_not_in_plan`: the plan feature that is missing, e.g. `release_compare`. |
| `required_plan` | string | no | Plan errors: the cheapest plan that includes the feature or lifts the limit. |
| `upgrade_url` | string | no | Plan errors: page where an admin upgrades in one click (Stripe, prorated), then comes back. |

### EventOut

| Field | Type | Required | Description |
|---|---|---|---|
| `seq` | integer | yes |  |
| `t_ms` | integer | yes |  |
| `at` | string (date-time) | yes |  |
| `type` | string | yes |  |
| `data` | object | yes |  |

### ExportOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `status` | `queued` \| `running` \| `done` \| `error` \| `expired` | yes |  |
| `created_at` | string (date-time) | yes |  |
| `finished_at` | string (date-time) | yes |  |
| `expires_at` | string (date-time) | yes | The archive is deleted at this date. |
| `size_bytes` | integer | yes |  |
| `sha256` | string | yes | SHA-256 of the zip archive. |
| `counts` | object | yes | Number of objects of each kind. |
| `error` | string | yes |  |
| `download_url` | string | yes | Signed link valid 24 hours, no API key needed. Set when done. |

### ForecastOut

| Field | Type | Required | Description |
|---|---|---|---|
| `period_start` | string | yes |  |
| `period_end` | string | yes | First day of the next period (exclusive). |
| `included` | integer | yes |  |
| `bonus_remaining` | integer | yes |  |
| `used_units` | integer | yes | Used or reserved this month (quota, bonus, wallet). |
| `scheduled_units` | integer | yes | What the enabled schedules will use until the end of the month. |
| `projected_units` | integer | yes |  |
| `will_exceed` | boolean | yes | The schedules will use more than the quota and bonus calls left. |
| `overage_units` | integer | yes |  |
| `estimated_overage_cents` | integer | yes | Wallet cost of `overage_units` at your plan's price, uncapped. null: the plan has no paid overage. |
| `month_overage_cents` | integer | yes | Spent so far + estimated. |
| `blocked_units` | integer | yes | Units expected to be blocked: overage off, no overage on the plan, or the monthly cap. |
| `overage_allowed` | boolean | yes |  |
| `spend_cap_cents` | integer | yes |  |
| `spent_cents` | integer | yes |  |
| `overage_price_cents` | integer | yes |  |
| `level` | `ok` \| `warn` \| `danger` | yes |  |
| `schedules_paused` | boolean | yes |  |
| `schedules` | list of ScheduleForecastOut | yes |  |
| `environments` | list of EnvironmentForecastOut | yes |  |

### FromBundleIn

| Field | Type | Required | Description |
|---|---|---|---|
| `call_id` | string (uuid) | no | Build the bundle from this call... |
| `bundle` | object | no | ...or pass a ReplayBundle. |
| `title` | string | no |  |

### GenerateIn

| Field | Type | Required | Description |
|---|---|---|---|
| `description` | string | yes | What the voice service does, in plain words. |
| `phone` | string | yes |  |
| `language` | string | no |  |
| `count` | integer | no |  |

### GenerationJobOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `kind` | `propose` \| `derive` \| `edge_case` | yes |  |
| `status` | `queued` \| `running` \| `done` \| `error` | yes |  |
| `scenario_id` | string (uuid) | yes |  |
| `call_id` | string (uuid) | yes |  |
| `result` | object | yes | When done. propose: {scenarios: [...], rejected}; derive: {variants: [...], rejected}; edge_case: {variant: {...}}. |
| `error` | string | yes |  |
| `usage` | object | yes |  |
| `created_at` | string (date-time) | yes |  |
| `finished_at` | string (date-time) | yes |  |
| `url` | string | yes | Poll this URL until status is done or error. |

### InvitationIn

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes |  |
| `role` | `admin` \| `member` \| `guest` | no |  |

### KeyRotateIn

| Field | Type | Required | Description |
|---|---|---|---|
| `grace_period_hours` | integer | no | How long the old key keeps working. 0 revokes it now. |

### KpisOut

| Field | Type | Required | Description |
|---|---|---|---|
| `tests` | integer | yes |  |
| `passed` | integer | yes |  |
| `failed` | integer | yes |  |
| `warned` | integer | yes |  |
| `pass_rate` | number | yes |  |
| `avg_score` | number | yes |  |
| `avg_latency_ms` | integer | yes |  |
| `p95_latency_ms` | integer | yes |  |
| `interruptions_per_call` | number | yes |  |
| `cost_cents` | integer | yes |  |

### LastAttempt

| Field | Type | Required | Description |
|---|---|---|---|
| `attempt_id` | string (uuid) | yes |  |
| `method` | string | yes |  |
| `status` | `succeeded` \| `failed` \| `error` \| `expired` \| `calling` | yes |  |
| `outcome` | string | yes | matched, not_heard, not_answered, wrong_code, call_not_placed... |
| `heard_digits` | string | yes | Digits we understood (the code is masked with * when it matched). |
| `sip_status` | integer | no | When the line never answered: the SIP final answer of the destination (480 unavailable, 408 no answer, 486 busy, 603 declined, 404 unknown number...). |
| `sip_reason` | string | no | The carrier's short reason for `sip_status`. |
| `failure_hint` | string | no | What to fix before the next attempt, in plain words (null when the attempt did not fail). Show it to the user as is. |
| `call_id` | string (uuid) | yes |  |
| `finished_at` | string (date-time) | yes |  |
| `dtmf_gate` | string | no | Keys pressed after the answer. |

### MatrixCell

| Field | Type | Required | Description |
|---|---|---|---|
| `environment` | string | yes |  |
| `bound` | boolean | yes |  |
| `enabled` | boolean | yes |  |
| `schedule` | string | yes |  |
| `timezone` | string | yes |  |
| `call_window` | string | yes |  |
| `schedule_enabled` | boolean | yes |  |
| `next_run_at` | string (date-time) | yes |  |
| `running` | boolean | yes | A run is queued or in progress. |
| `last_run` | MatrixRun | yes | Last completed run. |
| `trend` | MatrixTrend | yes |  |

### MatrixEntry

| Field | Type | Required | Description |
|---|---|---|---|
| `scenario_id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `run_id` | string (uuid) | yes |  |
| `score` | integer | yes |  |

### MatrixEnvironment

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `slug` | string | yes |  |
| `name` | string | yes |  |
| `color` | string | yes |  |
| `order` | integer | yes |  |
| `is_default` | boolean | yes |  |

### MatrixOut

| Field | Type | Required | Description |
|---|---|---|---|
| `environments` | list of MatrixEnvironment | yes |  |
| `scenarios` | list of MatrixScenario | yes |  |
| `versions` | list of MatrixVersionRow | yes | The last versions of each environment, newest first per environment. |
| `totals` | object | yes | Per environment slug, over the enabled scenarios: last verdicts. |

### MatrixRun

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `number` | integer | yes |  |
| `created_at` | string (date-time) | yes |  |
| `verdict` | `pass` \| `warn` \| `fail` | yes |  |
| `score` | integer | yes |  |
| `total` | integer | yes |  |
| `passed` | integer | yes |  |
| `version` | MatrixVersion | yes |  |

### MatrixScenario

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `target_phone` | string | yes |  |
| `tags` | list of string | yes |  |
| `cells` | list of MatrixCell | yes | One per environment, in `environments` order. |

### MatrixTotals

| Field | Type | Required | Description |
|---|---|---|---|
| `passed` | integer | yes |  |
| `warned` | integer | yes |  |
| `failed` | integer | yes |  |
| `untested` | integer | yes |  |
| `running` | integer | yes |  |

### MatrixTrend

| Field | Type | Required | Description |
|---|---|---|---|
| `scores` | list of integer | yes | Scores of the last completed runs, oldest first. |
| `direction` | string | yes |  |

### MatrixVersion

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `label` | string | yes |  |
| `git_sha` | string | yes |  |
| `deployed_at` | string (date-time) | yes |  |

### MatrixVersionRow

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `label` | string | yes |  |
| `environment` | string | yes |  |
| `deployed_at` | string (date-time) | yes |  |
| `git_ref` | string | yes |  |
| `git_sha` | string | yes |  |
| `source_url` | string | yes |  |
| `tags` | list of string | yes |  |
| `passed` | list of MatrixEntry | yes |  |
| `warned` | list of MatrixEntry | yes |  |
| `failed` | list of MatrixEntry | yes |  |
| `untested` | list of MatrixEntry | yes | Enabled in the environment, no run on this version. |

### MeOut

| Field | Type | Required | Description |
|---|---|---|---|
| `org_id` | string (uuid) | yes |  |
| `org_name` | string | yes |  |
| `org_slug` | string | yes |  |
| `api_key_prefix` | string | yes |  |
| `scopes` | list of string | yes |  |
| `plan` | PlanOut | yes |  |
| `subscription_status` | string | yes |  |
| `allowed_countries` | list of string | yes |  |

### MemberOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `email` | string | yes |  |
| `role` | `admin` \| `member` \| `guest` | yes |  |
| `joined_at` | string (date-time) | yes |  |

### MessageIn

| Field | Type | Required | Description |
|---|---|---|---|
| `body` | string | yes | Markdown. |

### MessageOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `author` | `customer_human` \| `customer_agent` \| `staff` | yes | `staff` is the CallQA team. |
| `author_name` | string | yes |  |
| `body` | string | yes |  |
| `created_at` | string (date-time) | yes |  |
| `read_by_customer` | boolean | yes | For a message of the team: a person of the organization opened it in the app. |

### NumberIn

| Field | Type | Required | Description |
|---|---|---|---|
| `target_id` | string (uuid) | yes | The number the scenarios point to. |
| `number_id` | string (uuid) | yes | The number to dial instead in this environment (registered and verified); null or equal to `target_id` removes the mapping. |

### Page_ApiLogOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of ApiLogOut | yes |  |

### Page_AuditOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of AuditOut | yes |  |

### Page_CallOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of CallOut | yes |  |

### Page_DeliveryOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of DeliveryOut | yes |  |

### Page_EventOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of EventOut | yes |  |

### Page_ExportOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of ExportOut | yes |  |

### Page_RunOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of RunOut | yes |  |

### Page_TicketOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of TicketOut | yes |  |

### Page_VersionOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of VersionOut | yes |  |

### Page_WalletTxOut_

| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | yes | Total number of matching items. |
| `limit` | integer | yes |  |
| `offset` | integer | yes |  |
| `items` | list of WalletTxOut | yes |  |

### PlanOut

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes |  |
| `name` | string | yes |  |
| `included_calls_per_month` | integer | yes |  |
| `overage_price_cents` | integer | yes | Wallet price of one extra call unit. |
| `max_seats` | integer | yes |  |
| `max_countries` | integer | yes |  |
| `max_concurrent_calls` | integer | yes |  |
| `features` | object | yes |  |

### PlanPublicOut

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes |  |
| `name` | string | yes |  |
| `included_calls_per_month` | integer | yes |  |
| `overage_price_cents` | integer | yes | Wallet price of one extra call unit. |
| `max_seats` | integer | yes |  |
| `max_countries` | integer | yes |  |
| `max_concurrent_calls` | integer | yes |  |
| `features` | object | yes |  |
| `monthly_price_cents` | integer | yes |  |
| `annual_monthly_price_cents` | integer | yes | Per month, billed yearly. |

### PortalIn

| Field | Type | Required | Description |
|---|---|---|---|
| `return_url` | string | no |  |

### PromoteIn

| Field | Type | Required | Description |
|---|---|---|---|
| `environment` | string | yes | Environment to promote to, by slug, name or id. |
| `released_at` | string (date-time) | no | Default: now. |

### ReferralOut

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes |  |
| `url` | string | yes | Share it: a new organization created through it gets, like yours, `bonus_calls_each` bonus calls at its first paid subscription. |
| `bonus_calls_each` | integer | yes |  |
| `pending` | integer | yes | Referred organizations that have not paid yet. |
| `granted` | integer | yes |  |
| `calls_earned` | integer | yes |  |

### ReleaseIn

| Field | Type | Required | Description |
|---|---|---|---|
| `version` | string | yes |  |
| `notes` | string | no |  |
| `released_at` | string (date-time) | no |  |
| `target_id` | string (uuid) | no |  |
| `environment` | string | no | Environment, by slug, name or id (`staging`). Default: the organization's default environment. |

### ReleaseOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `version` | string | yes |  |
| `notes` | string | yes |  |
| `released_at` | string (date-time) | yes |  |
| `target_id` | string (uuid) | yes |  |
| `environment` | string | yes | Slug of the release's environment. |

### ReleasePatch

| Field | Type | Required | Description |
|---|---|---|---|
| `version` | string | no |  |
| `notes` | string | no |  |
| `released_at` | string (date-time) | no |  |
| `target_id` | string (uuid) | no | Send null to detach the release from its target. |

### ReplayIn

| Field | Type | Required | Description |
|---|---|---|---|
| `call_id` | string (uuid) | no | Replay this call of your organization... |
| `bundle` | object | no | ...or an exported ReplayBundle. |
| `same_runtime` | boolean | no | Reuse the bundle's models, voices and settings (exact reproduction). |

### RoleIn

| Field | Type | Required | Description |
|---|---|---|---|
| `role` | `admin` \| `member` \| `guest` | yes |  |

### RunOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `number` | integer | yes |  |
| `scenario_id` | string (uuid) | yes |  |
| `scenario_name` | string | yes |  |
| `trigger` | string | yes |  |
| `status` | `queued` \| `running` \| `completed` \| `canceled` | yes |  |
| `verdict` | string | yes |  |
| `score` | integer | yes |  |
| `total` | integer | yes |  |
| `passed` | integer | yes |  |
| `failed` | integer | yes |  |
| `warned` | integer | yes |  |
| `errored` | integer | yes |  |
| `blocked` | integer | yes |  |
| `cost_cents` | integer | yes |  |
| `version` | string | yes | Label of the run's version: the one the trigger named, else the one live in its environment when it ran. |
| `version_id` | string (uuid) | yes |  |
| `release` | string | yes | **Deprecated**: same value as `version`. |
| `tags` | list of string | no |  |
| `environment` | string | yes | Slug of the run's environment. |
| `created_at` | string (date-time) | yes |  |
| `started_at` | string (date-time) | yes |  |
| `finished_at` | string (date-time) | yes |  |
| `ci` | CiOut | no |  |
| `url` | string | yes |  |

### Sandbox

| Field | Type | Required | Description |
|---|---|---|---|
| `calls_left_24h` | integer | yes |  |
| `max_concurrent_calls` | integer | yes |  |
| `max_duration_s` | integer | yes |  |
| `schedules_and_ci` | boolean | yes |  |

### ScenarioCompareOut

| Field | Type | Required | Description |
|---|---|---|---|
| `scenario_id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `a` | SideScore | yes |  |
| `b` | SideScore | yes |  |
| `score_delta` | number | yes |  |

### ScenarioInvalid

| Field | Type | Required | Description |
|---|---|---|---|
| `detail` | string | no |  |
| `code` | string | no |  |
| `errors` | list of ValidationIssue | yes |  |

### ScenarioOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `target_id` | string (uuid) | yes |  |
| `target_phone` | string | yes |  |
| `source` | string | yes |  |
| `tags` | list of string | yes |  |
| `variants` | integer | yes |  |
| `archived` | boolean | yes |  |
| `schedule` | string | yes |  |
| `updated_at` | string (date-time) | yes |  |
| `document` | object | yes | Full scenario document, variants included. |
| `variant_list` | list of VariantOut | yes |  |
| `compliance` | ComplianceOut | no | Compliance review (acceptable use policy) of this content. |

### ScenarioSummary

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `target_id` | string (uuid) | yes |  |
| `target_phone` | string | yes |  |
| `source` | string | yes |  |
| `tags` | list of string | yes |  |
| `variants` | integer | yes |  |
| `archived` | boolean | yes |  |
| `schedule` | string | yes |  |
| `updated_at` | string (date-time) | yes |  |

### ScheduleForecastOut

| Field | Type | Required | Description |
|---|---|---|---|
| `scenario_id` | string (uuid) | yes |  |
| `scenario_name` | string | yes |  |
| `environment` | string | yes | Environment of this schedule (a scenario has one schedule per environment): decides the number dialed. |
| `schedule` | string | yes | Cron expression, local time of `timezone`. |
| `timezone` | string | yes |  |
| `call_window` | string | yes |  |
| `runs_left` | integer | yes | Runs before the 1st of next month, 00:00 UTC. |
| `test_cases` | integer | yes | Enabled variants (1 without variants). |
| `weight` | integer | yes | Units per 10 minute slice for the destination; 0 when the destination is blocked. |
| `avg_duration_s` | number | yes | Average duration of the scenario's calls of the last 30 days. |
| `observed` | boolean | yes | false: never ran recently, counted at its maximum duration. |
| `slices_per_call` | number | yes |  |
| `units` | integer | yes |  |

### SecretRotateIn

| Field | Type | Required | Description |
|---|---|---|---|
| `grace_period_hours` | integer | no | How long the previous secret keeps signing deliveries (second `v1`). 0 stops it at once. |

### SideScore

| Field | Type | Required | Description |
|---|---|---|---|
| `tests` | integer | yes |  |
| `pass_rate` | number | yes |  |
| `score` | number | yes |  |
| `latency_ms` | integer | yes |  |

### StabilityOut

| Field | Type | Required | Description |
|---|---|---|---|
| `environment` | string | yes |  |
| `tests` | integer | yes |  |
| `pass_rate` | number | yes |  |
| `avg_score` | number | yes |  |
| `p95_latency_ms` | integer | yes |  |
| `flaky` | integer | yes | Test cases that both passed and failed in the period. |
| `last_run_id` | string (uuid) | yes |  |
| `last_run_verdict` | string | yes |  |

### SubscriptionCheckoutIn

| Field | Type | Required | Description |
|---|---|---|---|
| `plan` | string | yes | A public plan code (GET /plans). |
| `interval` | `monthly` \| `annual` | no |  |
| `success_url` | string | no |  |
| `cancel_url` | string | no |  |

### SubscriptionOut

| Field | Type | Required | Description |
|---|---|---|---|
| `plan` | PlanOut | yes |  |
| `status` | string | yes |  |
| `interval` | string | yes |  |
| `current_period_start` | string (date-time) | yes |  |
| `current_period_end` | string (date-time) | yes |  |
| `cancel_at_period_end` | boolean | yes |  |
| `managed_by_stripe` | boolean | yes | false for plans invoiced manually. |
| `payment_retry_until` | string (date-time) | no | status past_due: the renewal payment failed and Stripe retries it. The plan keeps working until this date; update the card in the billing portal before it or the subscription is canceled. |

### TagBulkIn

| Field | Type | Required | Description |
|---|---|---|---|
| `add` | list of string | no | Tags to add, created if new. |
| `remove` | list of string | no | Tags to remove. |
| `objects` | TagObjects | yes |  |

### TagBulkOut

| Field | Type | Required | Description |
|---|---|---|---|
| `added` | list of string | yes |  |
| `removed` | list of string | yes |  |
| `objects` | object | yes | Objects touched, per kind. |

### TagIn

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `color` | string | no |  |

### TagObjects

| Field | Type | Required | Description |
|---|---|---|---|
| `scenarios` | list of string (uuid) | no |  |
| `runs` | list of string (uuid) | no |  |
| `versions` | list of string (uuid) | no |  |
| `numbers` | list of string (uuid) | no | Phone numbers (targets). |

### TagOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `name` | string | yes |  |
| `color` | string | yes |  |

### TagPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no |  |
| `color` | string | no |  |

### TagSuggestion

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `name` | string | yes |  |
| `color` | string | yes |  |
| `uses` | integer | yes | Objects carrying the tag (scenarios, runs, versions, numbers). |

### TargetIn

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `phone` | string | yes | E.164 number, e.g. +14155550100. |
| `kind` | `ai_agent` \| `ivr` \| `iva` \| `human` \| `unknown` | no |  |
| `platform` | string | no |  |
| `language` | string | no |  |
| `description` | string | no |  |

### TargetOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `phone` | string | yes |  |
| `country` | string | yes |  |
| `line_type` | string | yes |  |
| `kind` | string | yes |  |
| `platform` | string | yes |  |
| `language` | string | yes |  |
| `description` | string | yes |  |
| `verification_status` | `unverified` \| `pending` \| `verified` \| `expired` \| `locked` \| `suspended` | yes |  |
| `verification_method` | string | yes |  |
| `verified_at` | string (date-time) | yes |  |
| `verification_expires_at` | string (date-time) | yes |  |
| `attested` | boolean | yes | The legal attestation is signed (sandbox calls). |
| `created_at` | string (date-time) | yes |  |

### TargetPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no |  |
| `kind` | string | no |  |
| `platform` | string | no |  |
| `language` | string | no |  |
| `description` | string | no |  |

### TicketDetail

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `ref` | string | yes | Short reference, e.g. CQA-42. |
| `kind` | `bug` \| `question` \| `feedback` \| `billing` \| `abuse_report` | yes |  |
| `status` | `open` \| `waiting_customer` \| `answered` \| `closed` | yes |  |
| `channel` | `mcp` \| `api` \| `app` \| `email` | yes |  |
| `subject` | string | yes |  |
| `created_by` | string | yes |  |
| `call_id` | string (uuid) | yes |  |
| `run_id` | string (uuid) | yes |  |
| `scenario_id` | string (uuid) | yes |  |
| `context` | object | yes |  |
| `created_at` | string (date-time) | yes |  |
| `updated_at` | string (date-time) | yes |  |
| `last_staff_at` | string (date-time) | yes | Last answer of the team. |
| `unread` | integer | yes | Answers of the team nobody of the organization has opened in the app yet. |
| `message_count` | integer | yes |  |
| `url` | string | yes | The ticket in the app. |
| `messages` | list of MessageOut | yes |  |
| `secrets_redacted` | integer | no | On create and reply: how many things that looked like secrets were removed. |

### TicketIn

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string | yes |  |
| `body` | string | yes | Markdown. What you tried, what happened (error code and detail), what you expected. |
| `kind` | `bug` \| `question` \| `feedback` \| `billing` \| `abuse_report` | no |  |
| `call_id` | string (uuid) | no | A call this is about. |
| `run_id` | string (uuid) | no | A run this is about. |
| `scenario_id` | string (uuid) | no | A scenario this is about. |
| `context` | object | no | Free JSON (8 KB at most): client name and version, OS, the request that failed... |

### TicketOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `ref` | string | yes | Short reference, e.g. CQA-42. |
| `kind` | `bug` \| `question` \| `feedback` \| `billing` \| `abuse_report` | yes |  |
| `status` | `open` \| `waiting_customer` \| `answered` \| `closed` | yes |  |
| `channel` | `mcp` \| `api` \| `app` \| `email` | yes |  |
| `subject` | string | yes |  |
| `created_by` | string | yes |  |
| `call_id` | string (uuid) | yes |  |
| `run_id` | string (uuid) | yes |  |
| `scenario_id` | string (uuid) | yes |  |
| `context` | object | yes |  |
| `created_at` | string (date-time) | yes |  |
| `updated_at` | string (date-time) | yes |  |
| `last_staff_at` | string (date-time) | yes | Last answer of the team. |
| `unread` | integer | yes | Answers of the team nobody of the organization has opened in the app yet. |
| `message_count` | integer | yes |  |
| `url` | string | yes | The ticket in the app. |

### TriggerIn

| Field | Type | Required | Description |
|---|---|---|---|
| `variants` | list of string (uuid) | no | Subset of variant ids; default all enabled variants. |
| `version` | string | no | Version of your code under test (`v2.3.0`, a short sha), in the run's environment; created if new. Without it the run belongs to the version live in the environment. |
| `git_ref` | string | no | Branch or tag of the version, stored when the version is created. |
| `git_sha` | string | no |  |
| `source_url` | string | no | Commit or pipeline page. |
| `release` | string | no | **Deprecated**: the former name of `version`, read when `version` is absent. |
| `tags` | list of string | no | Free keywords added to the run. |
| `ci` | boolean | no | Mark the run as triggered by CI. |
| `environment` | string | no | Environment, by slug, name or id (`staging`). Default: the organization's default environment. The version, when given, is looked up (or created) in this environment. |

### TurnOut

| Field | Type | Required | Description |
|---|---|---|---|
| `t_ms` | integer | yes |  |
| `speaker` | string | yes |  |
| `text` | string | yes |  |
| `start_ms` | integer | yes |  |
| `end_ms` | integer | yes |  |

### UsageOut

| Field | Type | Required | Description |
|---|---|---|---|
| `period_start` | string | yes |  |
| `included` | integer | yes |  |
| `used` | integer | yes |  |
| `held` | integer | yes |  |
| `remaining` | integer | yes |  |
| `bonus_remaining` | integer | yes |  |
| `overage_price_cents` | integer | yes |  |
| `wallet_available_cents` | integer | yes |  |
| `plan` | string | yes |  |

### ValidationIssue

| Field | Type | Required | Description |
|---|---|---|---|
| `loc` | string | yes | Dotted path of the offending field. |
| `msg` | string | yes |  |

### VariantIn

| Field | Type | Required | Description |
|---|---|---|---|
| `title` | string | yes |  |
| `source` | `auto` \| `manual` | no |  |
| `persona` | object | no |  |
| `goal` | string | no |  |
| `instructions` | string | no |  |
| `test_data` | object | no |  |
| `assertions` | list of object | no |  |
| `enabled` | boolean | no |  |

### VariantOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `index` | integer | yes |  |
| `title` | string | yes |  |
| `source` | `auto` \| `manual` | yes |  |
| `enabled` | boolean | yes |  |
| `spec` | object | yes | callqa_spec Variant document. |

### VariantPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | no |  |
| `spec` | object | no | Replace the variant document. |

### VerdictDiffOut

| Field | Type | Required | Description |
|---|---|---|---|
| `scenario` | string | yes |  |
| `index` | integer | yes |  |
| `title` | string | yes |  |
| `a` | string | yes |  |
| `b` | string | yes |  |
| `a_call_id` | string (uuid) | yes |  |
| `b_call_id` | string (uuid) | yes |  |
| `regressed` | boolean | yes | True when the test case passes on side A only. |

### VerificationCallIn

| Field | Type | Required | Description |
|---|---|---|---|
| `dtmf_gate` | string | no | Replace the keys to press of the active code (see `start`). `{code}` only works when it was already given to `start` or `retry`. |

### VerificationCallOut

| Field | Type | Required | Description |
|---|---|---|---|
| `call_id` | string (uuid) | yes |  |
| `verification` | VerificationOut | yes |  |

### VerificationCodeOut

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes | Shown ONCE, never stored in clear: copy it now. |
| `phrase` | string | yes | Add this sentence to the greeting or the agent prompt. |
| `code_expires_at` | string (date-time) | yes |  |
| `method` | string | yes |  |
| `verification` | VerificationOut | yes |  |

### VerificationOut

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | `unverified` \| `pending` \| `verified` \| `expired` \| `locked` \| `suspended` | yes |  |
| `method` | string | yes |  |
| `verified_at` | string (date-time) | yes |  |
| `expires_at` | string (date-time) | yes | Re-verification needed after this date. |
| `locked_until` | string (date-time) | yes |  |
| `suspended_reason` | string | yes |  |
| `attested` | boolean | yes |  |
| `attested_at` | string (date-time) | yes |  |
| `attestation_version` | string | no | Version of the signed text. |
| `attestation_current_version` | string | yes | Version to sign today. |
| `attestation_outdated` | boolean | no | Signed under a text that is no longer accepted: sign again, calls to this number are blocked until then. |
| `attempts_left` | integer | yes | Failed attempts allowed before a 24 h lock. A call that is not answered counts too. |
| `attempts_max` | integer | no | Failed attempts allowed per number in 24 h. |
| `attempts_reset_at` | string (date-time) | no | When the oldest failed attempt stops counting (24 h after it ended) and one attempt comes back; null when none counts. |
| `dtmf_gate` | string | no | Keys remembered for the next verification call (IVR menu). |
| `active_code` | ActiveCode | yes |  |
| `last_attempt` | LastAttempt | yes |  |
| `sandbox` | Sandbox | yes | Limits while the number is not verified (attestation signed). |
| `inbound_number` | string | yes | Number to call for `inbound_dtmf`. |

### VerificationStartIn

| Field | Type | Required | Description |
|---|---|---|---|
| `method` | `spoken_code` \| `inbound_dtmf` | no | `spoken_code` (default): put the code in the line's greeting or agent prompt, then `POST .../verification/call`. `inbound_dtmf` (when enabled): call our verification number from the line and type the code. |
| `attestation` | AttestIn | no | Sign the attestation in the same request (required once). |
| `dtmf_gate` | string | no | Keys the verification call presses after the line answers, before listening for the code, when the code is behind an IVR menu: 0-9, `*`, `#`, `w` for a 0.5 s pause (as in Twilio's sendDigits), at most 40 characters. Example: `1w2`. `{code}` types the code itself (reserved to CallQA test lines). Omitted: the keys remembered on the number; empty string: no keys. |
| `renew` | boolean | no | Verify a `verified` number again (renews the 180 days). The number stays `verified` during the attempt and if it fails. |

### VerifyIn

| Field | Type | Required | Description |
|---|---|---|---|
| `method` | string | no | Legacy endpoint: signs the attestation only. |
| `accept` | boolean | yes | Must be true: you accept the attestation text. |
| `signer_name` | string | yes | Full name of the person attesting. |

### VersionIn

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | yes |  |
| `environment` | string | no | Environment, by slug, name or id (`staging`). Default: the organization's default environment. |
| `deployed_at` | string (date-time) | no | When it went live. Default: now. |
| `git_ref` | string | no |  |
| `git_sha` | string | no |  |
| `source_url` | string | no |  |
| `notes` | string | no |  |
| `target_id` | string (uuid) | no | Only for this phone number (default: every number). |
| `tags` | list of string | no |  |

### VersionOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `label` | string | yes |  |
| `environment` | string | yes | Slug of the version's environment. |
| `deployed_at` | string (date-time) | yes |  |
| `git_ref` | string | yes |  |
| `git_sha` | string | yes |  |
| `source_url` | string | yes |  |
| `notes` | string | yes |  |
| `target_id` | string (uuid) | yes |  |
| `tags` | list of string | yes |  |
| `created_at` | string (date-time) | yes |  |

### VersionPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `label` | string | no |  |
| `deployed_at` | string (date-time) | no |  |
| `git_ref` | string | no |  |
| `git_sha` | string | no |  |
| `source_url` | string | no |  |
| `notes` | string | no |  |
| `target_id` | string (uuid) | no | Send null to apply the version to every number. |
| `tags` | list of string | no | Replaces the tags. |

### VersionPromoteIn

| Field | Type | Required | Description |
|---|---|---|---|
| `environment` | string | yes | Environment to promote to, by slug, name or id. |
| `deployed_at` | string (date-time) | no | Default: now. |

### WalletOut

| Field | Type | Required | Description |
|---|---|---|---|
| `balance_cents` | integer | yes |  |
| `held_cents` | integer | yes |  |
| `available_cents` | integer | yes |  |
| `currency` | string | yes |  |

### WalletTxOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes |  |
| `kind` | string | yes |  |
| `amount_cents` | integer | yes |  |
| `held_delta_cents` | integer | yes |  |
| `balance_after_cents` | integer | yes |  |
| `call_id` | string (uuid) | yes |  |
| `description` | string | yes |  |
| `created_at` | string (date-time) | yes |  |

### WebhookCreated

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `url` | string | yes |  |
| `events` | list of string | yes |  |
| `description` | string | yes |  |
| `enabled` | boolean | yes |  |
| `created_at` | string (date-time) | yes |  |
| `filters` | WebhookFilters | yes |  |
| `previous_secret_expires_at` | string (date-time) | no | While set and in the future, deliveries carry a second `v1` signature made with the previous secret. |
| `secret` | string | yes | Signing secret. Shown only once. |

### WebhookFilters

| Field | Type | Required | Description |
|---|---|---|---|
| `scenario_ids` | list of string (uuid) | no | Only events of these scenarios. |
| `tags` | list of string | no | Only events of scenarios or runs carrying one of these tags. |
| `environments` | list of string | no | Only events of these environments (slug, name or id). |

### WebhookIn

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |
| `events` | list of string | yes | Event types, or ['*'] for all. |
| `description` | string | no |  |
| `enabled` | boolean | no |  |
| `filters` | WebhookFilters | no | Optional scope. Each non-empty list must match (any value of a list, all lists). Call, run and version events are filtered; organization events (`wallet.low_balance`) always go through. Empty or absent: every event. |

### WebhookOut

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `url` | string | yes |  |
| `events` | list of string | yes |  |
| `description` | string | yes |  |
| `enabled` | boolean | yes |  |
| `created_at` | string (date-time) | yes |  |
| `filters` | WebhookFilters | yes |  |
| `previous_secret_expires_at` | string (date-time) | no | While set and in the future, deliveries carry a second `v1` signature made with the previous secret. |

### WebhookPatch

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | no |  |
| `events` | list of string | no |  |
| `description` | string | no |  |
| `enabled` | boolean | no |  |
| `filters` | WebhookFilters | no | Optional scope. Each non-empty list must match (any value of a list, all lists). Call, run and version events are filtered; organization events (`wallet.low_balance`) always go through. Empty or absent: every event. Send `{}` or null to remove every filter. |
