Onboard a customer end-to-end
From "they signed up in my product" to "their first approved post is scheduled." One reference playbook for the full loop.
What you'll build
A full onboarding flow for a new end-customer: create their project, point Layers at their repo, watch the brand context and SDK PR land, generate a first piece of content, approve it, connect a social account through your UI, and schedule the first post. Every long step is a job you poll; every call is idempotent; every failure is recoverable without losing state.
This is the longest guide in the docs, and the one every other guide assumes you've read.
Before you start
- An API key. If you don't have one yet, see Authentication.
- Your GitHub App installation on the customer's org. If they haven't installed it yet, create an install URL in Step 2.
- A
returnUrlallowlisted on your key. Social OAuth bounces back to this URL when the user finishes authorizing.
Base URL: https://api.layers.com/v1. Throughout this guide, you're onboarding a customer called Quinn's Coffee Co (repo quinns-coffee/quinns-app).
Create the project
One project per end-customer. The customerExternalId is your own handle — use it to look up the project later without storing the Layers UUID.
/v1/projects- Auth
- Bearer
- Scope
- projects:write
curl -X POST https://api.layers.com/v1/projects \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Quinns Coffee Co",
"customerExternalId": "quinn-coffee-001",
"timezone": "America/Los_Angeles",
"primaryLanguage": "en"
}'import { randomUUID } from "node:crypto";
const res = await fetch("https://api.layers.com/v1/projects", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Quinns Coffee Co",
customerExternalId: "quinn-coffee-001",
timezone: "America/Los_Angeles",
primaryLanguage: "en",
}),
});
const project = await res.json();import os
import uuid
import requests
res = requests.post(
"https://api.layers.com/v1/projects",
headers={
"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
json={
"name": "Quinns Coffee Co",
"customerExternalId": "quinn-coffee-001",
"timezone": "America/Los_Angeles",
"primaryLanguage": "en",
},
)
project = res.json(){
"id": "2481fa5c-a404-44ed-a561-565392499abc",
"organizationId": "2481fa5c-a404-44ed-a561-565392499abc",
"name": "Quinns Coffee Co",
"status": "active",
"customerExternalId": "quinn-coffee-001",
"timezone": "America/Los_Angeles",
"primaryLanguage": "en",
"ownerEmail": null,
"brand": null,
"brandContext": null,
"ingestState": { "github": null, "website": null, "appstore": null },
"requiresApproval": false,
"firstNPostsBlocked": null,
"currentBlockedCount": 0,
"metadata": null,
"createdAt": "2026-04-18T17:22:14.312Z",
"updatedAt": "2026-04-18T17:22:14.312Z"
}Save project.id — you'll use it on every subsequent call. IDs on the partner API are UUIDs (not prefixed). Resending with the same Idempotency-Key returns the cached response.
If you see 409 IDEMPOTENCY_CONFLICT, you reused a key with a different body. Rotate the key and retry. For "external id already taken" scenarios, look up the existing project with GET /v1/projects?customerExternalId=quinn-coffee-001.
Register the GitHub installation
You install the Layers GitHub App once per end-customer GitHub org. Then you tell Layers which installation corresponds to which project.
/v1/github/installation- Auth
- Bearer
- Scope
- github:admin
If the customer hasn't installed the App yet, create an install URL with GET /v1/github/installation/install-url — the response carries an authorizeUrl you redirect them to, plus a state token. When they return, GitHub passes both installation_id and the same state back via the callback query string. You post that pair to register the installation.
curl -X POST https://api.layers.com/v1/github/installation \
-H "X-Api-Key: $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"installationId": 48271993,
"state": "gh_state_01HXA1NHKJZXPV8R7Q6WSM5BCD"
}'await fetch("https://api.layers.com/v1/github/installation", {
method: "POST",
headers: {
"X-Api-Key": process.env.LAYERS_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
installationId: 48271993,
state: "gh_state_01HXA1NHKJZXPV8R7Q6WSM5BCD",
}),
});import os
import requests
requests.post(
"https://api.layers.com/v1/github/installation",
headers={
"X-Api-Key": os.environ["LAYERS_API_KEY"],
"Content-Type": "application/json",
},
json={
"installationId": 48271993,
"state": "gh_state_01HXA1NHKJZXPV8R7Q6WSM5BCD",
},
){
"installationId": 48271993,
"orgLogin": "quinns-coffee",
"githubAccount": { "login": "quinns-coffee", "type": "Organization" },
"registeredAt": "2026-04-18T17:23:02.008Z"
}Trigger the repo ingest
This is the big one. Layers clones the repo into an ephemeral sandbox, extracts brand context from the code and any marketing copy it finds, writes a PR that instruments the app with the Layers SDK, then destroys the sandbox. Typical runtime is 3–8 minutes.
The call returns immediately with a jobId. You poll.
/v1/projects/:projectId/ingest/github- Auth
- Bearer
- Scope
- ingest:write
curl -X POST "https://api.layers.com/v1/projects/$PROJECT_ID/ingest/github" \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"repoFullName": "quinns-coffee/quinns-app",
"branch": "main",
"sdkConfig": { "includePlatforms": ["ios", "web"] }
}'const res = await fetch(
`https://api.layers.com/v1/projects/${project.id}/ingest/github`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
repoFullName: "quinns-coffee/quinns-app",
branch: "main",
sdkConfig: { includePlatforms: ["ios", "web"] },
}),
},
);
const { jobId } = await res.json();import os
import uuid
import requests
res = requests.post(
f"https://api.layers.com/v1/projects/{project_id}/ingest/github",
headers={
"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
json={
"repoFullName": "quinns-coffee/quinns-app",
"branch": "main",
"sdkConfig": {"includePlatforms": ["ios", "web"]},
},
)
job_id = res.json()["jobId"]{
"jobId": "job_01H9ZX6A2V...",
"status": "running",
"locationUrl": "/v1/jobs/job_01H9ZX6A2V..."
}Every async op in the partner API uses this envelope — see Jobs. Poll GET /v1/jobs/:jobId every 5–10 seconds until status is terminal:
async function waitForJob(jobId: string) {
while (true) {
const r = await fetch(`https://api.layers.com/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.LAYERS_API_KEY}` },
});
const job = await r.json();
if (job.status === "completed") return job.result;
if (job.status === "failed") throw new Error(job.error.message);
if (job.status === "canceled") throw new Error("Job was canceled");
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
const result = await waitForJob(jobId);A completed ingest returns the PR URL, the brand context, and the SDK app ID:
{
"jobId": "job_01H9ZX6A2V...",
"status": "completed",
"stage": "finalizing",
"progress": 1,
"finishedAt": "2026-04-18T17:28:41.090Z",
"result": {
"prUrl": "https://github.com/quinns-coffee/quinns-app/pull/142",
"prNumber": 142,
"sdkAppId": "app_01H9...",
"brandContext": {
"appName": "Quinn's Coffee",
"appDescription": "Order-ahead for third-wave coffee shops in the PNW.",
"tagline": "Your cafe, skip the line.",
"audience": "Urban coffee drinkers 24–40, iPhone-first.",
"icp": "Commuters, freelancers, students.",
"brandVoice": "Warm, specific, lightly opinionated about beans.",
"keywords": ["coffee", "cafe", "order ahead", "pacific northwest"],
"primaryLanguage": "en",
"logoUrl": "https://cdn.layers.com/brand/..."
}
}
}Failure paths.
PLATFORM_ERROR/installation_not_found— theinstallationIdregistered on the project doesn't match the org that owns the repo. Re-register with the correct install.VALIDATION/repoFullName— the repo is private and your installation doesn't cover it. Ask the customer to grant repo access in GitHub, then retry.- Job
status: "failed"/SDK_PATCH_GENERATION_FAILED— the sandbox couldn't find a supported platform in the repo. No PR was opened. Retry with an explicitsdkConfig.includePlatformsmatching the repo's stack.
Review the brand context
Fetch the project to see what ingest produced. Show this to the customer in your UI so they can edit it before you start generating content — it's the primary input to every future generation.
/v1/projects/:projectId- Auth
- Bearer
- Scope
- projects:read
curl "https://api.layers.com/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $LAYERS_API_KEY"{
"id": "prj_01H9ZX5YV8...",
"name": "Quinns Coffee Co",
"brandContext": {
"appName": "Quinn's Coffee",
"tagline": "Your cafe, skip the line.",
"brandVoice": "Warm, specific, lightly opinionated about beans."
},
"approvalPolicy": {
"requiresApproval": true,
"firstNPostsBlocked": 3,
"currentBlockedCount": 0
},
"layers": [
{ "projectLayerId": "pl_01H9...", "kind": "content", "templateName": "Social Content" },
{ "projectLayerId": "pl_01H9...", "kind": "distribution", "platform": "tiktok" },
{ "projectLayerId": "pl_01H9...", "kind": "distribution", "platform": "instagram" }
]
}To edit a field, PATCH /v1/projects/:id with only the changed fields. No full replace.
Select an influencer
Every project auto-creates at least one influencer from the brand context — the ambassador Layers will use to voice content. Your UI lists them, the customer picks one, and you pin that ID on the next call.
/v1/projects/:projectId/influencers- Auth
- Bearer
- Scope
- projects:read
curl "https://api.layers.com/v1/projects/$PROJECT_ID/influencers" \
-H "Authorization: Bearer $LAYERS_API_KEY"{
"items": [
{
"influencerId": "inf_01H9...",
"name": "Maya — PNW cafe regular",
"gender": "female",
"ageRange": "25-34",
"brandVoice": "Warm, specific, lightly opinionated about beans.",
"status": "ready"
}
],
"nextCursor": null
}Skip this step if you don't care — Layers picks the default influencer when none is pinned.
Generate the first piece of content
format: "auto" lets Layers pick between video_remix, slideshow_remix, and ugc_remix based on what media the project has. Pin a format if you need determinism.
/v1/projects/:projectId/content- Auth
- Bearer
- Scope
- content:write
curl -X POST "https://api.layers.com/v1/projects/$PROJECT_ID/content" \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"format": "auto",
"variantCount": 1,
"brief": {
"hook": "Your cafe, skip the line.",
"cta": "Download Quinns Coffee",
"targetPlatforms": ["tiktok", "instagram"],
"influencerId": "inf_01H9..."
}
}'const res = await fetch(
`https://api.layers.com/v1/projects/${project.id}/content`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LAYERS_API_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
format: "auto",
variantCount: 1,
brief: {
hook: "Your cafe, skip the line.",
cta: "Download Quinns Coffee",
targetPlatforms: ["tiktok", "instagram"],
influencerId: "inf_01H9...",
},
}),
},
);
const { jobId, containerIds } = await res.json();import os
import uuid
import requests
res = requests.post(
f"https://api.layers.com/v1/projects/{project_id}/content",
headers={
"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
json={
"format": "auto",
"variantCount": 1,
"brief": {
"hook": "Your cafe, skip the line.",
"cta": "Download Quinns Coffee",
"targetPlatforms": ["tiktok", "instagram"],
"influencerId": "inf_01H9...",
},
},
)
body = res.json()
job_id, container_ids = body["jobId"], body["containerIds"]{
"jobId": "job_01H9ZX9FJ...",
"containerIds": ["cnt_01H9ZX9FR..."],
"status": "running",
"format": "video_remix"
}Poll the job the same way as Step 3. Typical runtime is 90–180 seconds. When it completes, read the container to see media, captions, and the thumbnail:
curl "https://api.layers.com/v1/content/cnt_01H9ZX9FR..." \
-H "Authorization: Bearer $LAYERS_API_KEY"{
"id": "cnt_01H9ZX9FR...",
"status": "completed",
"approvalStatus": "pending",
"format": "video_remix",
"hook": "Your cafe, skip the line.",
"captionVariants": [
{ "platform": "tiktok", "text": "Walk in, grab it, walk out. ☕️ Free on the App Store." },
{ "platform": "instagram", "text": "The fastest coffee in the PNW. Link in bio." }
],
"mediaAssets": [
{ "id": "asset_01H9...", "kind": "video", "url": "https://cdn.layers.com/...", "durationMs": 11200 }
]
}Approve the content
The project was created with requiresApproval: true and the first 3 posts per customer are blocked behind your approval. Scheduling refuses with 403 APPROVAL_REQUIRED until you flip the flag.
Show the preview to your user, collect their decision, then call:
/v1/content/:containerId/approve- Auth
- Bearer
- Scope
- content:approve
curl -X POST "https://api.layers.com/v1/content/cnt_01H9ZX9FR.../approve" \
-H "X-Api-Key: $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "note": "Matches brief" }'{
"id": "cnt_01H9ZX9FR...",
"approvalStatus": "approved",
"approvedAt": "2026-04-18T17:41:08.712Z",
"approvedBy": "c2037bb9-354d-4662-96b7-97a28ad6b6e1"
}If the user rejects, POST /v1/content/:containerId/reject with an optional regenerateBrief to atomically kick off a new generation with edits.
Connect a social account
OAuth lives on TikTok and Instagram domains — it cannot be iframed. The user leaves your UI, authorizes on the platform, and returns. Create an OAuth URL with a returnUrl that lands back in your app:
/v1/projects/:projectId/social/oauth-url- Auth
- Bearer
- Scope
- social:write
curl -X POST "https://api.layers.com/v1/projects/$PROJECT_ID/social/oauth-url" \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "tiktok",
"returnUrl": "https://app.your-partner.com/customers/quinn/social-complete"
}'const res = 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://app.your-partner.com/customers/quinn/social-complete",
}),
},
);
const { authorizeUrl, state } = await res.json();import os
import requests
res = requests.post(
f"https://api.layers.com/v1/projects/{project_id}/social/oauth-url",
headers={
"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}",
"Content-Type": "application/json",
},
json={
"platform": "tiktok",
"returnUrl": "https://app.your-partner.com/customers/quinn/social-complete",
},
)
body = res.json()
authorize_url, state = body["authorizeUrl"], body["state"]{
"authorizeUrl": "https://www.tiktok.com/v2/auth/authorize?client_key=...",
"state": "st_01H9ZXB...",
"expiresAt": "2026-04-18T18:11:08.712Z"
}Redirect the user to authorizeUrl. After they authorize, TikTok redirects to Layers. Layers persists the token and 302s to your returnUrl with ?layers_state=<state>&status=success.
Back in your UI, poll GET /v1/projects/:id/social/oauth-status?state=<state> to resolve the connected account:
{
"state": "st_01H9ZXB...",
"status": "completed",
"socialAccountId": "sa_01H9ZXC..."
}If you see RETURN_URL_NOT_ALLOWED on the create call, the returnUrl doesn't match your key's allowlist. Update the allowlist in the admin UI or through your Layers contact.
Schedule the first post
With an approved container and a connected social account, schedule the post. Layers queues the publish, executes at scheduledFor, and surfaces the platform post ID and URL once it lands.
/v1/content/:containerId/schedule- Auth
- Bearer
- Scope
- publish:write
curl -X POST "https://api.layers.com/v1/content/cnt_01H9ZX9FR.../schedule" \
-H "Authorization: Bearer $LAYERS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"targets": [
{ "socialAccountId": "sa_01H9ZXC...", "mode": "direct_publish" }
],
"scheduledFor": "2026-04-18T22:00:00Z"
}'{
"scheduledPostIds": ["sp_01H9ZXD..."],
"gateStatus": "queued",
"scheduledFor": "2026-04-18T22:00:00Z"
}If the container weren't approved, gateStatus would be "blocked_on_approval" and scheduledPostIds would be empty. That's your signal to send it back to the approval queue.
Poll the scheduled post
/v1/scheduled-posts/:id- Auth
- Bearer
- Scope
- publish:write
curl "https://api.layers.com/v1/scheduled-posts/sp_01H9ZXD..." \
-H "Authorization: Bearer $LAYERS_API_KEY"{
"id": "sp_01H9ZXD...",
"status": "published",
"externalId": "7289437420198...",
"externalUrl": "https://www.tiktok.com/@quinnscoffee/video/7289437420198...",
"publishedAt": "2026-04-18T22:00:11.412Z"
}Status transitions: queued → publishing → published | failed | canceled. On failed, lastError carries the platform code so your UI can explain what happened (expired token, media rejected by TikTok, rate-limited by the platform).
Your customer is live.
What's next
Publish-to-learn loop
Read metrics, find top performers, commission more of what's working.
Clone a top performer
Fork a winning post into new variants.
Request leased TikTok accounts
Scale distribution beyond the customer's owned handles.
Configure auto-pilot engagement
First-comments and auto-replies once posts are live.