# Onboard a customer end-to-end (/docs/api/guides/onboard-customer)



## What you'll build [#what-youll-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 [#before-you-start]

* An API key. If you don't have one yet, see [Authentication](/docs/api/getting-started/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 `returnUrl` allowlisted 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`).

<Steps>
  <Step>
    ## Create the project [#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.

    <Endpoint method="POST" path="/v1/projects" scope="projects:write" phase="1" />

    <Tabs items="['curl', 'TypeScript', 'Python']">
      <Tab value="curl">
        ```bash
        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"
          }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```ts
        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();
        ```
      </Tab>

      <Tab value="Python">
        ```python
        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()
        ```
      </Tab>
    </Tabs>

    <Response status="201" description="Project created">
      ```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"
      }
      ```
    </Response>

    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`.
  </Step>

  <Step>
    ## Register the GitHub installation [#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.

    <Endpoint method="POST" path="/v1/github/installation" scope="github:admin" phase="1" />

    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.

    <Tabs items="['curl', 'TypeScript', 'Python']">
      <Tab value="curl">
        ```bash
        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"
          }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```ts
        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",
          }),
        });
        ```
      </Tab>

      <Tab value="Python">
        ```python
        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",
            },
        )
        ```
      </Tab>
    </Tabs>

    <Response status="200" description="Installation registered">
      ```json
      {
        "installationId": 48271993,
        "orgLogin": "quinns-coffee",
        "githubAccount": { "login": "quinns-coffee", "type": "Organization" },
        "registeredAt": "2026-04-18T17:23:02.008Z"
      }
      ```
    </Response>
  </Step>

  <Step>
    ## Trigger the repo ingest [#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.

    <Endpoint method="POST" path="/v1/projects/:projectId/ingest/github" scope="ingest:write" phase="1" />

    <Tabs items="['curl', 'TypeScript', 'Python']">
      <Tab value="curl">
        ```bash
        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"] }
          }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```ts
        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();
        ```
      </Tab>

      <Tab value="Python">
        ```python
        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"]
        ```
      </Tab>
    </Tabs>

    <Response status="202" description="Job accepted">
      ```json
      {
        "jobId": "job_01H9ZX6A2V...",
        "status": "running",
        "locationUrl": "/v1/jobs/job_01H9ZX6A2V..."
      }
      ```
    </Response>

    Every async op in the partner API uses this envelope — see [Jobs](/docs/api/concepts/jobs). Poll `GET /v1/jobs/:jobId` every 5–10 seconds until status is terminal:

    ```ts
    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:

    <Response status="200" description="Job completed">
      ```json
      {
        "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/..."
          }
        }
      }
      ```
    </Response>

    **Failure paths.**

    * `PLATFORM_ERROR` / `installation_not_found` — the `installationId` registered 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 explicit `sdkConfig.includePlatforms` matching the repo's stack.
  </Step>

  <Step>
    ## Review the brand context [#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.

    <Endpoint method="GET" path="/v1/projects/:projectId" scope="projects:read" phase="1" />

    ```bash
    curl "https://api.layers.com/v1/projects/$PROJECT_ID" \
      -H "Authorization: Bearer $LAYERS_API_KEY"
    ```

    <Response status="200">
      ```json
      {
        "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" }
        ]
      }
      ```
    </Response>

    To edit a field, `PATCH /v1/projects/:id` with only the changed fields. No full replace.
  </Step>

  <Step>
    ## Select an influencer [#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.

    <Endpoint method="GET" path="/v1/projects/:projectId/influencers" scope="projects:read" phase="1" />

    ```bash
    curl "https://api.layers.com/v1/projects/$PROJECT_ID/influencers" \
      -H "Authorization: Bearer $LAYERS_API_KEY"
    ```

    <Response status="200">
      ```json
      {
        "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
      }
      ```
    </Response>

    Skip this step if you don't care — Layers picks the default influencer when none is pinned.
  </Step>

  <Step>
    ## Generate the first piece of content [#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.

    <Endpoint method="POST" path="/v1/projects/:projectId/content" scope="content:write" phase="1" />

    <Tabs items="['curl', 'TypeScript', 'Python']">
      <Tab value="curl">
        ```bash
        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..."
            }
          }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```ts
        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();
        ```
      </Tab>

      <Tab value="Python">
        ```python
        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"]
        ```
      </Tab>
    </Tabs>

    <Response status="202">
      ```json
      {
        "jobId": "job_01H9ZX9FJ...",
        "containerIds": ["cnt_01H9ZX9FR..."],
        "status": "running",
        "format": "video_remix"
      }
      ```
    </Response>

    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:

    ```bash
    curl "https://api.layers.com/v1/content/cnt_01H9ZX9FR..." \
      -H "Authorization: Bearer $LAYERS_API_KEY"
    ```

    <Response status="200">
      ```json
      {
        "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 }
        ]
      }
      ```
    </Response>
  </Step>

  <Step>
    ## Approve the content [#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:

    <Endpoint method="POST" path="/v1/content/:containerId/approve" scope="content:approve" phase="1" />

    ```bash
    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" }'
    ```

    <Response status="200">
      ```json
      {
        "id": "cnt_01H9ZX9FR...",
        "approvalStatus": "approved",
        "approvedAt": "2026-04-18T17:41:08.712Z",
        "approvedBy": "c2037bb9-354d-4662-96b7-97a28ad6b6e1"
      }
      ```
    </Response>

    If the user rejects, `POST /v1/content/:containerId/reject` with an optional `regenerateBrief` to atomically kick off a new generation with edits.
  </Step>

  <Step>
    ## Connect a social account [#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:

    <Endpoint method="POST" path="/v1/projects/:projectId/social/oauth-url" scope="social:write" phase="1" />

    <Tabs items="['curl', 'TypeScript', 'Python']">
      <Tab value="curl">
        ```bash
        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"
          }'
        ```
      </Tab>

      <Tab value="TypeScript">
        ```ts
        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();
        ```
      </Tab>

      <Tab value="Python">
        ```python
        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"]
        ```
      </Tab>
    </Tabs>

    <Response status="200">
      ```json
      {
        "authorizeUrl": "https://www.tiktok.com/v2/auth/authorize?client_key=...",
        "state": "st_01H9ZXB...",
        "expiresAt": "2026-04-18T18:11:08.712Z"
      }
      ```
    </Response>

    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:

    ```json
    {
      "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.
  </Step>

  <Step>
    ## Schedule the first post [#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.

    <Endpoint method="POST" path="/v1/content/:containerId/schedule" scope="publish:write" phase="1" />

    ```bash
    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"
      }'
    ```

    <Response status="200">
      ```json
      {
        "scheduledPostIds": ["sp_01H9ZXD..."],
        "gateStatus": "queued",
        "scheduledFor": "2026-04-18T22:00:00Z"
      }
      ```
    </Response>

    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.
  </Step>

  <Step>
    ## Poll the scheduled post [#poll-the-scheduled-post]

    <Endpoint method="GET" path="/v1/scheduled-posts/:id" scope="publish:write" phase="1" />

    ```bash
    curl "https://api.layers.com/v1/scheduled-posts/sp_01H9ZXD..." \
      -H "Authorization: Bearer $LAYERS_API_KEY"
    ```

    <Response status="200">
      ```json
      {
        "id": "sp_01H9ZXD...",
        "status": "published",
        "externalId": "7289437420198...",
        "externalUrl": "https://www.tiktok.com/@quinnscoffee/video/7289437420198...",
        "publishedAt": "2026-04-18T22:00:11.412Z"
      }
      ```
    </Response>

    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.
  </Step>
</Steps>

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

<Cards>
  <Card title="Publish-to-learn loop" href="/docs/api/guides/publish-to-learn" description="Read metrics, find top performers, commission more of what's working." />

  <Card title="Clone a top performer" href="/docs/api/guides/clone-top-performer" description="Fork a winning post into new variants." />

  <Card title="Request leased TikTok accounts" href="/docs/api/guides/request-leased-accounts" description="Scale distribution beyond the customer's owned handles." />

  <Card title="Configure auto-pilot engagement" href="/docs/api/guides/configure-auto-pilot-engagement" description="First-comments and auto-replies once posts are live." />
</Cards>
