---
name: lathe-api-developer
version: 1.2.0
last_updated: 2026-09-24
download_url: https://lathe.studio/skills/lathe-api-developer.md
---

# Lathe Studio API — Developer Skill

Official reference for integrating with the Lathe Studio REST API v1.
This skill covers authentication, endpoints, CI/CD recipes, webhook handling, error resolution, and common integration patterns.

## Scopes Reference

API keys are assigned scopes at creation time. Each scope controls access to a set of endpoints.

| Scope | Endpoints |
|-------|-----------|
| `builds:create` | POST /builds |
| `builds:read` | GET /builds, GET /builds/{id} |
| `builds:update` | PATCH /builds/{id} |
| `projects:read` | GET /projects, GET /projects/{id}/testcases, GET /projects/{id}/testcases/{tcId}, GET /projects/{id}/custom-fields, GET /organizations |
| `releases:create` | POST /releases |
| `releases:read` | GET /releases, GET /releases/{id} |
| `results:read` | GET /testruns/{id}/results, GET /testruns/{id}/results/{executionId}/notes |
| `results:submit` | POST /testruns/{id}/results, POST /results/ingest |
| `testcases:create` | POST /projects/{id}/testcases |
| `testcases:delete` | DELETE /projects/{id}/testcases/{tcId} |
| `testcases:update` | PUT /projects/{id}/testcases/{tcId} |
| `testruns:assign` | POST /testruns/{id}/assign |
| `testruns:create` | POST /testruns, POST /testruns/{id}/split |
| `testruns:read` | GET /testruns, GET /testruns/{id}, GET /releases/{id}/progress |
| `testsets:create` | POST /projects/{id}/test-sets |
| `testsets:delete` | DELETE /projects/{id}/test-sets/{setId} |
| `testsets:read` | GET /projects/{id}/test-sets, GET /projects/{id}/test-sets/{setId}, GET /projects/{id}/test-sets/{setId}/preview |
| `testsets:update` | PUT /projects/{id}/test-sets/{setId} |
| `webhooks:manage` | GET /webhooks, POST /webhooks, DELETE /webhooks/{id} |


## Endpoint Reference

All endpoints are relative to `https://lathe.studio/api/v1`.

### Builds

#### `POST /builds` — requires `builds:create`

Create a build

Creates a new build and returns `201 Created`. `release_id` is optional — without it the build lands in the project's "No release" bucket; a release is never chosen for you. Duplicate names are never refused: if a build with the same name (case-insensitive) already exists in the same release bucket, the new build is created as "name (2)", "name (3)", … and the final name is returned. An optional `suffix` makes the stored name "<name> (<suffix>)".

**Project resolution:** a project-scoped key uses its project. An org-scoped key MUST send `project_key` (project UUID or project_code) — there is no default project and `repo_url` no longer selects one (breaking change for org-scoped keys, 2026-09-24). `project_key` naming a different project than a project-scoped key → `403`.

Idempotency: send an `Idempotency-Key` header to safely retry. An identical retry returns `200` with the original build (no second insert); the same key with a different body returns `409` (`details.reason: idempotency_key_reused`). Keys expire after 24 hours; a claim whose request died mid-flight is reclaimed after 5 minutes.

Typically called from your CI/CD pipeline immediately after a successful deployment.

#### `GET /builds` — requires `builds:read`

List builds

Returns a paginated list of builds. Filter by project (`project_id` or `project_key` — both sent and disagreeing is a 400) or by release bucket: `release=none` for release-less builds, `release=<uuid>` for one release (must belong to the resolved project, else 404).

#### `GET /builds/{id}` — requires `builds:read`

Get a build

Returns a single build by ID. A project-scoped key only sees its own project's builds; anything else is a 404. This is where the `Location` header from `POST /builds` points.

#### `PATCH /builds/{id}` — requires `builds:update`

Place a build in a release

Moves a build into a release, or back to the project's "No release" bucket with `release_id: null`. The body is exactly `{ "release_id": "<uuid>" }` — any other field is a 400, and no other build field is editable through the API.

The move runs through the same core as the UI's *Place in release*: the build's runs follow it in the database, a name collision in the target bucket suffixes the name ("X (2)") instead of refusing, and a **completed** release refuses the move in either direction (**409** `details.reason: release_completed`). A release from another project is a 404.

Returns the build (`id`, `name`, `release_id`, `status`).

### Test Runs

#### `GET /testruns` — requires `testruns:read`

List test runs

Returns a paginated list of test runs for a project or build.

#### `POST /testruns` — requires `testruns:create`

Create a test run

Create a new test run with selected test cases. The run is created in `planned` status and can be executed manually or via automation.

When `build_id` is given, the run's release is its build's release: a run created on a build in a release belongs to that release, and a run on a release-less build has no release. Passing both `release_id` and `build_id` where the build's release differs returns **400** `VALIDATION_ERROR` with `details.reason: 'release_build_mismatch'` — an explicit release is never silently overridden.

`assignee_id` and `queue: true` both require the **`testruns:assign`** scope in addition to `testruns:create` (touchless T3, 2026-09-24): assigning or queueing a run is a lead's decision in the UI, and the API mirrors that. An assignee together with `queue: true` is refused (400 `assignee_and_queue`). Keys created before 2026-09-24 do not have the scope — an admin enables it on the key. An assigned run now sends the assignment notifications (email, `testrun.assigned.v1`, `run.assigned` chat trigger) attributed to the API key; previously API-created assigned runs sent nothing.

`assignee_email` (touchless T4, 2026-09-24) is accepted anywhere `assignee_id` is: the email is resolved case-insensitively to a user inside your organization and the run is assigned exactly as with `assignee_id` (same validation, notifications, and `testruns:assign` scope requirement). An email that does not match exactly one assignable org member returns **400** `BAD_REQUEST` (the same code this endpoint has always used for `assignee_id` errors, which now also carry `details.reason`) with `details.reason` one of `assignee_not_found`, `assignee_not_assignable` (member lacks effective `test_run:update` in the project), or `assignee_ambiguous` (the email matches more than one user — disambiguate with `assignee_id`). The response is identical whether the email belongs to a different organization or to no one — the API never confirms which. Sending both fields returns `details.reason: 'assignee_conflict'`. There is no endpoint that lists member emails.

#### `GET /testruns/{id}` — requires `testruns:read`

Get a test run

Returns a single test run by ID.

#### `POST /testruns/{id}/assign` — requires `testruns:assign`

Assign, queue, or unassign a test run

Moves a run between the three assignment states. Exactly one target is required:
- `{ "assignee_id": "<user id>" }` — assign or reassign to a person (must be assignable in the project).
- `{ "assignee_email": "<email>" }` — the same, by email: resolved case-insensitively inside your organization (touchless T4). An email that does not match exactly one assignable org member returns **400** `VALIDATION_ERROR` (`details.reason`: `assignee_not_found`, `assignee_not_assignable`, or `assignee_ambiguous`) — the identical response whether the email belongs to another org or to no one. Sending both assignee fields returns `details.reason: 'assignee_conflict'`.
- `{ "queue": true }` — release the run into the unassigned queue for testers to grab.
- `{ "unassigned": true }` — pull the run back to plain unassigned.

Sends the same notifications as assigning in the UI: the `testrun.assigned.v1` webhook, an email to the assignee, and the `run.assigned` Slack/Teams trigger — attributed to the API key by name (the audit row records the key, not a user). Repeating the run's current target returns **200** with `changed: false` and no side effects. An API key cannot grab a run for itself; self-claim remains a browser-only action.

**Completed releases freeze assignment:** if the run's release is `completed`, every target is refused with **409** `details.reason: 'release_completed'` — a completed release freezes the run's assignment.

**Behaviour change (2026-09-24):** creating a run with `assignee_id` (POST /testruns) and splitting a run with an assignee now send the same notifications.

Other `details.reason` values on **400** `VALIDATION_ERROR`: `invalid_target` (no target, or more than one), `assignee_and_queue` (an assignee together with `queue`), `invalid_assignee_email` (malformed email).

#### `POST /testruns/{id}/split` — requires `testruns:create`

Split a test run

Moves unexecuted cases from a run into one or more child runs. Executed cases stay locked in the original run, whose status becomes `superseded` when emptied.

- `by_section`: one child run per section.
- `by_set`: one child run per source regression set.
- `manual`: moves the executions listed in `execution_ids` into a single child run (`execution_ids` is required for this strategy).

`assignee_id` (or `assignee_email`, resolved case-insensitively inside your organization — touchless T4) is optional; when provided, the moved executions are assigned to that user (who must be assignable in the project). Supplying `assignee_id` also requires the `testruns:assign` scope (403 without it), and sends the same assignment notifications as `POST /testruns/{id}/assign`.

A project-scoped key used on another project's run gets **403** `FORBIDDEN` here (assign returns 404 for the same case). Assignee failures return **400** `VALIDATION_ERROR` with the same `details.reason` values as assign.

#### `GET /testruns/{id}/results` — requires `results:read`

Get test run results

Returns all execution results for a test run, including pass/fail status, notes, and defect references.

#### `POST /testruns/{id}/results` — requires `results:submit`

Submit automation results

Submit execution results from an automation framework (Playwright, Cypress, pytest …). One execution row is added per item. A `test_case_id` that isn't a live case in the run's project is skipped and counted in `skipped`, not rejected. This endpoint does not honour `Idempotency-Key`.

#### `GET /testruns/{id}/results/{executionId}/notes` — requires `results:read`

Get exploratory session notes

Returns the note log of one execution of an exploratory test case (the 'session'), oldest first. Read-only; notes are added from the run page.

#### `POST /results/ingest` — requires `results:submit`

Ingest automated test results

Accepts JSON test results from any CI/CD pipeline. Refers to a Build by name (results attach to the NEWEST non-deleted build of that name in the project, case-insensitive) and creates one in "No release" only when none exists — a release is never guessed. Creates a completed Test Run, links results to existing Test Cases (by display code or external_id), or auto-creates new Test Cases for unknown identifiers. Pass `build.release_id` to target a bucket, `build.suffix` for "name (suffix)".

**409** `CONFLICT` (`details.reason: release_completed`) when the build's release is completed. Validation failures are **400** `BAD_REQUEST` with `details.field`.

### Projects

#### `GET /projects` — requires `projects:read`

List projects

Returns all projects the API key has access to.

#### `GET /projects/{id}/testcases` — requires `projects:read`

List test cases

Returns test cases for a project. Useful for syncing test case IDs with your automation framework.

### Test Cases

#### `POST /projects/{id}/testcases` — requires `testcases:create`

Create a test case

Creates a new test case in the project and section given in the URL path. Steps may mix plain manual steps with tool steps (`step_type` + `tool_config`).

The case-level `preconditions` field is **deprecated**: when non-empty it is converted into a leading `step_type: 'precondition'` step and never stored in a column. Send a precondition step directly instead. Accepts an optional `case_type` — the vendor-style case Type (functional, regression, smoke, acceptance, …); omitted or null means not set. Unknown values are rejected with a 400 VALIDATION_ERROR.

#### `GET /projects/{id}/testcases/{tcId}` — requires `projects:read`

Get a test case

Returns one test case with its `custom_fields` serialized to the P6 wire format (see GET /projects/{id}/custom-fields for the schema). Values whose definition has been archived are omitted.

#### `PUT /projects/{id}/testcases/{tcId}` — requires `testcases:update`

Update a test case

Partial update of a test case. All fields are optional; unknown fields are rejected. At least one field must be provided.

When `preconditions` is provided without `steps`, the deprecated conversion applies against the case's stored steps. Pass `case_type` to set the vendor-style case Type, or `null` to clear it back to not set.

#### `DELETE /projects/{id}/testcases/{tcId}` — requires `testcases:delete`

Delete a test case

Soft-deletes a test case. Idempotent — returns 204 even if the case was already deleted.

#### `GET /projects/{id}/custom-fields` — requires `projects:read`

List project custom fields

Returns the project's ACTIVE custom field definitions in display (`position`) order: `key`, `label`, `field_type`, `options` (option labels, in order — null for non-select types), `position`. Archived definitions are omitted. There is no API to create, edit or archive definitions in v1 — managing fields is a human task in Project Settings.

### Regression Sets

#### `GET /projects/{id}/test-sets` — requires `testsets:read`

List Regression Sets

Returns the project's Regression Sets (reusable collections of test cases and section subscriptions) with their current item counts.

#### `POST /projects/{id}/test-sets` — requires `testsets:create`

Create a Regression Set

Creates a Regression Set from case items and/or section items. `case` items are static snapshots; `section` items become dynamic subscriptions — test cases added to a subscribed section join in-flight runs automatically.

Requires the `testsets:create` scope.

#### `GET /projects/{id}/test-sets/{setId}` — requires `testsets:read`

Get a Regression Set

Returns a single Regression Set with its items (cases and section subscriptions), ordered by position.

#### `PUT /projects/{id}/test-sets/{setId}` — requires `testsets:update`

Update a Regression Set

Updates a Regression Set's name and/or description. When `items` is provided it fully replaces the set's items.

Requires the `testsets:update` scope.

#### `DELETE /projects/{id}/test-sets/{setId}` — requires `testsets:delete`

Delete a Regression Set

Soft-deletes a Regression Set. A set that doesn't exist, or was already deleted, returns **404**.

Requires the `testsets:delete` scope.

#### `GET /projects/{id}/test-sets/{setId}/preview` — requires `testsets:read`

Preview a resolved Regression Set

Returns the set resolved against the current test case library: the total current case count plus up to 5 sample cases. Section items are expanded at request time, so the result reflects the library as it is now — not when the set was created.

### Releases

#### `GET /releases/{id}/progress` — requires `testruns:read`

Get release readiness

Readiness for a release: counts of verified, pending, blocked and new test cases across its builds. Requires the `testruns:read` scope.

#### `GET /releases` — requires `releases:read`

List releases

Returns a paginated list of a project's releases, newest first.

**Project resolution (D7):** a project-scoped key lists its own project. An org-scoped key MUST send `project_key` (project UUID or project_code) — there is no default project (400 without one).

Optional `status` filter: `planned`/`active`/`completed`.

#### `POST /releases` — requires `releases:create`

Create a release

Creates a new release and returns `201 Created` with a `Location` header.

**Project resolution (D7):** a project-scoped key creates in its own project; an org-scoped key MUST send `project_key`.

**Status:** `planned` (default) or `active`. `completed` is refused — creating an already-completed release is completing one, and completing is human-only (400 `details.reason: release_status_not_allowed`).

**Names:** duplicate names (case-insensitive) are refused with **409** `details.reason: release_name_taken`. Unlike builds, releases are never auto-suffixed.

**Idempotency:** an `Idempotency-Key` header retries safely — an identical retry returns `200` with the original release (no second insert); the same key with a different body returns `409`.

**Dates:** `target_date` must be today or later.

#### `GET /releases/{id}` — requires `releases:read`

Get a release

Returns a release with its `build_count` (non-deleted builds currently in the release). A project-scoped key only sees its own project's releases; anything else is a 404. There is no endpoint that updates a release's status or deletes it — completing and deleting are human-only.

### Discovery

#### `GET /events`

List webhook event types

Lists the webhook event types and a human-readable description of each payload. No authentication; this endpoint cannot fail.

#### `GET /whoami`

Get current API key metadata

Returns metadata for the API key used to authenticate the request. Any valid API key can call this endpoint.

#### `GET /organizations` — requires `projects:read`

Get your organization

Returns the organization that owns the API key: plan, preview days left, lock state, billing and usage. A project-scoped key returns its parent organization.

#### `GET /openapi`

Get OpenAPI specification

Redirects to the static OpenAPI JSON spec at `/openapi.json`.

### Webhooks

#### `GET /webhooks` — requires `webhooks:manage`

List webhook subscriptions

Returns all active webhook subscriptions for the organization.

#### `POST /webhooks` — requires `webhooks:manage`

Create webhook subscription

Register a new webhook endpoint. The signing secret is returned once — save it immediately.

#### `DELETE /webhooks/{id}` — requires `webhooks:manage`

Delete webhook subscription

Soft-deletes a webhook subscription. No further events will be delivered.


---

## Quick Reference

- **Base URL:** `https://lathe.studio/api/v1`
- **Auth:** `Authorization: Bearer pt_live_<64-hex-chars>`
- **Content-Type:** `application/json` for all request bodies
- **HTTPS only:** HTTP requests are rejected

---

## Scopes Cheatsheet

| Scope | Use When | Example Endpoint |
|-------|----------|-----------------|
| `builds:read` | Read build records | `GET /builds` |
| `builds:create` | Create builds via API | `POST /builds` |
| `builds:update` | Place a build into/out of a release | `PATCH /builds/{id}` |
| `releases:read` | Read releases and their build counts | `GET /releases`, `GET /releases/{id}` |
| `releases:create` | Create releases | `POST /releases` |
| `testruns:read` | Read test runs and results | `GET /testruns`, `GET /testruns/{id}` |
| `testruns:create` | Create test runs | `POST /testruns` |
| `testruns:assign` | Assign, queue, or unassign runs; required with `assignee_id`/`assignee_email`/`queue` on create | `POST /testruns/{id}/assign` |
| `results:read` | Read execution results | Not yet live |
| `results:submit` | Submit results to existing run or ingest | `POST /testruns/{id}/results`, `POST /results/ingest` |
| `projects:read` | List projects and test cases; read the custom field schema | `GET /projects`, `GET /projects/{id}/testcases`, `GET /projects/{id}/custom-fields` |
| `webhooks:manage` | Manage webhook subscriptions | `GET /webhooks`, `POST /webhooks`, `DELETE /webhooks/{id}` |

**Principle of least privilege:** Create separate keys per use case.
A CI pipeline that only creates builds needs `builds:create` — not `projects:read`.

---

## CI/CD Pipeline Recipes

### GitHub Actions

```yaml
name: Lathe Studio Integration

on:
  push:
    branches: [main]

jobs:
  register-build:
    runs-on: ubuntu-latest
    steps:
      - name: Register build with Lathe Studio
        run: |
          curl -X POST https://lathe.studio/api/v1/builds \
            -H "Authorization: Bearer ${{ secrets.LATHE_API_KEY }}" \
            -H "Content-Type: application/json" \
            -H "Idempotency-Key: build-${{ github.run_id }}" \
            -d '{
              "name": "${{ github.ref_name }}",
              "build_number": "${{ github.run_number }}",
              "commit_sha": "${{ github.sha }}",
              "branch": "${{ github.ref_name }}",
              "repo_url": "${{ github.server_url }}/${{ github.repository }}"
            }'
```

### GitLab CI

```yaml
register-build:
  stage: deploy
  image: curlimages/curl:latest
  script: |
    curl -X POST https://lathe.studio/api/v1/builds \
      -H "Authorization: Bearer $LATHE_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: build-$CI_PIPELINE_ID" \
      -d "{
        \"name\": \"$CI_COMMIT_REF_NAME\",
        \"build_number\": \"$CI_PIPELINE_ID\",
        \"commit_sha\": \"$CI_COMMIT_SHA\",
        \"branch\": \"$CI_COMMIT_REF_NAME\",
        \"repo_url\": \"$CI_PROJECT_URL\"
      }"
```

### Jenkins

```groovy
pipeline {
  agent any
  stages {
    stage('Register Build') {
      steps {
        sh '''
          curl -X POST https://lathe.studio/api/v1/builds \
            -H "Authorization: Bearer ${LATHE_API_KEY}" \
            -H "Content-Type: application/json" \
            -H "Idempotency-Key: build-${BUILD_NUMBER}" \
            -d "{
              \"name\": \"${BRANCH_NAME}\",
              \"build_number\": \"${BUILD_NUMBER}\",
              \"commit_sha\": \"${GIT_COMMIT}\",
              \"branch\": \"${BRANCH_NAME}\"
            }"
        '''
      }
    }
  }
}
```

### Azure DevOps

```yaml
steps:
- script: |
    curl -X POST https://lathe.studio/api/v1/builds \
      -H "Authorization: Bearer $(LATHE_API_KEY)" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: build-$(Build.BuildId)" \
      -d "{
        \"name\": \"$(Build.SourceBranchName)\",
        \"build_number\": \"$(Build.BuildId)\",
        \"commit_sha\": \"$(Build.SourceVersion)\",
        \"branch\": \"$(Build.SourceBranchName)\"
      }"
  displayName: 'Register build with Lathe Studio'
```

### Bitbucket Pipelines

```yaml
pipelines:
  default:
    - step:
        name: Register Build
        script:
          - |
            curl -X POST https://lathe.studio/api/v1/builds \
              -H "Authorization: Bearer $LATHE_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: build-$BITBUCKET_BUILD_NUMBER" \
              -d "{
                \"name\": \"$BITBUCKET_BRANCH\",
                \"build_number\": \"$BITBUCKET_BUILD_NUMBER\",
                \"commit_sha\": \"$BITBUCKET_COMMIT\",
                \"branch\": \"$BITBUCKET_BRANCH\"
              }"
```

---

## Common Patterns

### Idempotency

Include an `Idempotency-Key` header on `POST /builds` to safely retry without creating duplicates (more endpoints gain support over time — see the API errors docs):

```http
Idempotency-Key: my-pipeline-run-12345
```

Keys expire after 24 hours. If the same key is reused with a different body, a `409 Conflict` is returned (`details.reason: idempotency_key_reused`); a request with the same key still in flight returns `409` with `details.reason: idempotency_request_in_progress`.

### Retry Strategy

Use exponential backoff with jitter on `429` and `5xx` responses:

```python
import random
import time

wait = min(2 ** attempt + random.uniform(0, 0.5), 60)
time.sleep(wait)
```

Recommended: 5 max attempts, 60s max wait, 0–500ms jitter.

### Polling vs Webhooks

| Approach | When to Use | Pattern |
|----------|-------------|---------|
| **Polling** | Quick scripts, one-off checks | `GET /testruns/{id}` — cache for 30s |
| **Webhooks** | Production pipelines, real-time updates | Subscribe to `testrun.completed.v1` |

Webhooks are included on every plan; an org admin sets them up under Settings → Integrations.

### Duplicate Build Names

`POST /builds` never refuses a name. If a build with the same name (case-insensitive) already exists in the target release bucket, the new build is created with a suffix — "v2.4.1", then "v2.4.1 (2)", "v2.4.1 (3)" — and the final name is returned in the response `name`. The same name can be reused freely across different releases.

### Custom Fields

Project custom fields are **project-level and case-only**: each project defines its own fields (in Project Settings), and only test cases carry values. The API reads the schema and reads/writes values — it cannot create or edit definitions.

1. Fetch the schema: `GET /projects/{id}/custom-fields` → active definitions in display order (`key`, `label`, `field_type`, `options` as labels, `position`).
2. Read values: `GET /projects/{id}/testcases` (and `GET …/testcases/{tcId}`) include `custom_fields` per case.
3. Write values: `POST`/`PUT /projects/{id}/testcases[/tcId]` accept `custom_fields` in the wire format — an object keyed by field **key**, `select` values as option **labels** (`{ "severity": "High" }`), `multi_select` as an array of labels, `checkbox` boolean, `number` finite number, `date` `"YYYY-MM-DD"`. `PUT` **merges**: unspecified keys keep their stored values, an explicit `null` clears a key.

### Gherkin format

Create a Gherkin case with `test_case_type: "gherkin"` and `gherkin_content` in `POST /projects/{id}/testcases`. Update the text with `PUT /projects/{id}/testcases/{tcId}`. The API returns `gherkin_content` on list and detail reads. Do not send `steps` with Gherkin text: Lathe derives one step from each Given, When, Then, And, or But line on save. Syntax errors return 400 with a line number. Existing `@tags` must match the project's coverage tags by name. Scenario Outlines are supported: each `Examples` row runs as its own block of steps, with `<placeholders>` substituted.

Write failures are `400`; branch on `details.reason`:

| `details.reason` | Meaning |
|------------------|---------|
| `custom_field_unknown` | Key is not an active field of this project (archived counts as unknown). `details.field` = the key. |
| `custom_field_invalid` | Value doesn't fit the type or isn't an option label. `details.field` = the key. |
| `custom_fields_not_object` | `custom_fields` is not a JSON object. |

Values for archived definitions are omitted from reads — the API describes the current schema.

---

## Error Resolution Matrix

Every error is `{ "error": { "code", "message", "details"? } }`. Branch on `error.code`, then on `error.details.reason` where present; the code for a given failure never changes.

| Status | Error Code | Cause | Fix | Retry? |
|--------|-----------|-------|-----|--------|
| 400 | `BAD_REQUEST` / `VALIDATION_ERROR` | Malformed parameter or body, or a failed rule (`details.reason` says which) | Read `details.field` / `details.reason` | No |
| 400 | `INVALID_JSON` | Body is not valid JSON | Fix the body | No |
| 401 | `AUTH_REQUIRED` | Missing, malformed, or revoked API key | Verify `Authorization: Bearer pt_live_...` format | No |
| 403 | `FORBIDDEN` | Key lacks the scope or is scoped to another project; or the org is locked (preview ended or cancelled subscription: every call is refused) | New key with the right scope; for a lock, an org admin subscribes in Settings → Billing | No |
| 404 | `RESOURCE_NOT_FOUND` | Resource doesn't exist or belongs to a different org | Verify the ID belongs to your organization | No |
| 409 | `CONFLICT` | Idempotency key reused with a different body (`idempotency_key_reused`), same key still in flight (`idempotency_request_in_progress`), or the release is completed (`release_completed`; `POST /builds` says `completed_release`) | New key or same body; wait and retry for in-flight; no retry succeeds once a release is completed | Only `idempotency_request_in_progress` |
| 413 | `PAYLOAD_TOO_LARGE` | Body over the endpoint's limit | Split the request | No |
| 422 | `VALIDATION_ERROR` | Semantic validation failure | Check `details` for the invalid value | No |
| 429 | `RATE_LIMITED` | Per-minute limit or monthly quota hit | Wait until `X-RateLimit-Reset` (Unix seconds) | Yes (exponential backoff) |
| 500 | `INTERNAL_ERROR` | Unexpected server error | Retry with backoff; contact support if persistent | Yes (exponential backoff) |

---

## Webhook Integration

### Signature Verification

**Node.js:**

```typescript
import crypto from 'crypto'

function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string,
  secret: string
): boolean {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')

  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signatureHeader)
    )
  } catch {
    return false
  }
}
```

**Python:**

```python
import hmac
import hashlib

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

**Go:**

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func verifySignature(rawBody []byte, signatureHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
```

Always use **timing-safe comparison** to prevent timing attacks.

### Retry Schedule

| Attempt | Delay |
|---------|-------|
| 1 (initial) | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |

After 5 failed attempts, the delivery is marked `failed` and appears in the dead-letter queue.

### Dead Letter Queue

Failed deliveries are visible in **Organization Settings → Webhooks → Delivery Log**.
Org admins can manually retry failed deliveries from this UI.

---

## Version Compatibility

This skill covers API v1.x. Breaking changes trigger a **6-month deprecation window**:

1. Deprecation notice published in the [API Changelog](https://lathe.studio/docs/resources/changelog)
2. Deprecated behavior remains functional for 6 months
3. Sunset date published 60 days in advance
4. Old behavior removed in a minor version increment

Adding new endpoints, scopes, or response fields is **not** a breaking change.

---
