> ## Documentation Index
> Fetch the complete documentation index at: https://docs.testorim.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Start a test run headlessly

> The primary CI endpoint. Starts a run **in the background** and
returns immediately with a run id. The HTTP request does not block
on test completion, because typical runs take 30-120 s (and may be
allowed up to 120 minutes) while CI providers time out sooner. Poll
`GET /api/runs/{runId}` until `status` is terminal.

### Two mutually exclusive modes

The body must carry **exactly one** of `procedureId` or
`description`; the Zod schema has one `.refine` that rejects neither
and another that rejects both.

* **Replay mode** (`procedureId`) re-executes a saved procedure's
  stored steps. No LLM planning call. The procedure is looked up
  scoped to `projectId`, so a procedure from another project 404s.
* **Ad-hoc mode** (`description`) plans steps from the
  prompt, then executes them. The description is truncated to the
  first 2000 characters when it is stored as the run's `instruction`
  (the schema itself allows up to 4000).

### Base URL resolution

The browser opens, in priority order:
`environment.baseUrl` → `baseUrl` (this request) → `project.baseUrl`.
The resolved URL is then run through the SSRF guard
(`assertSafeUrl`); private, loopback, link-local and metadata hosts
are rejected with `400`.

### Duration

`maxDurationMinutes` overrides the procedure's saved cap. When
omitted the run inherits `procedure.maxDurationMinutes`, and failing
that the platform default of 10 minutes. The effective value is
always clamped to 5..120.

### Refusals

A pre-allocated `runs` row is created before setup. If setup then
fails (quota, unsafe URL, browser launch), that row is closed with a
reason rather than left dangling, so a `429` or `400` from this
endpoint does not leave a phantom pending run.




## OpenAPI

````yaml /api-reference/openapi.yaml post /api/runs/trigger
openapi: 3.1.0
info:
  title: Testorim API
  version: 1.0.0
  summary: AI browser QA. Trigger and inspect test runs from CI or your terminal.
  description: >
    Testorim runs plain-English browser QA tests in a real Playwright browser.

    This document describes the **API-key-authenticated subset** of the HTTP

    API, meaning the endpoints a CI pipeline, a terminal, or a third-party

    integration is expected to call.


    ## Scope and honesty notes


    * **This is not a frozen contract.** `1.0.0` is a documentation version,
      not a server version. The API is **unversioned in the URL**. There is
      no `/v1` prefix; every router mounts flat under `/api`.
    * **This is a subset.** The app's own SPA calls many more endpoints
      (billing, Polar/Clerk/GitHub webhooks, Slack and GitHub OAuth
      callbacks, fixtures, environments, schedules, alerts, visual
      baselines, run sharing, flakiness, test-data variables, perf budgets,
      role probes, bug reports, org/invite management). Those are
      deliberately **not** documented here: they are either session-oriented,
      provider-signed, or not stable enough to hand to an integrator.
      `PATCH`/`DELETE` on projects and procedures also exist in the code but
      are out of scope for this reference.

    ## Authentication


    Send the API key as an **HTTP Bearer token**:


    ```

    Authorization: Bearer tst_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

    ```


    Testorim reads exactly one header, `Authorization`, and accepts either

    credential on it:


    1. a value matching `tst_live_…` (prefix + at least 16 more chars) is
       verified as an API key by SHA-256 hash lookup, or
    2. anything else is verified as a Clerk session JWT (the browser path).


    An API key is bound to **one user and one organization**. On every

    request the middleware pins `currentOrg` to the key's org, so a key

    issued for org A can never read or write org B, even when the owning

    user belongs to both. If the user has since been removed from that org,

    the key stops working (`401`).


    Every list and lookup is org-scoped. A resource belonging to another

    organization returns `404`, never `403`, so the API does not leak the

    existence of rows you cannot see.


    ## Role restrictions


    Members of an org with role `viewer` are read-only: any

    non-`GET`/`HEAD`/`OPTIONS` request is rejected with `403` and

    `code: "read_only_role"`. This applies to API-key callers too, because

    the key inherits the user's role in the bound org.


    ## Rate limiting


    Limits are enforced as a sliding window:


    | Limiter | Policy | Keyed by | Applied to |

    | --- | --- | --- | --- |

    | `api:global` | 600 / minute | client IP | every `/api/*` request |

    | `procedure:minute` | 30 / minute | authenticated user id | `POST
    /api/runs/trigger` |

    | `api:read` | 120 / minute | authenticated user id | `GET /api/runs/active`
    |

    | `auth:strict` | 5 / 15 minutes | client IP | **failed** credential checks
    |


    A throttled request returns `429`, a JSON body of

    `{ "error": …, "retryAfter": <seconds> }`, and a `Retry-After` response

    header. **There are no `X-RateLimit-*` headers**. The middleware sets

    only `Retry-After`, so do not write a client that depends on quota

    headers.


    Rate limits are separate from **plan quotas**. Exhausting the

    organization's monthly run allowance also returns `429`, but from

    `checkRunLimit` rather than the limiter, and with no `Retry-After`.


    ## Request body rules


    * JSON bodies are capped at **1 MB**.

    * Any string anywhere in the body containing a NUL byte (`U+0000`)
      rejects the whole request with `400` before it reaches a handler.
    * Bodies are validated with Zod. A schema failure returns `400` with
      `{ "error": "Invalid request body", "issues": [...] }`, where `issues`
      is the raw `ZodError.issues` array.
  contact:
    name: Testorim support
    email: info@fulgic.com
    url: https://testorim.com
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
servers:
  - url: https://app.testorim.com
    description: >-
      Production. Single-origin deployment: Caddy on one GCP VM serves the SPA
      at `/` and reverse-proxies `/api/*` and `/ws` to the backend. This is also
      the default API base URL used by the Testorim CLI.
  - url: http://localhost:3001
    description: >-
      A locally running Testorim backend, for self-hosted or development use.
      The default port is 3001.
security:
  - ApiKeyAuth: []
tags:
  - name: Runs
    description: >-
      Trigger a test run and read its result. `POST /api/runs/trigger` is the
      endpoint CI actually uses.
  - name: Procedures
    description: Saved, replayable test sequences belonging to a project.
  - name: Projects
    description: A website-under-test, scoped to an organization.
  - name: API keys
    description: >-
      Mint, list and revoke API keys. Creating a key requires a browser session.
      See the operation notes.
  - name: Identity
    description: Who the presented credential belongs to.
paths:
  /api/runs/trigger:
    post:
      tags:
        - Runs
      summary: Start a test run headlessly
      description: |
        The primary CI endpoint. Starts a run **in the background** and
        returns immediately with a run id. The HTTP request does not block
        on test completion, because typical runs take 30-120 s (and may be
        allowed up to 120 minutes) while CI providers time out sooner. Poll
        `GET /api/runs/{runId}` until `status` is terminal.

        ### Two mutually exclusive modes

        The body must carry **exactly one** of `procedureId` or
        `description`; the Zod schema has one `.refine` that rejects neither
        and another that rejects both.

        * **Replay mode** (`procedureId`) re-executes a saved procedure's
          stored steps. No LLM planning call. The procedure is looked up
          scoped to `projectId`, so a procedure from another project 404s.
        * **Ad-hoc mode** (`description`) plans steps from the
          prompt, then executes them. The description is truncated to the
          first 2000 characters when it is stored as the run's `instruction`
          (the schema itself allows up to 4000).

        ### Base URL resolution

        The browser opens, in priority order:
        `environment.baseUrl` → `baseUrl` (this request) → `project.baseUrl`.
        The resolved URL is then run through the SSRF guard
        (`assertSafeUrl`); private, loopback, link-local and metadata hosts
        are rejected with `400`.

        ### Duration

        `maxDurationMinutes` overrides the procedure's saved cap. When
        omitted the run inherits `procedure.maxDurationMinutes`, and failing
        that the platform default of 10 minutes. The effective value is
        always clamped to 5..120.

        ### Refusals

        A pre-allocated `runs` row is created before setup. If setup then
        fails (quota, unsafe URL, browser launch), that row is closed with a
        reason rather than left dangling, so a `429` or `400` from this
        endpoint does not leave a phantom pending run.
      operationId: triggerRun
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TriggerRunRequest'
            examples:
              replaySavedProcedure:
                summary: Replay a saved procedure
                value:
                  projectId: 3f1c2b90-7a5e-4c31-9f0d-2b8a6c4e1d55
                  procedureId: 8d4e7a12-90bb-4f6c-a1e3-5c7d9f2b4a06
              replayAgainstPrPreview:
                summary: Replay against a PR preview deployment
                value:
                  projectId: 3f1c2b90-7a5e-4c31-9f0d-2b8a6c4e1d55
                  procedureId: 8d4e7a12-90bb-4f6c-a1e3-5c7d9f2b4a06
                  baseUrl: https://preview-42.staging.example.com
                  maxDurationMinutes: 20
              adHocNegativeTest:
                summary: Ad-hoc negative test from a prompt
                value:
                  projectId: 3f1c2b90-7a5e-4c31-9f0d-2b8a6c4e1d55
                  description: >-
                    Try to sign in with the wrong password and verify an error
                    message appears.
                  expectation: fail
      responses:
        '202':
          description: >-
            Accepted. The run row exists in `pending` and execution has started
            in the background.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TriggerRunAccepted'
        '400':
          description: >-
            Body failed Zod validation (including the either/or rule for
            `procedureId` vs `description`), or the resolved base URL pointed at
            a private/internal host and was refused by the SSRF guard
            (`UnsafeBaseUrlError`), or the body contained a NUL byte.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/ValidationError'
                  - $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ReadOnlyRole'
        '404':
          description: >-
            `projectId` does not name a live project in the key's organization,
            or `procedureId` does not belong to that project. Body is
            `{"error":"Project not found"}` or `{"error":"Procedure not
            found"}`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: |
            Two different conditions share this status:

            * **Rate limited.** More than 30 triggers in a minute for this
              user (`procedure:minute`), or more than 600 `/api/*` requests
              in a minute from this IP (`api:global`). Carries `Retry-After`
              and a `retryAfter` field.
            * **Plan quota exhausted.** `RunLimitExceededError` from
              `checkRunLimit`, e.g. the org used all its monthly runs or
              browser minutes. Body is `{"error": "<reason>"}` with **no**
              `retryAfter` and **no** `Retry-After` header.
          headers:
            Retry-After:
              description: >-
                Seconds until the window frees up. Present on rate-limit
                rejections only, not on plan-quota rejections.
              required: false
              schema:
                type: integer
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/RateLimitError'
                  - $ref: '#/components/schemas/Error'
      security:
        - ApiKeyAuth: []
components:
  schemas:
    TriggerRunRequest:
      type: object
      title: TriggerRunRequest
      description: >-
        Body of `POST /api/runs/trigger`, mirroring `TriggerRunBody` in
        `routes/runs.ts`. Exactly one of `procedureId` or `description` is
        required. The schema rejects both neither and both.
      required:
        - projectId
      properties:
        projectId:
          type: string
          format: uuid
          description: >-
            The project to run against. Always required, even in replay mode.
            Must be a live project in the key's organization.
        procedureId:
          type: string
          format: uuid
          description: >-
            Replay mode. A saved procedure belonging to `projectId`; its stored
            steps are executed with no LLM planning call. Mutually exclusive
            with `description`.
        description:
          type: string
          minLength: 1
          maxLength: 4000
          description: >-
            Ad-hoc mode. A plain-English description of the test; Claude plans
            the steps. Mutually exclusive with `procedureId`. Note the first
            2000 characters are what get stored as the run's `instruction`.
        expectation:
          $ref: '#/components/schemas/UserExpectation'
        baseUrl:
          type: string
          maxLength: 2048
          description: >-
            Override the project's base URL for this run only. The usual way to
            point CI at a PR preview deployment. Beaten by the environment's
            base URL when `environmentId` is also set. Validated only for length
            here; the SSRF guard checks the resolved URL and rejects private
            hosts with `400`.
        environmentId:
          type: string
          format: uuid
          description: >-
            Apply a saved project environment (base URL, extra headers,
            cookies). Its base URL takes priority over `baseUrl` and over the
            project default. An environment id that does not resolve for this
            project is silently ignored rather than erroring.
        maxDurationMinutes:
          type: integer
          minimum: 5
          maximum: 120
          description: >-
            Per-run time cap. Omit to inherit the procedure's saved value, then
            the platform default of 10 minutes.
      additionalProperties: false
    TriggerRunAccepted:
      type: object
      title: TriggerRunAccepted
      required:
        - runId
        - projectId
        - status
        - pollUrl
      properties:
        runId:
          type: string
          format: uuid
          description: The pre-allocated run id. Poll it to observe completion.
        projectId:
          type: string
          format: uuid
        status:
          type: string
          const: pending
          description: >-
            Always the literal string `pending`. This is the state the row was
            created in, not a live read.
        pollUrl:
          type: string
          description: >-
            Relative path to poll, e.g. `/api/runs/<runId>`. Join it to the
            server base URL.
          examples:
            - /api/runs/4c9e1f77-2a31-4e0b-8d6a-1f2b3c4d5e6f
    ValidationError:
      type: object
      title: ValidationError
      description: >-
        Emitted by `parseBody` / `parseQuery` when a Zod schema rejects the
        input. `issues` is the raw `ZodError.issues` array, passed through
        verbatim.
      required:
        - error
        - issues
      properties:
        error:
          type: string
          enum:
            - Invalid request body
            - Invalid query parameters
        issues:
          type: array
          description: Raw Zod issues.
          items:
            type: object
            properties:
              code:
                type: string
              path:
                type: array
                items:
                  type:
                    - string
                    - integer
              message:
                type: string
            additionalProperties: true
    Error:
      type: object
      title: Error
      description: >-
        The universal error envelope. Every failing handler in this surface
        responds with a JSON object carrying a human-readable `error` string;
        some add extra fields (see the other error schemas).
      required:
        - error
      properties:
        error:
          type: string
          description: Human-readable message. Not a stable machine-readable code.
      additionalProperties: true
    RateLimitError:
      type: object
      title: RateLimitError
      description: Emitted by the rate-limit middleware alongside a `Retry-After` header.
      required:
        - error
      properties:
        error:
          type: string
          description: The limiter's reason, e.g. `Rate limit exceeded. Try again in 42s.`
        retryAfter:
          type:
            - integer
            - 'null'
          description: >-
            Seconds until the window frees. Mirrors `Retry-After`. May be
            absent/null when the limiter did not report a reset time, in which
            case the header defaults to 60.
      additionalProperties: true
    UserExpectation:
      type: string
      title: UserExpectation
      enum:
        - pass
        - fail
        - unknown
      default: pass
      description: |
        How to frame the verdict.

        * `pass` (default): a positive test; every step should succeed.
        * `fail`: a negative test; the run "passes" when the application
          correctly refuses the action. A safety net in the orchestrator
          appends a synthetic failed step if nothing actually failed.
        * `unknown`: exploratory; no strong framing.

        The server coerces anything other than `fail`/`unknown` to `pass`.
    ReadOnlyRoleError:
      type: object
      title: ReadOnlyRoleError
      description: The one error in this surface with a stable machine-readable code.
      required:
        - error
        - code
      properties:
        error:
          type: string
          const: Viewers have read-only access to this workspace
        code:
          type: string
          const: read_only_role
  responses:
    Unauthorized:
      description: |
        No `Authorization: Bearer …` header, or the credential did not
        verify. Distinct messages the code can return:

        * `Missing or invalid Authorization header`
        * `Invalid or revoked API key`
        * `API key owner not found`
        * `API key's organization is no longer accessible`. The key's user
          has been removed from the org it is bound to
        * `Invalid token` / `Token missing sub claim`. Clerk JWT path

        Repeated failures burn the `auth:strict` bucket (5 per 15 minutes
        per IP) and then return `429` instead.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Invalid or revoked API key
    ReadOnlyRole:
      description: >-
        The credential's user has the `viewer` role in the bound organization,
        and viewers may not make mutating requests.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ReadOnlyRoleError'
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: tst_live_<32 url-safe base64 chars>
      description: |
        Send `Authorization: Bearer tst_live_…`.

        Format (`services/api-keys.ts`): the literal prefix `tst_live_`
        followed by 24 random bytes rendered as 32 base64url characters.
        Only the SHA-256 hash is stored server-side. The shape check that
        routes a token down the API-key path rather than the Clerk JWT path
        requires the `tst_live_` prefix and a total length of at least 25
        characters.

        Keys are minted in the dashboard at `/settings/team`. The same header
        also accepts a Clerk session JWT, which is how the web app
        authenticates, but the JWT path is out of scope for this document.

````

## Related topics

- [Run and inspect a test](/getting-started/run-and-inspect.md)
- [Create your first test](/getting-started/first-test.md)
- [Browser runs](/test-execution/browser-runs.md)
- [Quickstart](/getting-started/quickstart.md)
- [Debugging a failed run](/test-execution/debugging.md)
