# Content items (/docs/api/concepts/content-items)



A content item - or, to use the field name, a **content container** - is one creative. It holds the media, the captions, the hook, the format, the influencer that voiced it (when generated), and the approval state. When we talk about "a piece of content" in this API, we mean a container.

Containers come from two origins, distinguished by `creativeType`:

* `generated` — Layers produced the media. Created by the generate endpoints below.
* `uploaded` — you supplied finished media via one of the upload transports. Uploaded containers arrive `completed` (no job to poll), publish byte-for-byte, and are born `adsEnrollment: "opted_out"`.

Everything downstream — approval, scheduling, publishing — treats both origins the same.

Generation is async. You `POST` a `format` and a `hook`, you get back a `202` envelope with a `jobId` and a `containerIds[]` array (one entry per variant — see [Variants](#variants) below), and you poll either the [job envelope](/docs/api/concepts/jobs) for progress or each container for the final shape.

## Lifecycle [#lifecycle]

Container status values (from the `ContainerStatus` enum):

* `queued` - job accepted, not yet running.
* `processing` - generation job is running. Media assets are empty until complete.
* `completed` - generation finished. Media and captions are populated. If the project's [approval policy](/docs/api/concepts/content-review) requires review, the container's `approvalStatus` will be `pending`.
* `failed` - generation failed. Check `lastError` on the container.
* `canceled` - container was canceled (by explicit cancel or project archive).

Approval status is independent of container status - it lives on `approvalStatus` and takes values `not_required`, `pending`, `approved`, or `rejected`.

## Creating one [#creating-one]

```http
POST /v1/projects/:projectId/content
Content-Type: application/json
Authorization: Bearer $LAYERS_API_KEY
Idempotency-Key: 7c2f1a3e-0b4c-4a11-9f7e-33c0a2c1bd55

{
  "format": "slideshow-builder",
  "variantCount": 1,
  "hook": "wait for it...\nthis simple habit changed everything about my mindset 🧠",
  "references": {
    "mediaIds": ["med_01HX9Y6K7EJ4T2ABCDEF"]
  }
}
→ 202
{
  "jobId": "job_01HX9Y6K7EJ4T2ABCDEF01234",
  "kind": "content_generate",
  "status": "running",
  "stage": "queued",
  "projectId": "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
  "containerIds": ["cnt_7d18b9a1-8b2c-4f3e-a4d5-6e7f8a9b0c1d"],
  "format": "slideshow-builder",
  "locationUrl": "/v1/jobs/job_01HX9Y6K7EJ4T2ABCDEF01234",
  "startedAt": "2026-04-18T12:04:11.000Z"
}
```

<Callout>
  Pair `Idempotency-Key` with every generate call. A retry inside the replay window returns the original `202`; a conflicting body returns `409 IDEMPOTENCY_CONFLICT` rather than billing you for a duplicate job. **Don't** pass `id` in the body — the API mints all resource ids. See [Idempotency](/docs/api/operational/idempotency).
</Callout>

### Formats [#formats]

Layers internally produces four content formats. Two are partner-callable in v1; the other two are documented here so partners can plan against the full surface, but currently return `422 UNSUPPORTED_FORMAT`.

`format` is **required** at the partner surface. Pick one of the supported types — there is no `auto` fallback. Each format has its own preconditions; if they aren't met the call returns a structured `422` rather than silently substituting a different format.

| `format`            | What it produces                                                                          | Required preconditions                                                                                        | Status                                                          |
| ------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `slideshow-builder` | Multi-image vertical slideshow built from brand context (9:16).                           | Project has `app_name` + `app_description`.                                                                   | **Available.**                                                  |
| `ugc-remix`         | UGC video remixed from a partner-uploaded app-demo clip + Layers' reaction-template pool. | Project has at least one [media-library](/docs/api/concepts/media-library) row with `media_role: "app-demo"`. | **Available** once asset upload (A27) ships.                    |
| `video-remix`       | Short-form video remixed from a discovered third-party source clip.                       | Source-post selection endpoints not yet exposed to partners.                                                  | **Reserved (v2).** See "Roadmap: source-coupled formats" below. |
| `slideshow-remix`   | Image slideshow remixed from a discovered third-party slideshow source.                   | Source-post selection endpoints not yet exposed to partners.                                                  | **Reserved (v2).** See "Roadmap: source-coupled formats" below. |

Partner-asked formats with no internal equivalent return `422 UNSUPPORTED_FORMAT`: `image`, `carousel`, `short_form_video`. (Carousels are an Instagram distribution concept; our `slideshow-builder` outputs render as carousels at distribution time.)

#### Roadmap: source-coupled formats [#roadmap-source-coupled-formats]

`video-remix` and `slideshow-remix` need a **source post** as the basis for the remix. Internally Layers' UI lets a customer point at a TikTok video by ID, but the API integration doesn't yet expose the surface partners would need:

* A way to **discover candidate source posts** scoped to the project's brand or competitor set.
* A canonical contract for **passing a source-post identifier** on the generate call.

Until that endpoint pair lands, calling these formats returns `422 UNSUPPORTED_FORMAT` with a roadmap hint. There is no feature flag or scope that bypasses this — the surface simply isn't there yet. Track [the changelog](/docs/api/operational/changelog) for the unlock.

When preconditions fail, the response body carries the specific `MISSING_*` reason — see [Errors](/docs/api/operational/errors):

| Code                      | Cause                                                                                                                                                                                                           |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNSUPPORTED_FORMAT`      | Format name unknown or reserved for v2.                                                                                                                                                                         |
| `MISSING_APP_NAME`        | `slideshow-builder` requires `appName` on the project. Set it via [`POST /v1/projects`](/docs/api/reference/projects/create-project) or [`PATCH /v1/projects/:id`](/docs/api/reference/projects/patch-project). |
| `MISSING_APP_DESCRIPTION` | `slideshow-builder` requires `projects.app_description`.                                                                                                                                                        |
| `MISSING_APP_DEMO`        | `ugc-remix` requires a `media_role: "app-demo"` row in the project's [media library](/docs/api/concepts/media-library).                                                                                         |

### Variants [#variants]

`variantCount > 1` returns N independent containers — same `hook`, parallel jobs, fan-out billing (N × per-variant cost). Default `1`. The `containerIds[]` array carries every container in document order; each one polls independently via [`GET /v1/content/:containerId`](/docs/api/reference/content/get-container) or via the shared `jobId`.

## Reading a container [#reading-a-container]

```http
GET /v1/content/:containerId
→ 200
{
  "id": "cnt_7d18b9a1-8b2c-4f3e-a4d5-6e7f8a9b0c1d",
  "projectId": "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
  "status": "completed",
  "approvalStatus": "approved",
  "format": "slideshow-builder",
  "influencerId": "inf_4a8e1bc2-3d4f-46a8-9b0c-1d2e3f4a5b6c",
  "hook": "wait for it...\nthis simple habit changed everything about my mindset 🧠",
  "preview": {
    "kind": "slideshow",
    "primaryUrl": "https://media.layers.com/.../slide-01.jpg",
    "thumbnailUrl": "https://media.layers.com/.../slide-01.jpg",
    "imageUrls": [
      "https://media.layers.com/.../slide-01.jpg",
      "https://media.layers.com/.../slide-02.jpg",
      "https://media.layers.com/.../slide-03.jpg"
    ],
    "aspectRatio": "9:16"
  },
  "assets": [
    {
      "assetId": "ast_01HX9Y6K7EJ4T2ABCDEF",
      "kind": "image",
      "url": "https://media.layers.com/.../slide-01.jpg",
      "width": 1080,
      "height": 1920
    }
  ],
  "captions": [
    { "platform": "tiktok", "text": "..." }
  ],
  "lastError": null,
  "createdAt": "2026-04-18T12:04:11.000Z",
  "updatedAt": "2026-04-18T12:07:33.000Z"
}
```

The `preview` object is the canonical "render this in my UI" surface — partners should not infer media type from `assets[]` shape. See [Preview object](/docs/api/concepts/preview-object) for the full per-`kind` field-population matrix.

Asset descriptors use `assetId` (not `id`). Media URLs on `assets[].url` are long-lived CDN paths. For direct asset addressing, use [`GET /v1/content/:id/assets/:assetId`](/docs/api/reference/content/get-asset).

## Rejecting [#rejecting]

* `POST /v1/content/:id/reject` — reject through the [approval gate](/docs/api/concepts/content-review). To produce a new take, call `POST /v1/projects/:id/content` again with a fresh `hook` — generation is fast; we don't keep a separate "regenerate same container" surface.

## Listing [#listing]

```http
GET /v1/projects/:projectId/content?status=completed&format=slideshow-builder&limit=25
```

Filters: `status`, `format`, `creativeType`, `since`, `until`, `cursor`, `limit`. Cursor-paginated. Pass `status=completed` when you're rendering a library view; combine with an approval query to drive your review UX. `creativeType=uploaded` scopes to your uploaded library. List rows carry the same [Preview object](/docs/api/concepts/preview-object) as `GET /v1/content/:id`, so a gallery can render straight from the list response.

## Progress during generation [#progress-during-generation]

For an in-flight container, both endpoints tell you the same story from different angles:

* `GET /v1/jobs/:jobId` - canonical progress. Use this by default.
* `GET /v1/content/:id/progress` - same shape, scoped to the container. Useful if you've lost the `jobId`.
