# Getting started (/docs/api/getting-started/overview)



This is the only guide you need to integrate the Layers API. Everything else under **Reference** is a deeper dive on a single endpoint you'll hit from here.

```text
1. POST  /v1/projects                                          sync   — create project (auto-starts keywords + first influencer)
2. POST  /v1/projects/:projectId/app-media                     sync   — (recommended) upload logo / screenshots / demo video
3. POST  /v1/projects/:projectId/social/oauth-url              sync   — (optional) connect a social account
4. GET   /v1/projects/:projectId/content/source-recommendations sync  — discover a TikTok source
   POST  /v1/projects/:projectId/content/slideshow-remix       async  — generate content
5. POST  /v1/content/:containerId/schedule                     sync   — (optional) schedule it
```

Steps 1 and 4 are the core: a project, a piece of content. Step 2 (app media) is optional but strongly recommended — without it, content generation has to imagine the customer's product instead of using real assets. Steps 3 and 5 are only needed when you want Layers to publish the content for you.

Two things kick off automatically when you provide `appDescription` on step 1:

* **Keyword research.** Layers' research agent curates the project's TikTok hashtag bank over 4–5 minutes. The bank is what makes step 4's TikTok discovery surface on-brand sources instead of generic results. Read it at `GET /v1/projects/:id/keywords` — `refreshedAt` is `null` until the first run finishes. Force a re-run with `POST /v1/projects/:id/keywords/refresh`.
* **First influencer.** Layers generates a persona (name, gender, visual identity) anchored on the project's brand context. The influencer appears as `status: "pending"` immediately and flips through `training` to `status: "ready"` after \~1 minute. Read it at `GET /v1/projects/:id/influencers`. You need at least one `ready` influencer before step 4 — wait for it, or create additional personas with `POST /v1/projects/:id/influencers`.

You'll need a Bearer key from Layers and a UUID library for `Idempotency-Key` headers. The examples assume an env var `LAYERS_API_KEY=lp_…`.

## Step 1 · Create the project [#step-1--create-the-project]

Sync. Returns 201 with the project record. Send everything you already know about the customer — the more context you put on the project, the better the downstream content quality.

<Tabs items="['curl', 'TypeScript']">
  <Tab value="curl">
    ```bash
    curl https://api.layers.com/v1/projects \
      -H "Authorization: Bearer $LAYERS_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "name": "Acme Coffee",
        "customerExternalId": "acme-coffee",
        "timezone": "America/Los_Angeles",
        "primaryLanguage": "en",
        "appName": "Acme Coffee",
        "appDescription": "Daily ritual coffee subscriptions for runners and night-shift workers. Single-origin beans from named farms, roasted weekly, and shipped on a cadence that matches how you actually drink coffee — so the bag never goes stale and you never run out before a hard workout.",
        "tagline": "Coffee that shows up before you run out.",
        "brandVoice": "warm",
        "targetGender": "all"
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="1-create-project.ts"
    const res = await fetch("https://api.layers.com/v1/projects", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": crypto.randomUUID(),
      },
      body: JSON.stringify({
        name: "Acme Coffee",
        customerExternalId: "acme-coffee",
        timezone: "America/Los_Angeles",
        primaryLanguage: "en",
        appName: "Acme Coffee",
        appDescription:
          "Daily ritual coffee subscriptions for runners and night-shift workers. Single-origin beans from named farms, roasted weekly, and shipped on a cadence that matches how you actually drink coffee — so the bag never goes stale and you never run out before a hard workout.",
        tagline: "Coffee that shows up before you run out.",
        brandVoice: "warm",
        targetGender: "all",
      }),
    });
    const project = await res.json(); // { id: "prj_…", … }
    ```
  </Tab>
</Tabs>

Every field below maps directly to a column on `public.projects` — no `brand` wrapper, no `metadata` round-trip. Bounds mirror the in-app form.

| Field                | Required    | What it controls                                                                                                                                                |
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`               | yes         | Internal display name. 3–30 chars.                                                                                                                              |
| `timezone`           | yes         | IANA timezone. Used by scheduling so `scheduledFor: "2026-05-15T09:00:00"` resolves to the customer's local morning, not yours.                                 |
| `appName`            | recommended | Product name the generator anchors hooks and captions on. 3–30 chars. Required before [`GET /content/hooks`](#step-4--generate-content) will return anything.   |
| `appDescription`     | recommended | Product pitch the planner uses for hooks and captions. 100–1000 chars. Same precondition for `/content/hooks` — and the strongest single lever on hook quality. |
| `tagline`            | optional    | Short one-liner (≤ 80 chars) used in end-cards and overlays.                                                                                                    |
| `brandVoice`         | optional    | One of `authentic`, `witty`, `professional`, `warm`, `casual`, `educational`. Defaults to `authentic`.                                                          |
| `targetGender`       | optional    | One of `all`, `female`, `male`. Drives the influencer-create gender default and feeds persona selection on generated content.                                   |
| `primaryLanguage`    | optional    | BCP-47 tag (`en`, `es-MX`, …). Defaults to `en`.                                                                                                                |
| `customerExternalId` | optional    | Your own customer handle. Looked up later via `?customerExternalId=…`.                                                                                          |

Persist the returned `id` (prefixed `prj_…`) — you'll pass it on every subsequent call.

Layers immediately kicks off two background workflows for the project:

* **Keyword research** — the curated TikTok hashtag bank lands on `GET /v1/projects/:id/keywords` after \~4–5 minutes.
* **First influencer** — a persona (name, gender, visual identity) anchored on the project's brand context, rendered in \~1 minute. Read it at `GET /v1/projects/:id/influencers`; wait for `status: "ready"` before generating content.

You can move on to step 2 (app media) and step 3 (social) while these run. Step 4 (generate content) needs at least one `ready` influencer — see the polling snippet at the top of that step.

If you'd like additional personas (different gender, age, vibe), call `POST /v1/projects/:id/influencers` — see the [influencer reference](/docs/api/reference/influencers/create-influencer).

## Step 2 · Upload app media (recommended) [#step-2--upload-app-media-recommended]

Optional in the strict sense, but skipping it means content generation has to imagine the customer's product. With a logo, a few screenshots, and (if available) a short demo video uploaded, generated content uses real brand assets instead of hallucinated visuals.

Uploads are URL-based — hand us a public URL, we fetch it, validate, and store. Three kinds:

| Kind         | Replace vs. append        | Unlocks                                                                             |
| ------------ | ------------------------- | ----------------------------------------------------------------------------------- |
| `logo`       | Replace (one per project) | Real logo in end-cards and overlays                                                 |
| `screenshot` | Append                    | Real product context in slideshow composites                                        |
| `demo-video` | Append                    | The `ugc-remix` content format (the format treats the demo video as the core media) |

```ts title="2-upload-app-media.ts"
async function uploadAppMedia(kind: "logo" | "screenshot" | "demo-video", url: string) {
  const res = await fetch(
    `https://api.layers.com/v1/projects/${project.id}/app-media`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": crypto.randomUUID(),
      },
      body: JSON.stringify({ kind, url }),
    },
  );
  if (!res.ok) throw new Error(await res.text());
  return res.json(); // { id: "med_…", kind, url, mimeType, byteSize, createdAt }
}

await uploadAppMedia("logo", "https://cdn.acmecoffee.com/brand/logo-512.png");
await uploadAppMedia("screenshot", "https://cdn.acmecoffee.com/product/home.png");
await uploadAppMedia("demo-video", "https://cdn.acmecoffee.com/marketing/30s-demo.mp4");
```

Per-kind constraints:

| Kind         | Mime types                                               | Max size |
| ------------ | -------------------------------------------------------- | -------- |
| `logo`       | `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml` | 5 MB     |
| `screenshot` | `image/png`, `image/jpeg`, `image/webp`, `image/heic`    | 10 MB    |
| `demo-video` | `video/mp4`, `video/quicktime`, `video/webm`             | 200 MB   |

The URL must be publicly fetchable from Layers' egress. Internal addresses and private ranges are rejected by the SSRF guard with 422.

## Step 3 · Connect a social account (optional) [#step-3--connect-a-social-account-optional]

Only needed if Layers should publish the content for you. Skip this step if you'd rather hand the user the generated asset and let them post themselves.

The flow is OAuth — Layers gives you an `authorizeUrl`, you open it in a popup or top-level tab, the user consents on TikTok or Instagram, and Layers redirects back to your `returnUrl`. The token never touches your client; we store and refresh it.

```ts title="3-connect-social-account.ts"
// 3a. Get an authorize URL. `returnUrl` must match one of the domains
//     allowlisted on your API key (set when the key was issued).
const oauth = await fetch(
  `https://api.layers.com/v1/projects/${project.id}/social/oauth-url`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      platform: "tiktok",
      returnUrl: "https://yourapp.com/social/connect/callback",
    }),
  },
).then((r) => r.json());
// → { authorizeUrl, state, expiresAt }

// 3b. Open `oauth.authorizeUrl` in a popup/redirect. The user consents on
//     TikTok's domain; we redirect to your returnUrl with ?state=… &
//     either ?status=success or ?status=error appended.

// 3c. Poll for completion (your callback page can also handle this).
while (true) {
  const status = await fetch(
    `https://api.layers.com/v1/projects/${project.id}/social/oauth-status?state=${oauth.state}`,
    { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` } },
  ).then((r) => r.json());
  if (status.status === "completed") {
    var socialAccountId = status.socialAccountId; // "sa_…"
    break;
  }
  if (status.status === "failed") throw new Error(status.error?.message);
  await new Promise((r) => setTimeout(r, 1500));
}
```

`socialAccountId` is what you'll pass on the schedule call in step 5.

## Step 4 · Generate content [#step-4--generate-content]

Two API calls: discover a TikTok slideshow source, then remix it with your influencer. The remix re-grounds the slides in your brand's voice and the influencer's face — the partner doesn't supply a hook here, Layers extracts the slide text from the source and adapts it.

Before either call, make sure the project's auto-created influencer has finished rendering. Poll `GET /v1/projects/:id/influencers` until at least one row has `status: "ready"`:

```ts title="4-wait-for-influencer.ts"
async function waitForInfluencer(): Promise<string> {
  for (;;) {
    const { items } = await fetch(
      `https://api.layers.com/v1/projects/${project.id}/influencers`,
      { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` } },
    ).then((r) => r.json());
    const ready = items.find(
      (i: { status: string }) => i.status === "ready",
    );
    if (ready) return ready.influencerId; // "inf_…"
    await new Promise((r) => setTimeout(r, 3000));
  }
}
const influencerId = await waitForInfluencer();
```

Typical wait: \~1 minute. If your customer needs a different persona (gender, age, vibe), create one with `POST /v1/projects/:id/influencers` and use that id instead — see the [influencer reference](/docs/api/reference/influencers/create-influencer).

### 4a. Discover a TikTok slideshow [#4a-discover-a-tiktok-slideshow]

`GET /v1/projects/:projectId/content/source-recommendations?kind=slideshow` returns up to 20 slideshow candidates. Two modes:

* **Portfolio** (default, no `keyword` param) — slideshows the project's content-research pipeline has pre-discovered as on-brand matches for the customer.
* **Live search** (`?keyword=<text>`) — real-time TikTok keyword search.

```ts title="4a-discover-source.ts"
const { items } = await fetch(
  `https://api.layers.com/v1/projects/${project.id}/content/source-recommendations?kind=slideshow&limit=20`,
  { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` } },
).then((r) => r.json());
// items[]: { tiktokId, kind: "slideshow", thumbnailUrl, caption, author, stats, imageCount }
const chosen = items[0]; // your end-user picks one
```

Or live-search by keyword (useful when the project is new and the portfolio is empty):

```bash
curl "https://api.layers.com/v1/projects/$PROJECT_ID/content/source-recommendations?keyword=morning+routine&kind=slideshow" \
  -H "Authorization: Bearer $LAYERS_API_KEY"
```

| Query     | Notes                                                                                               |
| --------- | --------------------------------------------------------------------------------------------------- |
| `kind`    | `slideshow` for this flow. (`video` selects video sources for `video-remix`; `mixed` returns both.) |
| `keyword` | Switches to live TikTok search. Omit for portfolio mode.                                            |
| `limit`   | 1–20, default 20.                                                                                   |

There is no `cursor` today — paginate by re-querying with a different `keyword` or wait for portfolio refresh.

### 4b. Generate the slideshow-remix [#4b-generate-the-slideshow-remix]

`POST /v1/projects/:projectId/content/slideshow-remix` with three fields: the source `tiktokVideoId` (from step 4a's `items[].tiktokId`), your `influencerId`, and nothing else.

<Tabs items="['curl', 'TypeScript']">
  <Tab value="curl">
    ```bash
    curl https://api.layers.com/v1/projects/$PROJECT_ID/content/slideshow-remix \
      -H "Authorization: Bearer $LAYERS_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "tiktokVideoId": "7301234567890123456",
        "influencerId": "inf_..."
      }'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="4b-generate-slideshow-remix.ts"
    const generate = await fetch(
      `https://api.layers.com/v1/projects/${project.id}/content/slideshow-remix`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": crypto.randomUUID(),
        },
        body: JSON.stringify({
          tiktokVideoId: chosen.tiktokId,
          influencerId,
        }),
      },
    ).then((r) => r.json());
    const { jobId: contentJobId, containerIds } = generate;
    const containerId = containerIds[0];

    // 6c. Poll the job, then fetch the container's `preview` object for UI rendering.
    while (true) {
      const job = await fetch(`https://api.layers.com/v1/jobs/${contentJobId}`, {
        headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` },
      }).then((r) => r.json());
      if (job.status === "completed") break;
      if (job.status === "failed") throw new Error(job.error?.message);
      await new Promise((r) => setTimeout(r, 3000));
    }

    const container = await fetch(
      `https://api.layers.com/v1/content/${containerId}`,
      { headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` } },
    ).then((r) => r.json());
    // container.preview has { kind: "slideshow", primaryUrl, thumbnailUrl, imageUrls[], … }
    ```
  </Tab>
</Tabs>

| Field             | Required | Notes                                                                                     |
| ----------------- | -------- | ----------------------------------------------------------------------------------------- |
| `tiktokVideoId`   | yes      | The id you picked from step 4a's `items[].tiktokId`.                                      |
| `influencerId`    | yes¹     | The persona to face-swap onto the slideshow.                                              |
| `socialAccountId` | yes¹     | Alternative to `influencerId` — anchor on a connected account's wired influencer instead. |

¹ At least one of `influencerId` or `socialAccountId` must be supplied. `slideshow-remix` cannot run without an actor.

> **Other formats.** `video-remix` (TikTok video → remixed video) and `slideshow-builder` (hook-only, no source) are documented under [Reference → Content](/docs/api/reference/content). The body shapes are similar; the source-recommendations call returns videos when you set `kind=video`.

## Step 5 · Schedule it (optional) [#step-5--schedule-it-optional]

Only relevant if you connected a social account in step 3. Skip if you handed the asset back to the user.

```ts title="5-schedule.ts"
const sched = await fetch(
  `https://api.layers.com/v1/content/${containerId}/schedule`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({
      scheduledFor: "2026-05-15T09:00:00-07:00",
      targets: [
        { socialAccountId, mode: "publish" },
      ],
    }),
  },
).then((r) => r.json());
// → { scheduledPostIds: ["sp_…"], gateStatus: "queued", scheduledFor }
```

| Field                       | What it does                                                                                                                                                                                                                                          |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheduledFor`              | ISO 8601 timestamp in UTC (Z suffix required, e.g. `2026-05-15T17:00:00Z`).                                                                                                                                                                           |
| `targets[].socialAccountId` | The account from step 3. You can pass multiple targets (≤ 50) to fan one piece of content out.                                                                                                                                                        |
| `targets[].mode`            | `publish` posts to the platform at `scheduledFor`. `draft` pushes the asset to the customer's phone as a draft instead — useful when the customer wants final review. `managed` dispatches via the project's connected managed-distribution provider. |

You can change your mind later:

* **Move the time.** `POST /v1/scheduled-posts/:postId/reschedule` with a new `scheduledFor`. Only works pre-publish.
* **Swap the account or cancel.** `DELETE /v1/scheduled-posts/:postId` cancels it. To re-target a different account, cancel and call `/schedule` again. (Each scheduled post is a content + account + time triple — re-targeting means a new row.)

## Common patterns [#common-patterns]

* **Always send `Idempotency-Key`** on POST/PATCH. Send a fresh UUIDv4 per logical action; replay with the same key + same body returns the cached response.
* **Async jobs return 202** with `{ jobId, status: "running", … }`. Poll `GET /v1/jobs/:jobId` until `status` is `completed` / `failed` / `canceled`. See [Concepts → Jobs](/docs/api/concepts/jobs).
* **Sandbox keys.** Issue an `lp_test_…` key to skip platform calls during development — content, OAuth, and publish all return fixture-backed results. See [Concepts → Sandbox](/docs/api/concepts/sandbox).
* **Errors.** Every 4xx/5xx returns `{ error: { code, message, … } }`. The codes are stable; the messages are for humans. See [Operational → Errors](/docs/api/operational/errors).
* **Many customers?** The flow above puts every customer in one project under your org. If you'd rather give each customer a hard isolation boundary — its own wallet, audit trail, and API key — model them as [sub-organizations](/docs/api/concepts/organizations) instead. Your org becomes the control plane that provisions, funds, and offboards each child. The [Provision and fund a customer](/docs/api/guides/provision-and-fund-a-customer) quickstart is the six-call version of that.

## What's next [#whats-next]

* [Influencer reference](/docs/api/reference/influencers/create-influencer) — full body schema including `language` for non-English personas.
* [Slideshow-builder reference](/docs/api/reference/content/slideshow-builder) — every body field, including `socialAccountId` shorthand if you already connected an account.
* [Webhooks](/docs/api/operational/webhooks) — `content.generated`, `social_account.connected`, `scheduled_post.published`. Avoid polling once you've set these up.
* [Approval policies](/docs/api/concepts/content-review) — gate publishes behind a human approval step if your customer needs it.
* [Manage customers with sub-organizations](/docs/api/guides/manage-customers-with-sub-organizations) — give each customer an isolated child org with its own wallet and keys.
