Layers
Partner APIGetting started

Getting started

Create a project, generate a piece of content, and (optionally) schedule it to a connected social account. Three calls, one coherent flow — Layers auto-creates the project's keyword bank and first influencer in the background.

View as Markdown

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.

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/keywordsrefreshedAt 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

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.

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"
  }'
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_…", … }

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

FieldRequiredWhat it controls
nameyesInternal display name. 3–30 chars.
timezoneyesIANA timezone. Used by scheduling so scheduledFor: "2026-05-15T09:00:00" resolves to the customer's local morning, not yours.
appNamerecommendedProduct name the generator anchors hooks and captions on. 3–30 chars. Required before GET /content/hooks will return anything.
appDescriptionrecommendedProduct pitch the planner uses for hooks and captions. 100–1000 chars. Same precondition for /content/hooks — and the strongest single lever on hook quality.
taglineoptionalShort one-liner (≤ 80 chars) used in end-cards and overlays.
brandVoiceoptionalOne of authentic, witty, professional, warm, casual, educational. Defaults to authentic.
targetGenderoptionalOne of all, female, male. Drives the influencer-create gender default and feeds persona selection on generated content.
primaryLanguageoptionalBCP-47 tag (en, es-MX, …). Defaults to en.
customerExternalIdoptionalYour 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.

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:

KindReplace vs. appendUnlocks
logoReplace (one per project)Real logo in end-cards and overlays
screenshotAppendReal product context in slideshow composites
demo-videoAppendThe ugc-remix content format (the format treats the demo video as the core media)
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:

KindMime typesMax size
logoimage/png, image/jpeg, image/webp, image/svg+xml5 MB
screenshotimage/png, image/jpeg, image/webp, image/heic10 MB
demo-videovideo/mp4, video/quicktime, video/webm200 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)

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.

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

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":

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.

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.
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):

curl "https://api.layers.com/v1/projects/$PROJECT_ID/content/source-recommendations?keyword=morning+routine&kind=slideshow" \
  -H "Authorization: Bearer $LAYERS_API_KEY"
QueryNotes
kindslideshow for this flow. (video selects video sources for video-remix; mixed returns both.)
keywordSwitches to live TikTok search. Omit for portfolio mode.
limit1–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

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

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_..."
  }'
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[], … }
FieldRequiredNotes
tiktokVideoIdyesThe id you picked from step 4a's items[].tiktokId.
influencerIdyes¹The persona to face-swap onto the slideshow.
socialAccountIdyes¹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. The body shapes are similar; the source-recommendations call returns videos when you set kind=video.

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.

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 }
FieldWhat it does
scheduledForISO 8601 timestamp in UTC (Z suffix required, e.g. 2026-05-15T17:00:00Z).
targets[].socialAccountIdThe account from step 3. You can pass multiple targets (≤ 50) to fan one piece of content out.
targets[].modepublish 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

  • 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.
  • 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.
  • Errors. Every 4xx/5xx returns { error: { code, message, … } }. The codes are stable; the messages are for humans. See 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 instead. Your org becomes the control plane that provisions, funds, and offboards each child. The Provision and fund a customer quickstart is the six-call version of that.

What's next

On this page