# Overview

## Layer REST API (v1) 1.0.0

REST API for AI-powered game asset creation on the Layer platform.

## Authentication

Every endpoint requires a Bearer token in the `Authorization` header, except the ones a client reads while deciding whether to integrate at all: `openapi.json`, `versioning`, and the interactive docs.

```
Authorization: Bearer <token>
```

### Personal Access Tokens (recommended)

The simplest way to authenticate is with a Personal Access Token (PAT):

1. Log in to [layer.ai](https://layer.ai/login)
2. Go to **Settings > Personal Access Tokens**
3. Click **Create Token**, give it a name, and copy the token value
4. Pass it as `Authorization: Bearer <your-pat-token>`

PATs are long-lived and ideal for scripts, CI pipelines, and partner integrations.

### OAuth2 (browser-based)

For browser-based applications, use the Auth0 OAuth2 flow. Redirect users to the Layer login page, exchange the authorization code for a JWT access token, and use it as `Authorization: Bearer <jwt>`.

## Creative Units

Generations consume Creative Units (CUs). Use the estimate endpoints to check cost before executing. Check workspace balance via `GET /v1/workspaces/{id}`.

## Pagination

List endpoints support cursor-based pagination via `limit` and `cursor` query parameters. Responses include a `pagination` object with `next_cursor`, `has_more_results`, and `total_count`. Cursors are opaque — do not parse or construct them.

## Errors

All errors return [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) with content type `application/problem+json`:

```
{
  "type": "https://api.layer.ai/errors/ERROR_CODE",
  "title": "Human-readable title",
  "status": 404,
  "detail": "Detailed description of what went wrong.",
  "error_code": "ERROR_CODE"
}
```

Branch on `error_code` (or `type`), not on `title` or `detail`: those are prose and may be reworded. `type` is a stable opaque identifier whose last path segment is `error_code`; it is not a live URL and does not resolve to a page. Documentation lives at [layer.ai/docs](https://layer.ai/docs).

Common status codes: `401` (unauthenticated), `403` (forbidden), `404` (not found), `422` (invalid input), `429` (rate limited).

Creative Units are not checked when a run is submitted, so an underfunded workspace does not fail at submission time. Call the estimate endpoint and read `has_sufficient_creative_units` to check up front; otherwise the run is accepted and then reaches a terminal `FAILED` state with `error_code` `INSUFFICIENT_BALANCE`.

Guardrails and provider content policies are also judged after submission, so a refused generation is never a `4xx`: the run reaches `FAILED` with a refusal `error_code`, and its `refusal` object (`refusals` on a workflow run) names the guardrail that blocked it and its category.

## Idempotency

A lost response is indistinguishable from a request that never arrived, and retrying a generation spends Creative Units again. Send an idempotency key on any unsafe request so a retry returns the first result instead of executing again, within the limits below:

```
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
```

The first request with a given key executes. A later request with the same key returns that first response, with `Idempotent-Replayed: true` added, without executing anything, for as long as the rules below keep it. Any opaque string up to 255 characters works; a UUID per logical operation is the usual choice. Keys are scoped to your credential’s owner.

A retry is the same request: same method, same path, same query parameters (in any order), and a byte-identical body. The body is compared as raw bytes, not as parsed JSON, so keep the bytes you sent and resend them; re-serializing the same object can change whitespace or key order and turn a retry into a different request.

What a retry with the same key gets depends on what the server did with the first request, which a lost response does not tell you. Retry with the same key and the server answers from whichever case applies:

* **It succeeded (`2xx`).** The response is stored and a retry replays it for 24 hours, even if the original response never reached you. Storing is best effort: a response too large to store, or one the store could not accept, is covered only by the 120-second claim below.
* **It was rejected (`4xx`).** Nothing was spent and the key is released, so a corrected retry executes.
* **It is still running, or it failed (`5xx`).** When the first request is received, its key is claimed for 120 seconds, and the claim is not extended while the request runs. A retry inside that window returns `409` `IDEMPOTENT_REQUEST_IN_PROGRESS` with `Retry-After`; wait that long and try again. A `5xx` may already have spent Creative Units, so its key is neither stored nor released and stays claimed until the 120 seconds run out.

A `409` therefore means the outcome is not known yet: the original may still be running, or it may have failed after spending. Keep retrying through `409` with the same key to get the stored response once the original succeeds. Once the 120 seconds run out without a stored response, the next retry with the same key executes as a new request: it spends again, and it may run alongside an original that is still in flight. Decide before that point whether a second run is acceptable.

Idempotency is best effort throughout, not only for stored responses. If the key store cannot be reached when a request arrives, the request runs without protection, and a claim or stored response can disappear before its time is up. An earlier `409` or replay therefore does not guarantee that a later retry cannot execute again.

A different request under a key that is still claimed or stored returns `422` `IDEMPOTENCY_KEY_REUSED`. The key covers the request it was first used with, so this is a client bug rather than a retry, and returning some other request’s result would be worse than an error.

Keys on safe methods are ignored: `GET` is already repeatable.

## Rate limits

Quotas are per user and apply to the compute operations that spend Creative Units: starting a generation and starting a training run.

Every response describes the quotas in force, including one that rejected you for having no credential — a client that has not authenticated yet is the one that most needs to know what budget it will be planning against:

```
RateLimit-Policy: "generation";q=60;w=60, "generation-video";q=30;w=60, "training";q=10;w=3600
```

A quota named after a modality narrows the one it extends: a video generation spends from both `generation` and `generation-video`, so the tighter of the two decides.

Once you are authenticated, responses from the operations a quota governs also report what is left of it:

```
RateLimit: "generation";r=57;t=3
RateLimit-Limit: 60
RateLimit-Remaining: 57
RateLimit-Reset: 3
```

`RateLimit-Policy` and `RateLimit` are the standard structured fields — `q` is the quota, `w` the window in seconds, `r` the units left, `t` the seconds until the quota is fully restored. The `RateLimit-Limit`/`-Remaining`/`-Reset` triple repeats the tightest quota for clients that parse the older convention.

Exceeding a quota returns `429` with a `Retry-After` header; wait that many seconds before retrying.

## Versioning and deprecation

The API version is the first path segment. `/v1` and `/v2` are both stable and served together; v1 routes are unaffected by v2.

`/v2` is a complete surface — every resource is reachable under it, so a client can pin `/v2` and never fall back. It differs from v1 in the generation surface: inference runs take reference sets — trained subjects, styles, and characters — alongside a base model, and the v1 `/models` catalog is split into `/base-models` and `/reference-sets`.

Every other resource is the same endpoint under both prefixes: identical handler, request body, and response shape. Their v2 operation ids carry a `V2` suffix so generated clients get one symbol per published path.

Backward-compatible additions — a new endpoint, a new optional request field, a new response field — ship into an existing version without notice. Anything that could break a client gets a new version instead.

Nothing is withdrawn without notice. When an operation or a version is deprecated:

1. It is marked `deprecated: true` in `openapi.json`.
2. Every response from it carries `Deprecation`([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) with the date it was deprecated, `Sunset` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) with the earliest date it may stop working, and a `Link` header with `rel="deprecation"` to this policy and `rel="successor-version"` to its replacement.
3. It keeps working until the sunset date, which is at least 90 days after the deprecation date.

Poll `openapi.json` or watch for `Deprecation` on your own traffic — both carry the same timeline.

## Webhooks

Long-running requests can push their result to you instead of being polled for it. Pass a `webhook` object on `POST /v2/.../inferences`, `POST /v1/.../training-runs`, `POST /v1/.../scores`, or `POST /v1/.../workflows/{workflow_id}/runs`(`POST /v1/.../inferences` is poll-only — move to `/v2` to be pushed results):

```
{
  "webhook": {
    "url": "https://example.com/hooks/layer",
    "metadata": {"job": "hero-splash-42"}
  }
}
```

Every response to those endpoints carries `expected_events` — the deliveries that request will produce. Wait for those and no others: a generation in a workspace with automatic scoring rules produces a second, separate event for its scores, and one without produces nothing further.

### The payload

Every delivery has the same envelope, and `data` is exactly what the matching `GET` endpoint returns for that job — no follow-up request is needed.

```
{
  "id": "evt_7c9e6679…",
  "type": "inference.completed",
  "created_at": "2026-08-25T09:41:02Z",
  "data": { "inference_id": "…", "status": "complete", "outputs": [ … ] },
  "metadata": {"job": "hero-splash-42"}
}
```

`data` only ever gains fields. Ignore ones you do not recognise rather than rejecting the body.

### Verifying a delivery

Deliveries carry [Standard Webhooks](https://www.standardwebhooks.com) headers, so any Standard Webhooks library verifies them with no code of your own:

* `webhook-id` — the event id, stable across retries
* `webhook-timestamp` — unix seconds when the attempt was signed
* `webhook-signature` — one or more space-delimited `v1,<base64>` signatures

Verifying by hand: HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed by the base64-decoded secret (without its `whsec_` prefix), compared in constant time. Sign the **raw request body** — parsing and re-serializing produces different bytes and will not verify.

More than one signature means a rotation is in its grace window; a delivery is authentic if **any** signature matches. Read the secret from `GET /v1/workspaces/{workspace_id}/webhook-secret`and rotate it with `POST /v1/workspaces/{workspace_id}/webhook-secret/rotate`.

Reject deliveries whose `webhook-timestamp` is far from now — a few minutes’ tolerance — so a captured delivery cannot be replayed at you later.

### Responding

Answer with any 2xx within **10 seconds**. The body is ignored. Acknowledge first and do your work afterwards; a receiver that finishes its processing before answering will time out.

* **Any non-2xx, or no response** — retried.
* **`429`** — retried, honouring `Retry-After` (in seconds) where it asks for longer.
* **`410 Gone`** — delivery stops permanently. This is how you retire an endpoint.
* **Redirects are not followed.** A `3xx` counts as a failure.

### Retries and duplicates

Five attempts over roughly an hour: immediately, then after \~10s, \~1m, \~10m and \~45m. If they are all exhausted, nothing is lost — the job’s `GET` endpoint returns the same body for as long as the job exists, which is what makes a short retry window safe.

Delivery is **at-least-once and unordered**. Deduplicate on `webhook-id`, which does not change between attempts of the same event, and use `created_at` to order events for one job. In particular `inference.completed` and its `scoring_run.completed` can arrive in either order.

Every attempt is recorded on the job itself: `GET` the run and read its `webhook` block for the attempt count, the receiver’s last status code, and the last error.

### Before you go live

`POST /v1/workspaces/{workspace_id}/webhook-tests` sends a signed sample to any URL and tells you synchronously what came back — reachability, latency, status code. It costs nothing and creates no run, so it is the cheapest way to check a tunnel or a signature check.

Callback URLs must be public HTTPS endpoints. `http://`, private addresses and `localhost` are rejected when the request is submitted, so use a tunnel while developing.

Information

* OpenAPI version: `3.1.0`

## Operations

GET

[/v1/workspaces/{workspace\_id}/models](/docs/v1/rest-api/operations/listmodels)

GET

[/v1/workspaces/{workspace\_id}/models/{model\_id}](/docs/v1/rest-api/operations/getmodel)

POST

[/v1/workspaces/{workspace\_id}/inferences/estimate](/docs/v1/rest-api/operations/estimateinferenceprice)

POST

[/v1/workspaces/{workspace\_id}/inferences](/docs/v1/rest-api/operations/executeinference)

GET

[/v1/workspaces/{workspace\_id}/inferences/{inference\_id}](/docs/v1/rest-api/operations/getinferencerun)

POST

[/v1/workspaces/{workspace\_id}/inferences/{inference\_id}/cancel](/docs/v1/rest-api/operations/cancelinference)

POST

[/v1/workspaces/{workspace\_id}/reference-sets](/docs/v1/rest-api/operations/createreferenceset)

POST

[/v1/workspaces/{workspace\_id}/training-runs/estimate](/docs/v1/rest-api/operations/estimatetrainingprice)

GET

[/v1/workspaces/{workspace\_id}/training-runs](/docs/v1/rest-api/operations/listtrainingruns)

POST

[/v1/workspaces/{workspace\_id}/training-runs](/docs/v1/rest-api/operations/starttrainingrun)

GET

[/v1/workspaces/{workspace\_id}/training-runs/{training\_run\_id}](/docs/v1/rest-api/operations/gettrainingrun)

POST

[/v1/workspaces/{workspace\_id}/training-runs/{training\_run\_id}/cancel](/docs/v1/rest-api/operations/canceltrainingrun)

GET

[/v1/workspaces/{workspace\_id}/training-runs/{training\_run\_id}/model](/docs/v1/rest-api/operations/gettrainedmodel)

POST

[/v1/workspaces/{workspace\_id}/files/upload-url](/docs/v1/rest-api/operations/requestfileuploadurl)

GET

[/v1/workspaces/{workspace\_id}/scoring-rules](/docs/v1/rest-api/operations/listscoringrules)

POST

[/v1/workspaces/{workspace\_id}/scores](/docs/v1/rest-api/operations/scorefiles)

GET

[/v1/workspaces/{workspace\_id}/scoring-runs/{scoring\_run\_id}](/docs/v1/rest-api/operations/getscoringrun)

GET

[/v1/workspaces/{workspace\_id}/files/{file\_id}/scores](/docs/v1/rest-api/operations/getfilescores)

GET

[/v1/workspaces/{workspace\_id}/workflows](/docs/v1/rest-api/operations/listworkflows)

POST

[/v1/workspaces/{workspace\_id}/workflows/{workflow\_id}/estimate](/docs/v1/rest-api/operations/estimateworkflowprice)

POST

[/v1/workspaces/{workspace\_id}/workflows/{workflow\_id}/runs](/docs/v1/rest-api/operations/executeworkflow)

GET

[/v1/workspaces/{workspace\_id}/workflows/{workflow\_id}/runs/{run\_id}](/docs/v1/rest-api/operations/getworkflowrun)

GET

[/v1/workspaces/{workspace\_id}/webhook-secret](/docs/v1/rest-api/operations/getwebhooksecret)

POST

[/v1/workspaces/{workspace\_id}/webhook-secret/rotate](/docs/v1/rest-api/operations/rotatewebhooksecret)

POST

[/v1/workspaces/{workspace\_id}/webhook-tests](/docs/v1/rest-api/operations/testwebhook)

GET

[/v1/workspaces](/docs/v1/rest-api/operations/listworkspaces)

GET

[/v1/workspaces/{workspace\_id}](/docs/v1/rest-api/operations/getworkspace)

GET

[/v1/workspaces/{workspace\_id}/usage](/docs/v1/rest-api/operations/getworkspaceusage)

GET

[/v1/workspaces/{workspace\_id}/projects](/docs/v1/rest-api/operations/listprojects)

POST

[/v1/workspaces/{workspace\_id}/projects](/docs/v1/rest-api/operations/createproject)

GET

[/v1/workspaces/{workspace\_id}/projects/{project\_id}](/docs/v1/rest-api/operations/getproject)

DELETE

[/v1/workspaces/{workspace\_id}/projects/{project\_id}](/docs/v1/rest-api/operations/deleteproject)

PATCH

[/v1/workspaces/{workspace\_id}/projects/{project\_id}](/docs/v1/rest-api/operations/updateproject)

POST

[/v1/workspaces/{workspace\_id}/projects/{project\_id}/members](/docs/v1/rest-api/operations/addprojectmembers)

DELETE

[/v1/workspaces/{workspace\_id}/projects/{project\_id}/members/{user\_id}](/docs/v1/rest-api/operations/removeprojectmember)

GET

[/v1/workspaces/{workspace\_id}/members](/docs/v1/rest-api/operations/listworkspacemembers)

POST

[/v1/workspaces/{workspace\_id}/members](/docs/v1/rest-api/operations/inviteworkspacemember)

PATCH

[/v1/workspaces/{workspace\_id}/members/{user\_id}](/docs/v1/rest-api/operations/updateworkspacemember)

GET

[/v1/workspaces/{workspace\_id}/groups](/docs/v1/rest-api/operations/listgroups)

POST

[/v1/workspaces/{workspace\_id}/groups](/docs/v1/rest-api/operations/creategroup)

DELETE

[/v1/workspaces/{workspace\_id}/groups/{group\_id}](/docs/v1/rest-api/operations/deletegroup)

PATCH

[/v1/workspaces/{workspace\_id}/groups/{group\_id}](/docs/v1/rest-api/operations/updategrouprole)

GET

[/v1/workspaces/{workspace\_id}/groups/{group\_id}/members](/docs/v1/rest-api/operations/listgroupmembers)

POST

[/v1/workspaces/{workspace\_id}/groups/{group\_id}/members](/docs/v1/rest-api/operations/addgroupmember)

DELETE

[/v1/workspaces/{workspace\_id}/groups/{group\_id}/members/{user\_id}](/docs/v1/rest-api/operations/removegroupmember)

PUT

[/v1/workspaces/{workspace\_id}/groups/{group\_id}/usage-limit](/docs/v1/rest-api/operations/setgroupusagelimit)

DELETE

[/v1/workspaces/{workspace\_id}/groups/{group\_id}/usage-limit](/docs/v1/rest-api/operations/removegroupusagelimit)

POST

[Inference Event](/docs/v1/rest-api/webhooks/inference%5Feventinference%5Fcompleted%5Fpost) WEBHOOK

POST

[Training Event](/docs/v1/rest-api/webhooks/training%5Feventtraining%5Fsucceeded%5Fpost) WEBHOOK

POST

[Scoring Run Event](/docs/v1/rest-api/webhooks/scoring%5Frun%5Feventscoring%5Frun%5Fcompleted%5Fpost) WEBHOOK

POST

[Workflow Run Event](/docs/v1/rest-api/webhooks/workflow%5Frun%5Feventworkflow%5Frun%5Fsucceeded%5Fpost) WEBHOOK

## Authentication

### bearerAuth

Bearer authentication with a Layer Personal Access Token (PAT), prefixed `pat_`. Send it as `Authorization: Bearer <token>`. Create one in the app under Settings → Personal Access Tokens. An Auth0 session JWT is also accepted for first-party browser use.

**Security scheme type:** http

### oauth2

OAuth 2.0 sign-in for a person, against Layer’s authorization server. Discovery metadata for this resource is published at `https://api.app.layer.ai/.well-known/oauth-protected-resource` (RFC 9728), and the authorization server’s own metadata at `https://auth.app.layer.ai/.well-known/oauth-authorization-server` (RFC 8414). A person’s token carries no scope: it acts with the person’s role.

**Security scheme type:** oauth2

**Flow type:** authorizationCode

**Authorization URL:** <https://auth.app.layer.ai/authorize>

**Token URL:** <https://auth.app.layer.ai/oauth/token>

**Refresh URL:** <https://auth.app.layer.ai/oauth/token>

### serviceAccount

A service account’s access token, from the client credentials grant: the account’s id as `client_id`, one of its keys as `client_secret`. A key is also accepted directly as a bearer token. The account acts with its role, limited to the token’s scopes.

**Security scheme type:** oauth2

**Flow type:** clientCredentials

**Token URL:** <https://api.app.layer.ai/api/oauth/token>

**Scopes:**

* workspace:read \- Read workspace
* workspace:write \- Read and change workspace
* members:read \- Read members
* members:write \- Read and change members
* scim:read \- Read scim
* scim:write \- Read and change scim
* billing:read \- Read billing
* billing:write \- Read and change billing
* labels:read \- Read labels
* labels:write \- Read and change labels
* library:read \- Read library
* library:write \- Read and change library
* sessions:read \- Read sessions
* sessions:write \- Read and change sessions
* projects:read \- Read projects
* projects:write \- Read and change projects
* reference\_sets:read \- Read reference sets
* reference\_sets:write \- Read and change reference sets
* generation:read \- Read generation
* generation:write \- Read and change generation
* workflows:read \- Read workflows
* workflows:write \- Read and change workflows
* skills:read \- Read skills
* skills:write \- Read and change skills
* integrations:read \- Read integrations
* integrations:write \- Read and change integrations
* campaigns:read \- Read campaigns
* campaigns:write \- Read and change campaigns
