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.
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 itSteps 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—refreshedAtisnulluntil the first run finishes. Force a re-run withPOST /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 throughtrainingtostatus: "ready"after ~1 minute. Read it atGET /v1/projects/:id/influencers. You need at least onereadyinfluencer before step 4 — wait for it, or create additional personas withPOST /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"
}'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.
| 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 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/keywordsafter ~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 forstatus: "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.
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) |
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)
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.
// 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":
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
keywordparam) — 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.
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 oneOr 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"| 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
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_..."
}'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[], … }| 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) andslideshow-builder(hook-only, no source) are documented under Reference → Content. The body shapes are similar; the source-recommendations call returns videos when you setkind=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.
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/reschedulewith a newscheduledFor. Only works pre-publish. - Swap the account or cancel.
DELETE /v1/scheduled-posts/:postIdcancels it. To re-target a different account, cancel and call/scheduleagain. (Each scheduled post is a content + account + time triple — re-targeting means a new row.)
Common patterns
- Always send
Idempotency-Keyon 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", … }. PollGET /v1/jobs/:jobIduntilstatusiscompleted/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
- Influencer reference — full body schema including
languagefor non-English personas. - Slideshow-builder reference — every body field, including
socialAccountIdshorthand if you already connected an account. - Webhooks —
content.generated,social_account.connected,scheduled_post.published. Avoid polling once you've set these up. - Approval policies — gate publishes behind a human approval step if your customer needs it.
- Manage customers with sub-organizations — give each customer an isolated child org with its own wallet and keys.