ppl.studio

API reference

The public ppl.studio API. One per-user key does everything the dashboard does — personas, uploads, images, scripts, storyboards, workflow runs, video and social — JSON in, JSON out.

Getting started

One base URL, one key, one response envelope. Every /api/v1 route takes the same key and sees only your own data.

Base URL

https://ppl.studio

Authenticate

x-api-key: ppl_live_…
# or
Authorization: Bearer ppl_live_…

Quickstart

# 1. Mint a key in the dashboard: Account → API keys  (ppl_live_…)
# 2. Ask what it may do:

curl https://ppl.studio/api/v1/account \
  -H "x-api-key: ppl_live_xxx"

# → { "ok": true, "data": { "plan": "creator", "images": { … }, "video": { "allowed": true, … } } }

Mint and revoke keys in the dashboard under Account → API keys. A key is shown once — store it securely. A key that is present but invalid or revoked is a 401; it never falls back to a browser session.

Also

  • OpenAPI 3.1 spec — every endpoint below, to generate a client from.
  • MCP server — this API as tools for AI assistants: hosted at https://ppl.studio/mcp (sign in with OAuth — claude.ai, ChatGPT, Cursor), or local via npx to save results to your disk. The Claude skill teaches the workflows.
  • API overview — what you can build, and a five-call quickstart from a product page to an MP4.

A video, end to end

# Start a run — it works hands-off up to the priced preview, then waits
curl -X POST https://ppl.studio/api/v1/flows \
  -H "Authorization: Bearer ppl_live_xxx" -H "content-type: application/json" \
  -d '{ "preset": "creator-ugc-video", "creatorSlug": "maya",
        "briefSource": { "mode": "url", "value": "https://shop.example/serum" } }'
# → { "ok": true, "data": { "runId": "…" } }

# Poll it; data.next says what it waits on, data.estimate is the price
curl https://ppl.studio/api/v1/flows/$RUN -H "Authorization: Bearer ppl_live_xxx"
# → { …, "awaiting": "gate", "next": { "action": "approve", … }, "estimate": { "usd": 0.4, … } }

# Approve — video spend starts only now
curl -X POST https://ppl.studio/api/v1/flows/$RUN/approve -H "Authorization: Bearer ppl_live_xxx"

Auth tiers

  • API keyPer-user key (ppl_live_…) — every /api/v1/* route, scoped to your account. A signed-in dashboard session works on the same routes.
  • SessionSigned-in session only — managing API keys and your Gemini key. A key can't mint keys.
  • x-api-keyPartner key — writes to the shared persona directory.
  • PublicNo auth — anonymous, rate-limited free tools.

Response envelope

// /api/v1 success            // failure (4xx / 5xx)
{ "ok": true, "data": … }   { "ok": false, "error": "message" }

Key off the HTTP status: auth failures (401, 403) and rate limits (429) can come back as a bare { "error": "…" }. 404 means missing or not yours — other users' rows are never revealed. Free-tool endpoints return their payload with no envelope.

Plans, limits and 429s

Images spend hosted generation slots unless you've connected your own Gemini key (then they're unlimited). Video — Veo, the voice, lip-sync — needs the Creator plan and your own Gemini key; without them a video step answers 403. Text-model calls (scripts, hooks, captions, topics, persona writing) are capped per user per day only while they run on the platform's key; over the cap is a 429 with Retry-After and X-RateLimit-Policy. GET /api/v1/account tells you where you stand before you call.

Async jobs

Anything slow is a job: the start call answers 202 (or 201) with a jobId, and you poll the matching GET …/{id} until status is done, failed or cancelled. Runs are polled at GET /api/v1/flows/{id}, whose next names the call that moves the run on. videos/assemble and videos/upscale also take an https callbackUrl. GET /api/v1/queue lists everything in flight.

The priced preview

A workflow run writes, voices and frames by itself, then stops at the preview — the frames with the line said over each, priced by the server — until you POST /api/v1/flows/{id}/approve. No video money is spent before that, unless the run was started straightThrough.

Idempotency

POST /api/v1/images/photo-pack and POST /api/v1/images/bulk honour an optional Idempotency-Key header: a retry with the same key and body replays the first answer instead of paying again (24 h). Same key with a different body is a 422; one still in flight is a 409.

Files

Endpoints take hosted URLs. To host a local file, POST /api/v1/uploads (images ≤ 5 MB, video / audio ≤ 9 MB), or upload video and audio up to 512 MB in parts with /api/v1/uploads/multipart. The URL you get back is public and permanent.

Account & usage

Your plan, your limits and everything in flight. API keys and the Gemini key are managed with a signed-in session, never with a key.

GET/api/v1/accountAPI key

Who you are and what you may generate right now — call it first to explain why an image or video step would be refused.

Response

{ "ok": true, "data": {
  "user": { "id", "email", "name", "image", "createdAt" },
  "authenticatedWith": "api_key | session",
  "plan": "free | creator",
  "gemini": { "connected": true, "imageModel": "string" },
  "images": { "unlimited", "hostedUsed", "hostedLimit", "hostedRemaining": "number | null", "refusal": "string | null" },
  "video": { "allowed", "denied": "plan | key | null", "reason": "string | null" }
} }
images: with your own Gemini key you're unlimited; without one, images use hosted slots — a lifetime count that never resets. refusal is the error an image request would get now (HOSTED_LIMIT_REACHED on free, the add-your-key message on Creator). video: Veo, the voice and lip-sync need Creator and your own key. The key itself is never returned.
GET/api/v1/queueAPI key

Every generation job of yours, across every job table — what the Queue page lists.

Request

?hours=72        // 0 = all time
&limit=80        // max 200
&status=queued | running | done | failed   // narrows items only

Response

{ "ok": true, "data": {
  "items": [{ "id", "kind": "video | assembly | image | lipsync | clip | upscale | brick",
              "status": "queued | running | done | failed", "rawStatus", "title", "detail", "error",
              "attempts", "createdAt", "startedAt", "finishedAt", "href", "retryHref", "resultUrl",
              "runningMinutes", "stalled" }],
  "counts": { "queued", "running", "done", "failed" }, "inFlight", "failed", "active",
  "window": { "hours", "limit" }
} }
href / retryHref are dashboard paths; a run's detail is GET /api/v1/flows/{id}. Poll while active is true.
GET/api/v1/account/api-keysSession

List your API keys — never the secret.

Response

{ "ok": true, "data": { "keys": [{ "id", "name", "key_prefix", "last_used_at", "revoked_at", "created_at" }] } }
POST/api/v1/account/api-keysSession

Mint a key (201). The raw key is returned once.

Request

{ "name"?: "string (default 'API key')" }

Response

{ "ok": true, "data": { "raw": "ppl_live_…", "row": { "id", "name", "key_prefix", … } } }
GET/api/v1/account/gemini-keySession

Whether you have your own Gemini key saved, and the image model.

Response

{ "ok": true, "data": { "hasKey": true, "imageModel": "string" } }
PUT/api/v1/account/gemini-keySession

Save your own Gemini key — lifts the hosted image cap and the text rate limits; with Creator it unlocks video.

Request

{ "key": "string" }

Response

{ "ok": true, "data": { "saved": true } }
PATCH/api/v1/account/gemini-keySession

Set the image model.

Request

{ "model": "string" }

Response

{ "ok": true, "data": { "saved": true } }

Uploads

Host a local file and get back a permanent public URL — for products, personas, the gallery, run inputs, video clips and brick audio.

POST/api/v1/uploadsAPI key

Store one file (201). multipart/form-data.

file (required): an image — JPEG, PNG, WebP, GIF ≤ 5 MB; a video — MP4, MOV, WebM ≤ 9 MB; or audio — MP3, WAV, M4A ≤ 9 MB. The type comes from the part's Content-Type (or the filename's extension) and must match the bytes. gallery=true also adds an image to your gallery.

Request

curl -X POST https://ppl.studio/api/v1/uploads \
  -H "x-api-key: ppl_live_xxx" \
  -F file=@serum.jpg -F gallery=true

Response

{ "ok": true, "data": {
  "url": "https://…", "kind": "image | video | audio", "contentType", "bytes", "galleryImageId"?
} }
Requests over 10 MB are refused (413) — send larger video or audio in parts, below.
POST/api/v1/uploads/multipartAPI key

Start a large upload — video or audio up to 512 MB, in 8 MB parts (201). Images always use the single upload.

Request

{ "contentType": "video/mp4", "bytes": 214958080 }

Response

{ "ok": true, "data": { "uploadId", "path", "partSize", "maxParts", "kind", "contentType" } }
PUT/api/v1/uploads/multipartAPI key

Upload part N as the raw request body: ?uploadId=&path=&part=N (1-based).

Response

{ "ok": true, "data": { "part": 1, "etag": "string" } }
Every part but the last is exactly partSize bytes. Part 1 is checked against the declared type.
DELETE/api/v1/uploads/multipartAPI key

Abort an upload: ?uploadId=&path=.

Response

{ "ok": true, "data": { "aborted": true } }
POST/api/v1/uploads/multipart/completeAPI key

Finish the upload — every part 1…N once, with the etags PUT returned.

Request

{ "uploadId", "path", "parts": [{ "part": 1, "etag": "…" }, …] }

Response

{ "ok": true, "data": { "url", "kind", "contentType" } }

Personas

The creators in your videos. Full CRUD over the ones you own, AI helpers to write them, their images and passport photo — plus the shared public directory.

GET/api/v1/personasAPI key

List your personas.

Response

{ "ok": true, "data": { "personas": [{ "id", "slug", "name", "label", "niche", "gender",
  "tone_of_voice", "accent", "passport_image_url" }] } }
POST/api/v1/personasAPI key

Create a persona you own (201). 409 if the slug is taken.

name is required; slug defaults to one made from it. Profile fields include niche, label, gender, tone_of_voice, accent, voice (its TTS voice), short_bio, peer_archetype, backstory, visual_description, catchphrases[], interests[], outfits_owned[], avoid_phrases[]. Unknown keys are a 400. Videos need a passport photo: set one with …/passport or make one with POST /api/v1/images/generate (type: "passport").

Request

{ "name": "Maya", "niche": "skincare", "tone_of_voice": "warm, direct", … }

Response

{ "ok": true, "data": { /* the persona */ } }
GET/api/v1/personas/{slug}API key

One of your personas (404 if not yours).

Response

{ "ok": true, "data": { /* the persona */ } }
PATCH/api/v1/personas/{slug}API key

Update any of the create fields — 404 if not yours, 409 if a new slug is taken.

Response

{ "ok": true, "data": { /* the persona */ } }
DELETE/api/v1/personas/{slug}API key

Delete one of your personas (its gallery images are unassigned first).

Response

{ "ok": true, "data": { "slug" } }
POST/api/v1/personas/generateAPI key

Write a persona profile with AI from a niche and/or a product. A draft only, unless save is true.

Request

{
  "niche"?: "string (3+ chars)",
  "productUrl"?: "https://…", "productDescription"?: "string",
  "productType"?: "physical | digital | personal_blog | membership | software | service | other",
  "save"?: false
}

Response

{ "ok": true, "data": { "draft": { "name", "label", "niche", "peer_archetype", "short_bio",
  "tone_of_voice", "visual_description", "backstory", "catchphrases": [ … ], … },
  "persona"?: { … } } }   // persona (201) with save: true
429 at the persona-writing limit without your own key; 503 when no Gemini key resolves. A saved draft whose slug is taken is a 409.
POST/api/v1/personas/{slug}/fieldsAPI key

The edit form's AI helpers on a saved persona: fill every empty field, write one, or shrink / enlarge one.

Request

{ "action": "fill_empty", "context"?: { … }, "save"?: false }
{ "action": "generate", "field", "currentValue"?, "context"?, "save"? }
{ "action": "shrink" | "enlarge", "field", "currentValue"?, "save"? }
// field: short_bio | tone_of_voice | accent | visual_description | backstory

Response

{ "ok": true, "data": { "fields": { … } } }            // fill_empty
{ "ok": true, "data": { "field", "content" } }         // generate / shrink / enlarge
// + "persona": { … } when save is true
context is unsaved field values the model reads, never saved themselves. Suggestions only unless save. Same 429 limit as generate.
GET/api/v1/personas/{slug}/imagesAPI key

The persona's UGC images in order, and its passport photo.

Response

{ "ok": true, "data": { "passport_image_url",
  "images": [{ "id", "url", "type", "sort_order", "caption", "generation_params", "created_at", "prompt" }] } }
POST/api/v1/personas/{slug}/imagesAPI key

Add an image to the persona (201): multipart file (JPEG / PNG / WebP / GIF ≤ 5 MB), or JSON { url } to attach a gallery or hosted image.

Response

{ "ok": true, "data": { "url", "image"? } }
DELETE/api/v1/personas/{slug}/imagesAPI key

Unattach an image by URL: ?url=… The image stays in your gallery.

Response

{ "ok": true, "data": { "url", "unattached": true } }
DELETE/api/v1/personas/{slug}/images/{imageId}API key

Unattach an image by id — a no-op if it isn't attached. The image stays in your gallery.

Response

{ "ok": true, "data": { "imageId", "unattached": true } }
PUT/api/v1/personas/{slug}/passportAPI key

Set the passport photo — the reference face for every image and video: multipart file (≤ 5 MB) or JSON { url }.

Response

{ "ok": true, "data": { "url" } }
DELETE/api/v1/personas/{slug}/passportAPI key

Clear the passport photo (the image stays in your gallery).

Response

{ "ok": true, "data": { "passport_image_url": null } }
GET/api/personasPublic

Public list of the shared persona directory.

POST/api/personasx-api-key

Create an unowned persona in the shared directory (partner key).

Same fields as POST /api/v1/personas, plus an optional passport_image_url.

Response

{ "slug": "string", "id": "uuid" }

Products

Your physical products (Props) — what a persona holds, wears or stands beside, the facts the writers use, and what's inside a box or set (the What's-inside carousel). image_urls are hosted URLs (upload first with /api/v1/uploads). App screens are Apps now.

POST/api/v1/propsAPI key

Create a product (201).

Request

{
  "name": "string",
  "category": "handheld | large_object | ambient",
  "text"?: "string (what it is — the writers read it)",
  "image_urls"?: ["https://…"],
  "price"?: 49.99, "currency"?: "USD", "link"?: "https://… (where to buy it)",
  "contents"?: [{ "name", "brand"?, "type"?, "size"?, "imageUrl"?: "https://…", "fullSize"?: true }]   // what's inside, ≤ 60
}

Response

{ "ok": true, "data": { "id", "name", "category", "text", "image_urls", "price", "currency", "link", "contents", … } }
GET/api/v1/propsAPI key

List your products, newest first.

Response

{ "ok": true, "data": { "props": [{ "id", "name", "category", "text", "image_urls", "price", "currency", "link", "contents", "created_at" }] } }
PATCH/api/v1/props/{id}API key

Partial update of name, category, text, image_urls, price (null clears), currency, link, contents (the whole list) — 404 if not yours.

Response

{ "ok": true, "data": { /* the product */ } }
DELETE/api/v1/props/{id}API key

Delete one of your products.

Response

{ "ok": true, "data": { "id", "deleted": true } }

Apps

Your apps — and others' you talk about (own: false): screens and a store listing, for app demos and app carousels. From an App Store link the icon, screens and words come in (copied to your storage); the listing's numbers — rating, price, version, release notes — are read live when a run uses them.

POST/api/v1/appsAPI key

Add an app (201): from its App Store link, or by hand (Google Play, web). The same store app twice is refreshed, not duplicated.

Request

{ "storeUrl": "https://apps.apple.com/us/app/…/id570060128" | "storeId": "570060128", "country"?: "us", "own"?: true }
// or by hand:
{ "name", "platform"?: "ios | android | web", "own"?, "description"?, "iconUrl"?,
  "screenshots"?: [{ "url": "https://…", "device"?: "phone | tablet | desktop", "caption"? }] }

Response

{ "ok": true, "data": { "id", "name", "own", "platform", "store_id", "store_url", "country", "developer",
                          "genre", "description", "icon_url", "screenshots": [{ "url", "device", "caption", "source" }], … } }
GET/api/v1/appsAPI key

Your apps, yours first.

Response

{ "ok": true, "data": { "apps": [ … ] } }
GET/api/v1/apps/{id}API key

One app. ?live=1 adds the App Store's numbers right now (null for apps not on the store).

Response

{ "ok": true, "data": { …app, "listing"?: { "rating", "ratingCount", "priceLabel", "version", "releaseNotes", "phoneScreens", … } } }
PATCH/api/v1/apps/{id}API key

Change name, own, platform, storeUrl, country, developer, genre, description, iconUrl, screenshots (the whole list, in order — reorder, caption, drop or add).

Response

{ "ok": true, "data": { /* the app */ } }
POST/api/v1/apps/{id}/refreshAPI key

Read the store listing again: new screens, icon and words; uploaded screens and captions stay.

Response

{ "ok": true, "data": { /* the app */ } }
DELETE/api/v1/apps/{id}API key

Delete an app. Carousels made from it keep their slides.

Response

{ "ok": true, "data": { "id", "deleted": true } }

Catalogs

Your product feeds — a Google Merchant or Meta feed, a Shopify or WooCommerce shop, or a CSV. Only where the feed is gets saved: the products are read live whenever you look or a run starts, and a carousel keeps just the ones it showed, as they were that day.

POST/api/v1/catalogsAPI key

Save a feed (201). From an address, what it is is worked out; or upload a CSV (multipart, ≤ 8 MB — a Shopify product export reads as is).

Request

{ "url": "https://yourshop.com" | "https://…/feed.xml", "name"?, "currency"?: "USD", "siteUrl"? }
// or multipart/form-data: file (.csv / .tsv), name?, currency?, siteUrl?

Response

{ "ok": true, "data": { "id", "name", "url", "format": "merchant | shopify | woocommerce | csv", "site_url", "currency",
                          "last_read_at", "last_count", "last_error", … } }
GET/api/v1/catalogsAPI key

Your catalogs.

Response

{ "ok": true, "data": { "catalogs": [ … ] } }
GET/api/v1/catalogs/{id}/itemsAPI key

The feed's products, read live (reused 5 minutes; ?fresh=1 reads again). ?q= matches title, brand and labels; ?limit=&offset= page. Feeds past 5,000 products are cut there.

Response

{ "ok": true, "data": { "items": [{ "key", "title", "brand", "subtitle", "price": { "amount", "currency" }, "salePrice",
                                   "images", "link", "availability", "publishedAt", "labels" }],
                          "total", "matched", "readAt", "truncated" } }
POST/api/v1/catalogs/{id}/propsAPI key

Keep one product as a Prop (201) — its name, description, first two photos (copied), price and link.

Request

{ "itemKey": "string (from …/items)" }

Response

{ "ok": true, "data": { /* the Prop */ } }
PATCH/api/v1/catalogs/{id}API key

Change name, currency, siteUrl or format.

Response

{ "ok": true, "data": { /* the catalog */ } }
DELETE/api/v1/catalogs/{id}API key

Forget the feed's address (the feed itself is untouched).

Response

{ "ok": true, "data": { "id", "deleted": true } }

Stock photos

Free photos from Pexels, for carousel covers and backgrounds. Show 'Photos provided by Pexels' and each photographer's name wherever you show results. Licence: commercial use and edits are fine; don't imply the people in a photo endorse your product.

GET/api/v1/stock/photosAPI key

Search: ?q=gift+boxes[&page=1&orientation=portrait | landscape | square]. configured: false when the server has no Pexels key.

Response

{ "ok": true, "data": { "configured": true, "photos": [{ "id", "width", "height", "thumb", "full", "photographer",
                          "photographerUrl", "pageUrl", "alt" }], "total", "attribution": { "text", "url" } } }
POST/api/v1/stock/photosAPI key

Keep one (201): a copy in your storage and gallery — pass its url on (a carousel's options.coverPhoto).

Request

{ "id": 1234567 }

Response

{ "ok": true, "data": { "id", "url", "photographer", "pageUrl" } }

Images

UGC photos, passports, product packs and app screens — made now (synchronous, tens of seconds) or as jobs. Each image spends one hosted slot unless you've connected your own Gemini key; all are saved to your gallery.

GET/api/v1/images/optionsAPI key

The Workbench's scene options — what optionsMap takes in structured mode — plus whole-scene presets, gaze and head angles.

Response

{ "ok": true, "data": {
  "groups":  [{ "id", "label", "section", "multiSelect", "choices": [{ "value", "label" }] }],
  "presets": [{ "id", "label", "description"?, "optionsMap", "clothingText"? }],
  "gaze":    [{ "id", "label", "eyeGazeX", "eyeGazeY" }],
  "head":    [{ "id", "label", "headTiltX", "headTiltY" }],
  "skipValue": "__skip__", "customValue": "__custom__"
} }
POST/api/v1/images/generateAPI key

One UGC photo (or a persona's passport), made now (201).

JSON with hosted image URLs (fileUrl, handheldImageUrls, … ≤ 5 a field) or multipart/form-data with the files themselves. Unknown keys are a 400. A product (handheldPropId etc., from /api/v1/props) brings its text and photos; otherwise describe the object in words and photos. Visual presets come from /api/v1/visual-presets.

Request

{
  "type"?: "ugc | passport",
  "characterSourceType"?: "persona | upload | custom",
  "personaSlug"?: "string", "saveToPersonaSlug"?: "string",
  "characterDescription"?: "string", "fileUrl"?: "https://… (upload: the person)",
  "personaContext"?: { "niche"?, "interests"?: ["string"], "phone_model"? },
  "promptMode"?: "structured | custom",
  "optionsMap"?: { "<groupId>": "value | [values]", "eyeGazeX"?: 0, "headTiltY"?: 0 },
  "customTextPerGroup"?: { "<groupId>": "text" },
  "customPrompt"?: "string", "fullPromptOverride"?: "string",
  "selectedOutfits"?: ["string"],
  "handheldPropId"? | "handheldText"? + "handheldImageUrls"?: ["https://…"],
  "largeObjectPropId"? | "largeObjectText"? + "largeObjectImageUrls"?,
  "ambientPropId"? | "ambientText"? + "ambientImageUrls"?,
  "referenceText"?, "referenceImageUrls"?, "clothingText"?, "clothingImageUrls"?,
  "visualPresetClothing"?, "visualPresetBackground"?, "usePresetClothing"?, "usePresetBackground"?
}

Response

{ "ok": true, "data": { "imageUrl": "https://…" } }
passport with a personaSlug is saved as that persona's passport photo. Out of hosted slots: HOSTED_LIMIT_REACHED (free) or the add-your-key message (Creator).
POST/api/v1/images/jobsAPI key

Queue one UGC photo instead (202) — exactly the body of /images/generate. Refuses at once on the same limits.

Response

{ "ok": true, "data": { "job": { "id", "status": "queued", "previewUrl", "resultUrl": null, "error": null, "createdAt" } } }
GET/api/v1/images/jobsAPI key

?ids=a,b (up to 100) — those jobs; no ids — your queued and running jobs and those that failed in the last 24 h (≤ 50).

Response

{ "ok": true, "data": { "jobs": [{ "id", "status": "queued | running | done | failed",
  "previewUrl", "resultUrl", "error", "createdAt" }] } }
GET/api/v1/images/jobs/{id}API key

Poll one queued photo. resultUrl is the image once done.

Response

{ "ok": true, "data": { "job": { "id", "status", "previewUrl", "resultUrl", "error", "createdAt" } } }
POST/api/v1/images/jobs/{id}/retryAPI key

Queue a failed photo again as a new job (202; no body). No new slot is spent. 404 unless yours and failed.

Response

{ "ok": true, "data": { "job": { "id": "the new job", … } } }
POST/api/v1/images/bulkAPI key

Several images of a persona from one prompt, rendered in parallel and saved to the persona. Honours Idempotency-Key.

Request

{ "personaSlug": "string", "prompt": "string (the whole scene)", "count"?: 1-10 }   // default 3

Response

{ "ok": true, "data": { "imageUrls": ["string"], "succeeded": 3, "failed": 0, "errors"?: ["string"] } }
Each image spends a slot, so a partial batch is normal — check failed and errors.
POST/api/v1/images/camera-angleAPI key

The same scene from another camera angle (201).

Request

{
  "sourceImageUrl": "https://…",
  "azimuth": 0-315,     // snapped to 45° steps: 0 front, 90 right, 180 back, 270 left
  "elevation": -30 | 0 | 30 | 60,
  "distance": 0.6 | 1.0 | 1.4,   // close-up, medium, wide
  "personaSlug"?: "string (save to this persona)"
}

Response

{ "ok": true, "data": { "imageUrl" } }
POST/api/v1/images/expand-storyAPI key

The next image of a story: the same person, the next moment (201). JSON with hosted URLs, or multipart with files.

Request

{
  "currentImageUrl": "https://… (the last image)",
  "whatHappensNext": "string",
  "personaSlug"?: "string", "saveToPersonaSlug"?: "string",
  "handheldPropId"? | "handheldText"? + "handheldImageUrls"?,
  "largeObjectPropId"? | "largeObjectText"? + "largeObjectImageUrls"?,
  "ambientPropId"? | "ambientText"? + "ambientImageUrls"?
}

Response

{ "ok": true, "data": { "imageUrl" } }
GET/api/v1/images/photo-packAPI key

The shot plans — Product UGC, Amazon, Shopify, Before/After — with each shot's role.

Response

{ "ok": true, "data": { "plans": [{ "slug", "title", "productCategory", "aspectRatio",
  "shots": [{ "role", "label", "description", "needsPersona", "referenceRole"? }] }] } }
POST/api/v1/images/photo-packAPI key

Make the asked-for shots of a plan for one of your products, now, in parallel (a minute or more). Honours Idempotency-Key.

Request

{
  "workflowSlug": "string (a plan's slug)",
  "propId": "uuid (your product)",
  "roles": ["string (the plan's shots to make)"],
  "personaSlug"?: "string (for needsPersona shots)",
  "context"?: "direction for every shot",
  "contextByRole"?: { "<role>": "direction" },
  "references"?: { "<role>": "imageUrl (an already-made shot to match)" }
}

Response

{ "ok": true, "data": { "shots": [{ "role", "imageUrl": "string | null", "error": "string | null" }] } }
A partial pack is normal — ask again for the roles that failed. A Before/After "after" remade alone takes its "before" in references.
POST/api/v1/images/product-shotsAPI key

A whole plan's shots as one job (202; 200 when the same job is already running). A failed shot gets one more try.

Request

{ "plan": "string (a plan's slug)", "productId": "uuid", "personaSlug"?: "string", "direction"?: "string" }

Response

{ "ok": true, "data": { "jobId", "existing": false } }
GET/api/v1/images/product-shots/{id}API key

Poll a product-shots job. When done, output.frames are the images in plan order.

Response

{ "ok": true, "data": { "job": {
  "id", "brick": "picture", "variant": "product-shots",
  "status": "queued | running | waiting | done | failed | cancelled",
  "progress": { "stage", "message", "done"?, "total"?, "updatedAt" } | null,
  "output": { "frames": [{ "id", "kind": "image", "url", "use": { "segmentRole", … } }],
              "missing": [{ "role", "label", "error" }] },
  "error", "createdAt", "finishedAt"
} } }
POST/api/v1/images/app-screenAPI key

The persona holding a phone (or beside a laptop) with your app's screenshot on it, as a job (202).

Request

{
  "personaSlug": "string",
  "appId"?: "uuid (one of your /api/v1/apps)", "screen"?: 0 (which of its screens) | "screenUrl"?: "https://… (a hosted screenshot)",
  "appName"?: "string", "device"?: "mobile | desktop",
  "line"?: "≤ 180 chars — what they'll say, kept for animating"
}

Response

{ "ok": true, "data": { "jobId", "existing": false } }
GET/api/v1/images/app-screen/{id}API key

Poll an app-screen job. When done, output.frames[0].url is the image.

Response

{ "ok": true, "data": { "job": { "id", "status", "progress",
  "output": { "frames": [{ "id", "kind": "image", "url", "use": { "line", "prompt", "seconds" } }] },
  "error", "createdAt", "finishedAt" } } }
POST/api/v1/images/burn-captionAPI key

Burn a TikTok-style caption into an image. Admin only (403 otherwise). Nothing is saved.

Request

{ "imageUrl": "https://…", "text": "string" }   // or multipart { image: File, text }

Response

{ "ok": true, "data": { "mimeType", "filename", "base64" } }

Writing

The Script engine: every writer in the app. Pass a brief and the copy is written to its goal, platform, product facts and audience, then checked — banned register, the close the platform allows, line length, a headline figure no line says — with one targeted repair.

POST/api/v1/topicsAPI key

Topic ideas a creator is credible posting — for a carousel, a short-form video or a talking-head clip — each with a TikTok search phrase to study what already wins.

personaSlug shapes the ideas to the creator; nicheCategory (and a subNiche inside it) keeps every idea in that niche; seed is your own idea to develop. A goal and a saved productId set the product up without naming it. With a niche (picked, or read from the creator) up to eight of its trending topics (GET /api/v1/trends) reach the writer — trending: mix (default) uses one where it fits, prefer makes at least half the ideas from them, off leaves them out — and an idea on one carries trend.

Request

{
  "medium"?: "carousel | video | talking",
  "personaSlug"?: "string (yours)",
  "nicheCategory"?: "beauty-skincare | fitness | travel | … (the niche playbooks)",
  "subNiche"?: "string", "seed"?: "string",
  "focus"?: "free-text subject; overrides nicheCategory/subNiche",
  "goal"?: "reach | saves | comments | follows | clicks | sales",
  "platform"?: "tiktok | reels | shorts | meta-paid | tiktok-paid",
  "audience"?: "string", "product"?: "string", "productId"?: "uuid",
  "language"?: "string", "count"?: 3-12,
  "trending"?: "mix | prefer | off",
  "accountId"?: "uuid (a posting account: its brand rules shape the ideas; one with a never-say phrase is dropped)"
}

Response

{ "ok": true, "data": { "topics": [{ "title", "angle", "searchQuery"?, "trend"?: { "source": "tiktok | search", "why" } }] } }
GET/api/v1/hooksAPI key

The hooks that worked in a niche — how posts that beat their creator's usual views opened, as recipes to open a run on or to test against each other.

From the same library (the Hooks tab on /topics), best evidence first: multiple is the post's views over the creator's usual. preset puts the hooks that workflow makes first. Open a run on one with POST /api/v1/flows and options: { opening: recipe }; test two or three with POST /api/v1/hook-tests. Without a niche: every niche's counts and its top three. Writers are offered their niche's hooks by themselves when a run has no opening.

Request

?niche=relationships&preset=text-message-video&limit=30   // limit 1-30 (30)

Response

{ "ok": true, "data": { "generatedAt", "niche", "counts": { "hooks", "posts" },
  "hooks": [{ "recipe": "if your situationship does [this], it's over", "onScreen", "spoken", "family", "structure",
              "preset", "recipeKey", "medium", "formatSlug", "multiple", "posts", "via": "post | format",
              "whatToSteal", "whatNotToCopy", "example": { "url", "platform", "creator", "handle", "postedAt" } | null }] } }
// no niche → { "generatedAt", "niches": [{ "niche", "hooks", "posts", "top": [ …three hooks ] }] }
POST/api/v1/scripts/generateAPI key

Talking-head scripts: one candidate per viral structure (three by default).

format is a video format (talking-5, full-viral, story-pov…). With a brief, structures are picked for its goal (and from your recorded outcomes); mode: "exploit" repeats one structure so candidates differ by hook. workflowSlug sets the angle (creator or testimonial). Save one with POST /api/v1/saved-scripts and shoot it with …/send.

Request

{
  "niche": "string",
  "format": "talking-5 | full-viral | story-pov | …",
  "personaSlug"?: "string (yours)",
  "variations"?: 2-6,
  "workflowSlug"?: "creator-ugc-video | testimonial-video | …",
  "brief"?: {
    "goal": "reach | saves | comments | follows | clicks | sales",
    "platform": "tiktok | reels | shorts | meta-paid | tiktok-paid",
    "niche"?: "what it's about", "nicheCategory"?: "a niche playbook id", "subNiche"?: "string",
    "product"?: "string",
    "productId"?: "uuid (one of your /api/v1/props — its facts reach the writer)",
    "audience"?: "string", "language"?: "string"
  },
  "mode"?: "explore | exploit", "exploitStructure"?: "string"
}

Response

{ "ok": true, "data": {
  "scripts": [{ "structureType", "family", "hookText", "claimLimit", "productEntrySegment", "whyItWorks",
                "segments": [{ "segmentRole", "spokenLine", "onScreenText", "visualDirection" }] }],
  "structures": [ … ],
  "brief"?: { … }
} }
POST/api/v1/scripts/hooksAPI key

Alternative openings for a script whose body stays fixed — the cheapest lever once a script exists.

Request

{ "script": { "structureType", "hookText", "segments": [ … ] }, "brief": { … },
  "count"?: 5, "personaName"?: "string", "toneOfVoice"?: "string" }

Response

{ "ok": true, "data": { "variants": [{ "hookText", "spokenLine", "family", "note" }] } }
POST/api/v1/scripts/critiqueAPI key

Score one talking-head script before anything is rendered, with a rewrite of its weakest segment.

Request

{
  "niche", "format", "hookText",
  "segments": [{ "segmentRole", "spokenLine", "onScreenText" }],
  "brief"?: { … }, "structureType"?: "string", "claimLimit"?: "string"
}

Response

{ "ok": true, "data": { "critique": {
  "score", "verdict", "strengths": [ … ], "issues": [ … ], "weakestIndex", "weakestRewrite",
  "dimensions"?, "sendTarget"?, "leavePoints"?, "productEntrySegment"?, "bannedClaims"?, "fix"?, "seed"?
} } }
With a brief the rubric is goal-weighted and score is the weighted composite of dimensions.
POST/api/v1/scripts/rankAPI key

Rank up to 8 candidate scripts side by side in one comparative call (scoring each alone bunches the results).

Request

{ "niche", "format", "candidates": [{ "structureType"?, "hookText", "segments": [ … ] }] }

Response

{ "ok": true, "data": { "ranking": { "order": [2, 0, 1], "entries": [{ "index", "rank", "score", "verdict" }] } } }
POST/api/v1/scripts/reviseAPI key

Apply a plain-English change to a script (“make it shorter”), then the shared copy check.

Request

{ "instruction": "string", "hookText", "segments": [ … ], "niche"?, "personaName"?, "toneOfVoice"? }

Response

{ "ok": true, "data": { "script": { "hookText", "segments": [ … ], "note"? } } }
POST/api/v1/scripts/translateAPI key

Re-deliver a script for another platform — same idea, the hook rebalanced, the close obeying the new surface. Not a language translation (that's localize).

Request

{ "script": { … }, "brief": { … }, "toPlatform": "tiktok | reels | shorts | meta-paid | tiktok-paid" }

Response

{ "ok": true, "data": { "translation": { "hookText", "hookSecondLine"?, "segments": [ … ], "changes": [ … ] }, "toPlatform" } }
POST/api/v1/scripts/localizeAPI key

A script, slide set or caption in another language — same structure, figures in digits, the product name verbatim.

Request

{ "script": { … }, "format": "talking-5 | …", "language": "Spanish", "product"?: "string" }
// or any copy:
{ "texts": ["string"], "language": "string", "product"?: "string", "medium"?: "carousel slides" }

Response

{ "ok": true, "data": {
  "script" | "texts": …,
  "unresolved": [{ "index", "missingNumbers": ["60"], "productDropped": false }]
} }
POST/api/v1/scripts/long-formAPI key

A one-to-four-minute monologue for one voice — the lip-sync video's script — capped at the render's 620 words.

Request

{
  "seconds": 60 | 120 | 180 | 240,
  "personaSlug"?: "string (yours — its tone of voice)",
  "brief": {
    "goal": "reach | saves | comments | follows | clicks | sales",
    "platform": "tiktok | reels | shorts | meta-paid | tiktok-paid",
    "niche"?: "what it's about", "nicheCategory"?: "a niche playbook id", "subNiche"?: "string",
    "product"?: "string",
    "productId"?: "uuid (one of your /api/v1/props — its facts reach the writer)",
    "audience"?: "string", "language"?: "string"
  },
  "productId"?: "uuid"
}

Response

{ "ok": true, "data": { "title", "paragraphs": ["string"], "script": "string", "words": 0, "brief": { … } } }
POST/api/v1/scripts/ad-hooksAPI key

Ad hooks by angle — Problem, Curiosity, Social Proof, POV, Bold Claim — each with its spoken line, overlay and opening shot.

Request

{
  "product": "string", "audience"?: "string", "tone"?: "string", "perAngle"?: 1-8,
  "brief"?: {
    "goal": "reach | saves | comments | follows | clicks | sales",
    "platform": "tiktok | reels | shorts | meta-paid | tiktok-paid",
    "niche"?: "what it's about", "nicheCategory"?: "a niche playbook id", "subNiche"?: "string",
    "product"?: "string",
    "productId"?: "uuid (one of your /api/v1/props — its facts reach the writer)",
    "audience"?: "string", "language"?: "string"
  },
  "productId"?: "uuid"
}

Response

{ "ok": true, "data": { "hooks": [{ "angle", "hookText", "onScreenText", "openingVisual" }], "brief"?: { … } } }
POST/api/v1/scripts/ad-scriptAPI key

A 30- or 60-second UGC ad in timed scenes (hook → problem → demo → proof → CTA), opening on a chosen hook.

Request

{
  "product": "string", "audience"?: "string",
  "angle": "Problem-Solution | Transformation | Founder Story | Comparison | Unboxing/Demo",
  "length": "30s | 60s",
  "platform": "TikTok | Instagram Reels | YouTube Shorts | Meta Feed",
  "hook"?: "string — scene 1 says it word for word",
  "brief"?: {
    "goal": "reach | saves | comments | follows | clicks | sales",
    "platform": "tiktok | reels | shorts | meta-paid | tiktok-paid",
    "niche"?: "what it's about", "nicheCategory"?: "a niche playbook id", "subNiche"?: "string",
    "product"?: "string",
    "productId"?: "uuid (one of your /api/v1/props — its facts reach the writer)",
    "audience"?: "string", "language"?: "string"
  },
  "productId"?: "uuid"
}

Response

{ "ok": true, "data": {
  "title", "totalDuration",
  "scenes": [{ "timestamp", "section", "narration", "onScreenText", "shotDescription", "bRoll" }],
  "hook", "beats": [ … ], "cta"?, "brief"?
} }
POST/api/v1/scripts/captionAPI key

The post caption — text, hashtags, an AI disclosure line and a pinned reply — for a script, or for any finished video in your gallery.

Request

{ "asset": { "source": "mascot_videos | videos", "id": "uuid (yours)" },
  "platform"?: "instagram | facebook | tiktok | youtube" }
// or a studio script:
{ "script": { … }, "brief": { … }, "personaName"?: "string" }

Response

{ "ok": true, "data": {
  "caption": { "text", "hashtags": ["string"], "disclosure", "pinnedReply"? },
  "provenance"?: { … }
} }
POST/api/v1/phrasesAPI key

The spoken line for an animated clip, in one of your personas' voices: five variants, or one.

Request

{
  "personaSlug": "string",
  "count"?: 1 | 5,
  "structureTypes"?: ["Micro-Reveal Hook", …],   // at most two for five variants
  "preset"?: { "label", "actionDescription" }     // the action the line should suit
}

Response

{ "ok": true, "data": { "phrases": [{ "phrase", "structureType" }] } }
GET/api/v1/scripts/vaultAPI key

Your language vault — buyer phrases mined from reviews and comments, which the writers pull hooks from. ?nicheCategory=&product=

Response

{ "ok": true, "data": { "entries": [{ "id", "product", "niche_category", "phrase", "theme", "emotion",
  "source", "rating", "status": "candidate | testing | purchased", "occurrences", "created_at" }] } }
POST/api/v1/scripts/vaultAPI key

Mine a paste of reviews or comments (40–40,000 chars) into verbatim phrases, and keep them unless save is false.

Request

{ "text": "string", "kind"?: "reviews | comments", "product"?: "string", "nicheCategory"?: "string", "save"?: true }

Response

{ "ok": true, "data": { "entries": [{ "phrase", "theme", "emotion", "rating", "occurrences" }], "saved", "nicheCategory" } }
DELETE/api/v1/scripts/vaultAPI key

Delete a phrase: body { id } or ?id=.

Response

{ "ok": true, "data": { "id" } }

Carousels & story ads

The writers behind the viral-tiktok-carousel and animated-story-ad workflows, callable on their own.

POST/api/v1/carousel/hooksAPI key

Cover hooks for a photo carousel across angles, scored best first. The picked one locks slide 1 of a run (options.lockedCover).

Request

{
  "topic": "string", "formatId"?, "audience"?, "productName"?, "personaName"?, "count"?, "language"?,
  "avoid"?: ["hooks already used"], "reference"?: { /* a format from /carousel/autopsy */ },
  "brief"?: { … }, "productId"?: "uuid"
}

Response

{ "ok": true, "data": { "hooks": [{ "text", "angle", "score", "rationale" }] } }
POST/api/v1/carousel/autopsyAPI key

Clone a winning format: paste a winning slideshow's text (one line per slide) and get its DNA back — the carousel case of POST /api/v1/formats/references.

Request

{ "reference": "slide 1\nslide 2\n…", "topic"?, "productName"?, "language"? }

Response

{ "ok": true, "data": { "format": { "hookShape", "slideCount", "structure", "saveTrigger",
  "productSlot", "proof"?, "visualSwitch"?, "commentTrigger"? }, "referenceId" } }
Hand format to /carousel/hooks as reference, and to a run as options.reference with options.slideCount. It's kept in your reference formats (GET /api/v1/formats) under referenceId; the same pasted text twice is one entry.
POST/api/v1/story-ad/hooksAPI key

Alternative openings for an animated-story-ad script — each a full replacement for scene 1. Feed one to POST /api/v1/flows/{id}/variant.

Request

{ "script": { /* ≥ 2 scenes */ }, "count"?: 3-10, "brief"?, "productName"?, "productId"? }

Response

{ "ok": true, "data": { "hooks": [{ "narration", "action", "visual", "angle", "colorState", "stake" }] } }
POST/api/v1/story-ad/critiqueAPI key

Score a story-ad script against the format's rules before the preview spends narration, image and Veo credits.

Request

{ "script": { "premise", "character", "scenes": [{ "role", "narration", "action", "visual", "shot",
  "angle", "colorState", "transitionOut", "showsProduct", … }] } }

Response

{ "ok": true, "data": { "score", "verdict", "strengths": [ … ], "issues": [ … ], "weakestIndex",
  "weakestRewrite": { "narration", "action" } | null } }

Briefs & saved scripts

A brief says what a piece is for, who it's for and what it sells — saved once, picked by every workflow. Saved scripts are your own library: edit, shoot, and record how each did once posted.

POST/api/v1/briefs/generateAPI key

A brief from a product link or a one-line description: the product, the audience, a creator recommendation (with your best-matching persona), a format and a per-segment script.

Request

{ "mode": "url" | "description", "value": "string (url ≥ 8 chars, description ≥ 12, ≤ 4000)" }

Response

{ "ok": true, "data": { "brief": { "input", "product", "persona": { "recommendation", "resolved" },
  "format", "script", "structureType"?, "scriptBrief"?, "scene", "generatedAt" } } }
502 when the page can't be read or the model fails. Save its fields with POST /api/v1/briefs.
GET/api/v1/briefsAPI key

Your saved briefs, newest first (at most 100).

Response

{ "ok": true, "data": { "briefs": [{ "id", "title", "draft", "createdAt", "source" }] } }
POST/api/v1/briefsAPI key

Save a brief (201). A niche or a product is required. Pass its id as briefId to POST /api/v1/flows.

Request

{
  "draft": { "goal"?: "reach (default) | saves | comments | follows | clicks | sales",
             "platform"?: "tiktok (default) | reels | shorts | meta-paid | tiktok-paid",
             "niche"?, "nicheOverride"?, "subNiche"?, "product"?, "productId"?, "audience"?, "language"? },
  "title"?: "string (defaults to the product, else the niche)"
}

Response

{ "ok": true, "data": { "id", "title", "draft", "createdAt", "source" } }
GET/api/v1/briefs/{id}API key

One saved brief.

Response

{ "ok": true, "data": { "id", "title", "draft", "createdAt", "source" } }
DELETE/api/v1/briefs/{id}API key

Delete a saved brief.

Response

{ "ok": true, "data": { "id" } }
GET/api/v1/saved-scriptsAPI key

Your scripts library, newest-updated first (at most 200). A script sent to a workflow reads its status from the run.

Response

{ "ok": true, "data": { "scripts": [{ "id", "title", "brief", "format", "structure_type", "family",
  "status": "draft | queued | rendering | done | failed", "persona_slug", "run_id", "video_url",
  "created_at", "updated_at", "hookText", "score" }] } }
POST/api/v1/saved-scriptsAPI key

Save a script (201). brief and script are what POST /api/v1/scripts/generate returns; the segment count must match the brief's format.

Request

{ "brief": { … }, "script": { … }, "title"?, "personaSlug"?, "hookVariants"?, "candidates"?, "critique"?, "caption"? }

Response

{ "ok": true, "data": { /* the saved script, as GET /{id} */ } }
GET/api/v1/saved-scripts/{id}API key

One saved script. A script in a run has its status (and video_url once cut) refreshed from the run.

Response

{ "ok": true, "data": { "id", "title", "brief", "format", "structure_type", "family", "script",
  "hook_variants", "candidates", "critique", "caption", "status", "persona_id", "persona_slug",
  "workflow_slug", "run_id", "video_job_id", "video_url", "created_at", "updated_at" } }
PATCH/api/v1/saved-scripts/{id}API key

Edit a draft or failed script. Changing the brief's format needs a script with the matching segment count.

Request

{ "title"?, "personaSlug"?, "brief"?, "script"?, "hookVariants"?, "candidates"?, "critique"?, "caption"? }

Response

{ "ok": true, "data": { /* the updated script */ } }
DELETE/api/v1/saved-scripts/{id}API key

Delete a saved script (a run it was sent to keeps going).

Response

{ "ok": true, "data": { "id" } }
POST/api/v1/saved-scripts/{id}/sendAPI key

Shoot a saved script: starts a run of its video workflow with the script already written (201). It stops at the priced preview like every run.

Request

{
  "personaSlug"?: "string (required when the script has none)",
  "workflowSlug"?: "creator-ugc-video (default) | testimonial-video | …",
  "burnCaptions"?: false, "sameFrame"?: true, "anchorImageUrl"?: "https://…"
}

Response

{ "ok": true, "data": { "runId" } }
The workflow's angle must match the script's. 400 when it's already in a run.
GET/api/v1/saved-scripts/{id}/shoot-briefAPI key

The script as a markdown brief a human creator can film from: the hook word for word, timings, pacing, the product moment.

Response

{ "ok": true, "data": { "markdown", "filename" } }
GET/api/v1/saved-scripts/{id}/outcomesAPI key

What the script did once posted, newest first.

Response

{ "ok": true, "data": { "outcomes": [{ "id", "platform", "window_label", "posted_at", "goal",
  "structure_type", "hook_family", "metrics", "note", … }] } }
POST/api/v1/saved-scripts/{id}/outcomesAPI key

Record the posted numbers (201). They feed the writer: structures that beat your median get picked more.

Request

{
  "metrics": { "views"?, "reach"?, "likes"?, "comments"?, "shares"?, "saves"?, "follows"?,
               "avgWatchPct"?: 0-100, "clicks"?, "purchases"? },   // at least one
  "window"?: "24h | 7d | adhoc", "platform"?: "string", "postedAt"?: "ISO",
  "skepticismComments"?, "intentComments"?, "note"?
}

Response

{ "ok": true, "data": { /* the outcome */ } }
Re-recording a window replaces it. Purchases promote the vault phrases the script spoke.
GET/api/v1/saved-scripts/outcomes/summaryAPI key

What has worked for you for one goal — medians and hit rates per structure. ?goal= (required) &nicheCategory=

Response

{ "ok": true, "data": { "n", "scope": "niche | all-niches", "cohortMedian", "metric",
  "structures": [{ "structureType", "n", "median", "hitRate", "reliableMedian",
                   "status": "reference | trial | supported", "decay"? }],
  "exploitStructure", "skepticismRate" } }

Libraries

The shared, curated libraries: the formats library (the market's formats we can make, and your own), script examples (the structures storyboards start from), visual presets (looks for image generation) and the format vocabulary. Reads are open to every key; writes are admin only.

GET/api/v1/formatsAPI key

The formats library: the best of the Ideas app's library that ppl.studio can make (house formats: proven formats and strong candidates, carousels and videos) with the market's posts behind them, your own formats (winners you cloned), and the catalogue: every format ppl.studio can make or has from Ideas, in one list, tagged. ?niche=&preset= narrow the house formats to what a run could follow; ?niche=&productType=&tag=&origin=&preset= filter the catalogue.

Response

{ "ok": true, "data": {
  "house": { "generatedAt", "ideasRefreshedAt", "formats": [{ "key": "house:<slug>", "slug", "name", "summary", "medium", "recipeKey",
             "preset", "options", "readiness": "ready | close", "status": "proven | candidate | fading", "tier": "proven | strong",
             "freshness", "why", "creators", "bestMultiple", "viralCreators", "seriesCreators",
             "engagement": { "savesPerLike", "sharesPerLike", "commentsPerLike" }, "newestViralPost", "niches", "hookFamily",
             "hookRecipes", "beats", "whatToSteal", "whatNotToCopy", "audience",
             "examples": [{ "creator", "handle", "multiple", "views", "usualViews", "followers", "likes", "saves", "shares",
                            "comments", "sound", "photo", "slides", "hook", "hookRecipe", "url", "postedAt" }],
             "ours": { "verdict": "proven | weak", "multiple", "posts", "days", "niches", "why" } | null }] },
  "references": [{ "id", "source": "autopsy", "source_url", "name", "kind": "carousel | talking | chat | story | monologue | demo",
                   "preset", "medium", "recipe_key", "niche_categories", "format", "created_at" }],
  "catalog": [{ "key": "house:<slug> | chat:thread | deck:inside | ref:<id> …", "name", "summary", "origin": "ideas | ppl.studio | own",
                "tags": ["proven | strong | candidate | fading", "new", "proven-for-us | weak-for-us", "ideas | ppl.studio | own"],
                "tier", "niches", "productType": "none | app | prop | catalog | creator | recording", "paid", "medium",
                "preset", "presets", "recipeKey", "options", "body", "example": { "hook", "url", "note" } | null, "firstSeen",
                "href", "ours": { "posts", "multiple", "days" } | null, "ideasOurs": { "verdict", "multiple", "posts", "days", "niches", "why" } | null }] } }
Proven: viral — 5× the creator's usual views — for 3 creators, or for one running it as a series; strong: still ranking, and viral for 2 creators or with one 10× breakout. A run left to auto follows one that fits its workflow and niche, taking turns per options.account; ppl.studio's own formats are only the marked fallback. Start one on purpose with POST /api/v1/flows and options.houseFormat: "house:<slug>" — the writer follows its hook recipes, beats, structure, length, best example and never-copy rules (a key the library no longer holds is refused with a 400, never swapped for another pick); from one of your own with options.reference = its format, on its preset. An example is the market's post as facts and its address (TikTok or YouTube) — its pictures stay the creator's. The catalog holds every format once: the Ideas app's at every tier (candidates and fading ones too; any can be named, only proven and strong are followed on auto), ppl.studio's own (chat shapes, carousel formats, data-deck recipes, talking structures, story, lip-sync, demos) and yours. Each says the niches it suits (none: any), what it's made from (productType), whether its video is paid, and how to start it (options, and a whole POST /api/v1/flows body). New: first seen in the last 14 days. Proven for us: its posts on your accounts got 2× the account's usual views or more over 4 posts on 3 days; weak for us: 0.5× or less over 6 posts — the Ideas app's verdict for its formats (ideasOurs; a weak one is never picked on auto in the niches it was weak in), ppl.studio's ledger (ours) for the rest. tag takes several (tag=proven,new): an entry carries all of them.
POST/api/v1/formats/referencesAPI key

Clone a winning format of any kind: paste a winner's words and get its DNA back, kept in your formats.

Request

{ "kind"?: "carousel | talking | chat | story | monologue | demo", "text": "the winner's words",
  "topic"?, "productName"?, "language"?, "sourceUrl"? }

Response

{ "ok": true, "data": { "kind", "format": { "structure", "hookShape"?, "proof"?, "visualSwitch"?, "saveTrigger"?,
  "commentTrigger"?, "productSlot"?, "length"?, "avoid"?, "slideCount"? }, "referenceId", "reference": { "id", "name", "kind", "preset", … } } }
Paste a carousel's slide text, a talking-head video's transcript and on-screen text, a chat's hook and messages, a story's narration or a demo's lines (up to 8,000 characters; split several winners with a line of --- and what repeats is kept). Follow it with POST /api/v1/flows on reference.preset and options.reference = its format (a carousel also options.slideCount). The same text twice is one entry. Rate limit: scripts-analyze.
DELETE/api/v1/formats/references/{id}API key

Retire one of your reference formats — it leaves the library; runs that followed it keep their copy.

Response

{ "ok": true, "data": { "retired": true } }
GET/api/v1/formats/vocabAPI key

The format vocabulary: every recipe ppl.studio makes, with the run option that selects it, plus the media, structures, hook families and niches formats are labelled in. Keys are stable.

Response

{ "ok": true, "data": { "version", "media", "presets", "talkingFormats", "structures", "carouselFormats", "chatFormats",
                          "deckRecipes", "hookFamilies", "niches",
                          "recipes": [{ "key": "chat:thread", "family", "id", "label", "medium", "presets",
                                        "option": { "key", "value" }, "hookFamily", "signal", "status": "live | demoted" }] } }
Also public, without a key, at /formats-vocab.json.
GET/api/v1/formats/evidenceAPI key

How each recipe and format is doing on one of your accounts: ?goal=reach&accountId=&platform=tiktok&niche=&subNiche=&keys=chat:thread,carousel:myth-bust.

Response

{ "ok": true, "data": { "accountId", "platform", "goal", "metric", "nicheCategory", "subNiche", "scored", "proxied", "unscored",
  "keys": [{ "key": "chat:thread", "n", "hits", "hitRate", "medianScore", "reliableScore", "tier": "reference | trial | supported", "decay"?,
             "prior": { "source": "sub-niche | niche | all | market | none", "p", "posts"?, "accounts"? },
             "posterior": { "alpha", "beta", "mean", "low", "high" } }] } }
A post's score is its goal metric ÷ its account's median over the previous 20 posts at the same age (24 h or 7 d); a hit is 1 or more. Posts are scored once the account has 5 earlier readings — numbers come from the post ledger. Without accountId, all your accounts on the platform. keys adds formats you haven't posted, with their prior only. Priors pool other accounts in the niche (at least 5 accounts and 3 users) — scores only, never anyone's posts or numbers. Paused for now: feedbackPaused: true, nothing new is counted and runs don't pick from it.
GET/api/v1/scriptsAPI key

The curated script-examples library.

Response

{ "ok": true, "data": { "scripts": [{ "id", "title", "script_type", "frames": [ … ], … }] } }
GET/api/v1/scripts/{id}API key

One script example.

Response

{ "ok": true, "data": { /* the example */ } }
POST/api/v1/scriptsAPI key

Create a script example (201). Admin only.

Request

{ "title", "frames": [{ "caption", "visual_description", "use_character" }],
  "script_type"?: "single_image | carousel | video", "source_url"?, "source_platform"?, "viral_notes"?, "preset_id"? }
DELETE/api/v1/scripts/{id}API key

Delete a script example. Admin only.

Response

{ "ok": true, "data": { "id", "deleted": true } }
POST/api/v1/scripts/from-mediaAPI key

A script-example draft from an uploaded video, image or carousel screenshots — frames, plus an autopsy for a single video. Admin only; nothing is saved.

Request

multipart/form-data: files (one or more, in order) or file, title?, sourcePlatform?, niche?

Response

{ "ok": true, "data": { "script": { "title", "source_url", "source_platform", "viral_notes",
  "frames": [{ "caption", "visual_description", "use_character" }],
  "autopsy"?, "hook_text"?, "hook_spoken"?, "hook_verified"?, "inspected"?, "checked_at"? } } }
GET/api/v1/visual-presetsAPI key

The visual-preset library — each look's prompt, clothing and background lines.

Response

{ "ok": true, "data": { "presets": [{ "id", "title", "prompt", "description", "clothing", "background", "image_url" }] } }
POST/api/v1/visual-presetsAPI key

Create a visual preset (201). Admin only; image_url pre-hosted.

Request

{ "title", "prompt", "image_url", "description"?, "clothing"?, "background"? }
DELETE/api/v1/visual-presets/{id}API key

Delete a visual preset. Admin only.

Response

{ "ok": true, "data": { "id", "deleted": true } }

Storyboards

A sequence of frames — caption, visual description, image — for carousels and character stories. A frame has no id: it's addressed by its index, and every write answers with the whole storyboard so you see the new indices.

GET/api/v1/storyboards/presetsAPI key

The viral storyboard presets.

Response

{ "ok": true, "data": { "presets": [{ "id", "name", "description", "category", "icon",
  "frames": [{ "caption", "visual_description", "use_character"? }] }] } }
GET/api/v1/storyboardsAPI key

Your storyboards, newest first.

Response

{ "ok": true, "data": { "storyboards": [{ "id", "name", "persona_id", "image_ids", "frames", "created_at", "updated_at" }] } }
POST/api/v1/storyboardsAPI key

Make a storyboard (201) — empty, from gallery images, from a first and last frame, from a preset, or from a script example.

Request

{ "from": "empty", "name"?, "personaSlug"? }
{ "from": "images", "imageUrls": ["gallery image URLs"], "name"?, "personaSlug"? }
{ "from": "prompt", "firstFrameText", "lastFrameText", "totalFrames"?: 3 | 5 | 7 | 9, "name"?, "personaSlug"? }
{ "from": "preset", "presetId", "name"?, "personaSlug"? }
{ "from": "script" | "inspired-by-script", "scriptId": "a script example (GET /api/v1/scripts)", "name"?, "personaSlug"? }

Response

{ "ok": true, "data": { "id", "storyboard": { "id", "name", "persona_id", "image_ids", "frames", "created_at", "updated_at" },
  "resolvedFrames": [{ "frame": { "caption"?, "visual_description"?, "image_id"?, "is_first"?, "is_last"?,
                                  "use_character"?, "use_previous_image"? }, "image": { "id", "url", … } | null }],
  "personaSlug": "string | null",
  "framesNeedingImage": [2, 3] } }
The AI kinds (prompt, script, inspired-by-script, and preset with a persona) take one text rate-limit slot and run synchronously (~5–30 s).
GET/api/v1/storyboards/{id}API key

One storyboard with its frames resolved to images. framesNeedingImage lists frames with a visual description and no image.

Response

{ "ok": true, "data": {
  "storyboard": { "id", "name", "persona_id", "image_ids", "frames", "created_at", "updated_at" },
  "resolvedFrames": [{ "frame": { "caption"?, "visual_description"?, "image_id"?, "is_first"?, "is_last"?,
                                  "use_character"?, "use_previous_image"? }, "image": { "id", "url", … } | null }],
  "personaSlug": "string | null",
  "framesNeedingImage": [2, 3]
} }
PATCH/api/v1/storyboards/{id}API key

Rename it, or link a persona (null detaches).

Request

{ "name"?: "string | null", "personaSlug"?: "string | null" }

Response

{ "ok": true, "data": { "id", …the GET shape } }
DELETE/api/v1/storyboards/{id}API key

Delete a storyboard (its images stay in your gallery).

Response

{ "ok": true, "data": { "id", "deleted": true } }
POST/api/v1/storyboards/{id}/fillAPI key

Write frames with AI: rewrite them from a first and last frame, or fill every empty frame from the frames around it.

Request

{ "mode": "prompt", "firstFrameText", "lastFrameText", "totalFrames"?: 3 | 5 | 7 | 9, "personaSlug"?: "string | null" }
{ "mode": "empty-frames" }

Response

{ "ok": true, "data": { "id", …the GET shape } }
One text rate-limit slot per call; synchronous (~5–30 s).
POST/api/v1/storyboards/{id}/imagesAPI key

Append gallery images as new frames, in order (repeats and images already on it are skipped).

Request

{ "imageUrls": ["gallery image URLs"] }

Response

{ "ok": true, "data": { "id", …the GET shape } }
POST/api/v1/storyboards/{id}/framesAPI key

Append a frame (201). An empty body adds an empty frame.

Request

{ "caption"?, "visual_description"?, "use_character"?: true, "use_previous_image"?: true, "image_url"?: "a gallery image URL" }

Response

{ "ok": true, "data": { "id", "index": 4, …the GET shape } }
PATCH/api/v1/storyboards/{id}/frames/{index}API key

Edit a frame. role marks it first (moved to 0) or last (moved to the end), or clears the mark.

Request

{ "caption"?, "visual_description"?, "use_character"?, "use_previous_image"?,
  "image_url"?: "a gallery image URL | null", "role"?: "first | last | null" }   // at least one

Response

{ "ok": true, "data": { "id", "index": "its index afterwards", …the GET shape } }
PUT/api/v1/storyboards/{id}/frames/orderAPI key

Reorder: order[i] is the current index of the frame that should end up at i — every index once. First/last frames stay pinned.

Request

{ "order": [2, 0, 1] }

Response

{ "ok": true, "data": { "id", …the GET shape } }
POST/api/v1/storyboards/{id}/frames/{index}/generateAPI key

Make one frame's image (no body): continues the story from an earlier frame's image, or renders its visual description with the linked persona.

Response

{ "ok": true, "data": { "id", "index", "generated": true, "imageUrl", …the GET shape } }
Synchronous, ~15–60 s; one image slot. To make every missing image, call it for each index in framesNeedingImage, in order, stopping at the first failure.

Workflows

Every workflow is a preset of one flow of bricks — brief, cast, write, voice, picture, the preview, animate, cut, deliver. Start a run, follow it, fix what you don't like before the preview, then approve: video only ever starts after that, unless the run was started straight through.

GET/api/v1/presetsAPI key

Every workflow's preset: its steps, what a run needs, the options its steps read, and an example POST /api/v1/flows body.

Response

{ "ok": true, "data": {
  "presets": [{ "slug", "title", "guide", "guideUrl", "handsOff",
    "steps": [{ "index", "gate": false, "key", "brick", "variant", "title", "label", "cost", "needs", "makes", "options" }
             | { "index", "gate": true, "title" }],
    "setup": { "brief": "full | product-only | none", "creator" | "product" | "appScreen" | "recording": "required | optional | none",
               "topic", "gate", "video": { "engine": "veo", "tier" } | { "engine": "lipsync" } | null },
    "options": [{ "key", "type", "values"?, "default"?, "description", "readBy", "presetValue"? }],
    "example": { /* a POST /api/v1/flows body */ } }],
  "brief": { /* the brief's fields */ }, "body": { /* the POST /api/v1/flows body */ }
} }
GET/api/v1/presets/{slug}API key

One workflow's preset (404 for an unknown slug).

Response

{ "ok": true, "data": { "preset": { … }, "brief": { … }, "body": { … } } }
POST/api/v1/flowsAPI key

Start a run of a workflow (201). Hands-off by default: each step starts when the one before it is ready, up to the preview.

preset is a workflow slug: creator-ugc-video, testimonial-video, tiktok-reels-short-form, app-demo-video, product-demo-video, lipsync-ugc-video, animated-story-ad, drama-song-ad, viral-tiktok-carousel, text-message-video, screen-recording-video (your own recording in recordingUrl, a payout screenshot in proofUrl), the photo packs (product-ugc-photos, amazon-listing-pack, shopify-product-pack, before-after-photo-pack) or storyboard. Give the brief filled in, a saved briefId, or a link / description in briefSource — the run reads it first. A body missing what the preset needs (its setup) is a 400. straightThrough: true makes the video without stopping at the preview, for this run only.

Request

{
  "preset": "creator-ugc-video",
  "brief"?: { "niche", "goal", "platform", "product"?, "productId"?, "audience"?, "language"?, "nicheOverride"?, "subNiche"? },
  "briefSource"?: { "mode": "url" | "description", "value": "string" },
  "briefId"?: "uuid (a saved brief)",
  "creatorSlug"?: "string (yours)",
  "productId"?: "uuid (one of your /api/v1/props)",
  "appId"?: "uuid (one of your /api/v1/apps — app-demo-video)", "appScreen"?: 0, "appScreenUrl"?: "https://… (or a screenshot)",
  "appScreens"?: [0, 2, 3] (2–4 of the app's screens, in order: a line and a clip each; options.lines types them),
  "recordingUrl"?: "https://… (screen-recording-video: your MP4 / MOV / WebM, ≤ 3 min — upload it first)", "proofUrl"?: "https://… (optional: the payout screenshot, the cover)",
  "options"?: { "line"?, "seconds"?, "voice"?, "tier"?: "lite | fast | full", "captions"?, "hookFirst"?, "account"?,
                "opening"?: "the hook to open on, the topic in [brackets]", … },
  // The data carousels (whats-inside-carousel, app-carousel, catalog-carousel) — free, no preview:
  //   "options": { "source": { "kind": "prop", "propId" } | { "kind": "apps", "appIds": [ … ] } | { "kind": "catalog", "catalogId" }
  //                  | { "kind": "inline", "name", "url"?, "box"?, "items": [{ "key", "title", "price"?, "worth"?, "rating"?, "images": ["https://…"], … }] } (a box or a list, handed in whole),
  //                "recipe"?: "inside | worth-it | app-list | app-review | whats-new | app-how-to | top | under | new-in | on-sale | brand | versus | gift-guide | tiers | resale | editions",
  //                "deckParams"?: { "count", "maxPrice", "brand", "audience", "itemKeys", "theme", "steps", "ownSlot", "days" },
  //                "houseFormat"?: "house:<a list, ranking or side-by-side format>",
  //                "coverPhoto"?, "coverQuery"?, "headline"?, "kicker"?, "outro"?, "itemLabel"?, "lead"?, "worthLabel"?, "shop"? }
  "auto"?: true,
  "straightThrough"?: false,
  "name"?: "string"
}

Response

{ "ok": true, "data": { "runId": "uuid" } }
Video steps need the Creator plan and your own Gemini key. GET /api/v1/presets lists every option. options.opening — a hook from GET /api/v1/hooks, or a hook test's winner — is what the writer builds the first line, cover or headline on; a running hook test on options.account sets it instead.
GET/api/v1/flowsAPI key

Your runs, newest first, with where each stands. ?limit= (1–200, default 50).

Response

{ "ok": true, "data": { "runs": [
  { "runId", "name", "preset", "state": "working | waiting | failed | done",
    "awaiting": "gate | hook | input | retry | null", "step": "Frames | null",
    "video": "url | null", "thumb": "url | null", "createdAt", "finishedAt" }
] } }
GET/api/v1/flows/{id}API key

One run: every step and its state, what it made, the preview's price, its jobs — and next, the call that moves it on.

Response

{ "ok": true, "data": {
  "runId", "name", "preset": { "slug", "title", "guide" },
  "steps": [{ "index", "gate", "key", "brick", "variant", "title", "cost",
              "status": "todo | running | ready | approved | failed | waiting", "error"?, "jobId"? }],
  "current": 4, "awaiting": "gate | hook | input | retry | null", "auto": true, "straightThrough": false,
  "brief", "cast", "script": { "id", "variant", "lines", "hook", "meta" },
  "takes": [ … ], "frames": [{ "id", "kind", "url", "use": { "line", "seconds" }, "meta" }], "clips": [ … ], "cut": { … } | null,
  "estimate": { "usd": 0.4, "seconds": 8, "clips": 2, "engine": "veo", "tier": "lite" },
  "hookFirst", "hookApprovedAt", "clipsFailed", "aspect", "jobs": [ … ], "finishedAt",
  "next": { "action": "done | approve | retry | start-step | wait", "message", "step"?, "call"? }
} }
PATCH/api/v1/flows/{id}API key

Hands-off on or off; straight through the preview, for this run (403 without Creator and your Gemini key).

Request

{ "auto"?: boolean, "straightThrough"?: boolean }

Response

{ "ok": true, "data": { "runId", "outcome": { … } | null } }
DELETE/api/v1/flows/{id}API key

Delete a run you own. What it made stays in your gallery.

Response

{ "ok": true, "data": { "runId" } }
POST/api/v1/flows/{id}/approveAPI key

Approve what the run waits on: the preview (awaiting gate) — the one door to video, at the server's price — or, past it, the hook clip (awaiting hook).

Response

{ "ok": true, "data": { "runId", "outcome": { "state": "running", "step": 5 } } }
With 3 or more clips the hook clip renders first and the run waits with awaiting: "hook" — approve again for the rest (or remake the hook first with /steps). Start with options.hookFirst: false to render every clip at once. 400 when nothing waits; 403 without Creator and your Gemini key.
POST/api/v1/flows/{id}/stepsAPI key

Start a step by hand — the next one, or a failed one again — or make one item of it again (a take, a frame, a shot, a slide, a clip) (201).

Request

{ "step": 3, "item"?: 1 }

Response

{ "ok": true, "data": { "jobId": "uuid" } }
Before the preview is approved, anything before it can be made again; nothing past it starts until it's approved.
POST/api/v1/flows/{id}/scriptAPI key

Change the run's script before any video. What it makes stale (frames, takes, the story's character, a chat's recording) is made again.

Request

{ "kind": "talking", "script": { … } }   // a runner-up (script.meta.runnersUp[n]) or edited lines
{ "kind": "slides", "slides": [{ "role": "cover | body | cta", "caption", "visualPrompt",
                                 "featuresCreator"?, "render"?: "image | template" }] }
{ "kind": "story", "script": { … } }     // a narrated story (script.meta.script edited)
{ "kind": "chat", "thread": { "hook", "timestamp"?, "messages": [{ "from": "me | them", "text" }] } }

Response

{ "ok": true, "data": { "scriptId" } }
Refused once the preview is approved.
POST/api/v1/flows/{id}/framesAPI key

Put a photo from your gallery in place of the run's picture at index at — a pack shot, a slide, a storyboard frame, or a video frame before approval.

Request

{ "at": 0, "url": "https://… (a gallery image)" }

Response

{ "ok": true, "data": { "frameId" } }   // swapped at once
{ "ok": true, "data": { "jobId" } }     // a persona's frame is redone from the photo — poll the job
To make a frame again from scratch instead: /steps with { step, item: at }.
POST/api/v1/flows/{id}/captionAPI key

The post's caption, written from what the run made — a video's from its script, a carousel's from its slides. Nothing is stored.

Response

{ "ok": true, "data": { "caption": { "text", "hashtags", "disclosure" } } }
Same daily limit as the caption writer (429).
POST/api/v1/flows/{id}/variantAPI key

An Animated Story Ad again as a new run with another opening (201) — only the opening is made, after its own preview.

Request

{ "opening": { "narration", "action", "visual", "angle", "colorState", … } }   // from /api/v1/story-ad/hooks

Response

{ "ok": true, "data": { "runId", "kept": 3 } }   // kept: clips carried over
POST/api/v1/flows/randomAPI key

Create random UGC: a Creator UGC Video run on a topic picked for the persona, optionally starting from one of their photos (201).

Request

{ "personaSlug": "string", "anchorImageUrl"?: "https://… (a gallery photo)" }

Response

{ "ok": true, "data": { "runId" } }

Schedules & posting

Workflows that start themselves on a timetable — a product-feed carousel every Wednesday — the finished work waiting to be posted, for whatever posts it (a phone, a scheduler, a person), and the post ledger: what went out, on which account, made from what, and the numbers typed from each platform's insights — paused for now: recording and numbers answer 409. Only workflows that generate nothing paid can be scheduled — the data carousels and text-message videos; anything that makes pictures or video is refused, so nothing spends unattended.

POST/api/v1/schedulesAPI key

Schedule a workflow (201). Each run starts hands-off; `rotate` lays one option set over the setup per run, in turn. Up to 20.

Request

{
  "preset": "catalog-carousel",
  "name"?: "string",
  "weekdays"?: [3] (0 = Sunday … 6; none = every day), "hour"?: 9 (UTC),
  "setup": { "productId"?, "creatorSlug"?, "appId"?, "briefId"?, "brief"?, "options"?,   // as POST /api/v1/flows
             "topicSource"?: "trending" },   // a new trending topic each run (a text-message video)
  "rotate"?: [{ "deckParams": { "audience": "her" } }, { "deckParams": { "audience": "him" } }],
  "enabled"?: true
}

Response

{ "ok": true, "data": { "id", "preset", "name", "setup", "rotate", "weekdays", "hour", "enabled", "next_run_at",
                          "last_run_at", "last_run_id", "last_error", "runs_made" } }
setup.topicSource: "trending" — for a workflow that writes from a topic — gives every run a trending topic in the schedule's niche (brief.nicheOverride, else the niche of the posting account in options.account; see GET /api/v1/trends) that its last 20 runs didn't use: it goes into that run's options.topic and brief, and the setup stays as saved. The brief then needs no topic of its own.
GET/api/v1/schedulesAPI key

Your schedules.

Response

{ "ok": true, "data": { "schedules": [ … ] } }
PATCH/api/v1/schedules/{id}API key

Change name, weekdays, hour, setup, rotate or enabled — the next start is worked out again.

Response

{ "ok": true, "data": { /* the schedule */ } }
POST/api/v1/schedules/{id}/runAPI key

Start its next turn now (201); the timetable is unchanged.

Response

{ "ok": true, "data": { "runId" } }
DELETE/api/v1/schedules/{id}API key

Delete a schedule; the runs it made stay.

Response

{ "ok": true, "data": { "id", "deleted": true } }
GET/api/v1/posts/readyAPI key

Finished work nobody has posted yet, newest first: ?limit=20&since=<ISO time>.

Response

{ "ok": true, "data": { "posts": [{ "runId", "clientRef", "preset", "title", "name", "kind": "carousel | video | photos",
                                   "finishedAt", "images" (a carousel's slides, in order), "video", "caption" (hashtag-free),
                                   "hashtags", "sound", "aiGenerated", "scheduleId" }] } }
POST/api/v1/flows/{id}/postedAPI key

Report where a run went out — it leaves the ready list and lands in the post ledger. One call per platform.

Request

{ "url": "https://www.tiktok.com/@you/photo/…", "platform"?: "tiktok", "handle"? | "accountId"? }

Response

{ "ok": true, "data": { "posted": [{ "url", "platform", "at" }], "postId" } }
The account is read from a TikTok address when neither is given. Log the post's numbers with POST /api/v1/posts/{postId}/readings.
GET/api/v1/postsAPI key

The post ledger, newest first: ?accountId=&runId=&platform=&limit=50&before=<ISO time>. Removed posts are left out.

Response

{ "ok": true, "data": { "posts": [{
  "id", "account_id", "platform", "post_url", "platform_post_id", "posted_at", "status", "via", "run_id", "asset_id",
  "recipe_key": "chat:thread | carousel:myth-bust | talking:confession | …", "format_key", "medium", "goal",
  "niche_category", "sub_niche", "structure_type", "hook_family", "hook_text", "topic", "caption", "sound",
  "readings": [{ "id", "window_label": "24h | 7d | adhoc", "age_h", "metrics", "note", "captured_at" }],
  "due": ["24h" | "7d"]   // old enough to read, no numbers yet
}] } }
POST/api/v1/postsAPI key

Record something that went out (201). What made it — recipe, hook, topic, goal, niche — is taken from the work now and kept. The same address twice is one post. Paused for now: answers 409.

Request

{
  "url": "https://…",                  // required unless status "queued"
  "platform"?: "tiktok | instagram | facebook | youtube | pinterest",   // read from the url
  "accountId"? | "handle"?,            // a TikTok url names its own
  "runId"? | "assetId"? | "source"?: { "table": "videos | mascot_videos | assets", "id" },
  "postedAt"?, "caption"?, "sound"?, "status"?: "posted | queued", "externalRef"?
}

Response

{ "ok": true, "data": { /* the post */ } }
A run is better reported with /api/v1/flows/{id}/posted, which also takes it off the ready list.
GET/api/v1/posts/{id}API key

One post with its readings and what's due.

Response

{ "ok": true, "data": { /* the post, readings, due */ } }
PATCH/api/v1/posts/{id}API key

Change status, url, postedAt or accountId (null for the platform's unnamed account). Status removed takes it out of the list and the results.

Request

{ "status"?: "posted | queued | failed | removed", "url"?, "postedAt"?, "accountId"? }

Response

{ "ok": true, "data": { /* the post */ } }
POST/api/v1/posts/{id}/readingsAPI key

A post's numbers, typed from the platform's insights (201). Typing the same window again replaces it; a field left out stays unknown, never zero. Paused for now: answers 409.

Request

{ "window"?: "24h | 7d | adhoc", "ageH"?: 26 (hours since posting — sets the window),
  "metrics": { "views", "likes"?, "comments"?, "shares"?, "saves"?, "avgWatchPct"?, "follows"?, "clicks"?, "reach"?, "purchases"? },
  "note"? }

Response

{ "ok": true, "data": { "id", "window_label", "age_h", "source": "manual", "metrics", "captured_at" } }
views is required. Read at 24 hours and at 7 days — a reading 12–48 h in counts as 24h, from 5 days as 7d.
GET/api/v1/accountsAPI key

Your posting accounts — one platform, one handle. Results are kept per account.

Response

{ "ok": true, "data": { "accounts": [{ "id", "platform", "handle", "niche_category", "sub_niche", "connection_id",
                                      "phone_account_id", "external_id", "is_house", "rules", "created_at" }] } }
POST/api/v1/accountsAPI key

Add a posting account (201) — or get the existing one: handles match by letters and digits, so @advent_center and advent.center are one account.

Request

{ "platform": "tiktok | instagram | facebook | youtube | pinterest", "handle", "nicheCategory"?, "subNiche"? }

Response

{ "ok": true, "data": { /* the account */ } }
Connecting a Facebook Page or Instagram account makes one; recording a post with a handle does too.
PATCH/api/v1/accounts/{id}API key

Change handle, nicheCategory or subNiche — what the account is about — or its brand rules.

Request

{ "handle"?, "nicheCategory"?, "subNiche"?, "isHouse"?,
  "rules"?: { "never"?: string[], "tone"?: string, "notes"?: string } }

Response

{ "ok": true, "data": { /* the account */ } }
rules is replaced whole (send {} to clear): up to 40 never phrases the copy may not contain (checked, and reworded when a draft has one), a tone, and other rules in notes, one per line. Every writer follows them when a run or brief names the account (options.account or brief.accountId). isHouse marks one of ppl.studio's own accounts — admins only (403 otherwise).
GET/api/v1/formats/resultsAPI key

How posts on your house accounts did, newest first — what the Ideas app reads back for its formats' “our results”. ?since=<ISO time> keeps posts with a reading captured since.

Response

{ "ok": true, "data": { "generatedAt", "posts": [{ "postId", "url", "platform", "account": "@handle", "postedAt",
  "recipeKey", "formatKey": "house:<slug> | null", "goal", "readings": [{ "window": "24h | 7d", "capturedAt", "metrics" }],
  "accountMedianViews", "score", "scoredAt": "24h | 7d | null" }] } }
Only accounts marked isHouse. score is the post's goal metric ÷ the account's median over its previous 20 posts at the same age, null until the account has 5 earlier readings; accountMedianViews is that usual number. Paused for now: feedbackPaused: true, no new readings come in.
POST/api/v1/hook-testsAPI key

Start a hook test (201): one format held still on one posting account while two or three hooks take turns opening its runs.

Request

{ "accountId", "preset": "text-message-video | viral-tiktok-carousel | creator-ugc-video | tiktok-reels-short-form |
            animated-story-ad | lipsync-ugc-video | screen-recording-video",
  "arms": [{ "shape": "if your situationship does [this]…", "family"?, "source"?, "example"? }, …]   // 2–3
  "goal"?: "reach | saves | comments | follows | clicks | sales", "name"?, "minPosts"?: 3, "maxPosts"?: 8 }

Response

{ "ok": true, "data": { "id", "accountId", "preset", "goal", "name", "arms": [{ "id": "A | B | C", "shape", "family", "source", "example" }],
                   "status": "running", "winner": null, "minPosts", "maxPosts", "decidedAt", "createdAt" } }
While it runs, every run of that workflow made for the account (options.account) takes the arm with the fewest runs, and its writer builds the opening on the arm's shape — the topic goes in the [brackets]. A post reported from the run carries its arm. One running test per account and workflow (409 otherwise). Hold everything else still: same format, same posting times.
GET/api/v1/hook-testsAPI key

Your hook tests: ?accountId=&status=running|decided|stopped.

Response

{ "ok": true, "data": { "tests": [ /* as above */ ] } }
GET/api/v1/hook-tests/{id}API key

One test with its results per arm and what they say.

Response

{ "ok": true, "data": { /* the test */, "results": [{ "armId", "runs", "posts", "scored", "hits", "medianScore",
  "posterior": { "mean", "low", "high" }, "pBest" }],
  "decision": { "state": "collecting | winner | tie", "winner"?, "reason" } } }
A post is scored like the formats' results: its goal metric against the account's usual views at the same age; a hit beats the usual. An arm wins once every arm has minPosts scored posts and it is at least 90% likely the best; with every arm at maxPosts and none that clear, it's a tie.
PATCH/api/v1/hook-tests/{id}API key

Stop a test, or promote its winner — which then opens the account's runs of that workflow (options.opening).

Request

{ "status": "stopped" } | { "status": "decided", "winner": "A | B | C" }

Response

{ "ok": true, "data": { /* the test */ } }

Engine — bricks & jobs

The eight bricks every workflow is built from, callable one job at a time — the queue the Voice, Editor, Animate and Picture pages use.

GET/api/v1/bricksAPI key

The brick catalog: every variant, what it costs and needs, and the JSON Schema of its input.

Response

{ "ok": true, "data": {
  "bricks": ["brief", "cast", "write", "voice", "picture", "animate", "cut", "deliver"],
  "variants": [{ "brick", "variant", "key", "label", "cost": "free | cheap | video", "needs", "makes",
                 "lane": "io | cpu", "usesVeo", "maxAttempts", "runnable", "fromRun", "inputSchema", "schemaError"? }]
} }
runnable variants start as jobs below; fromRun ones read their input from a run — start those with POST /api/v1/flows/{id}/steps. cost: video needs Creator and your Gemini key; usesVeo jobs render one at a time per user.
POST/api/v1/bricks/jobsAPI key

Start one brick as a job (201; 200 with existing: true when that run already has it running).

Request

{ "brick": "voice", "variant": "string", "input": { /* matches the variant's inputSchema */ },
  "runId"?: "uuid", "op"?: "run | redo", "item"?: 0 }

Response

{ "ok": true, "data": { "jobId", "existing": false } }
Video bricks: 403 without Creator and your Gemini key. Rate-limited text bricks answer 429. A chat you already have becomes a video with cut / chat and { "thread": { "hook", "messages": [{ "from": "me | them", "text" }] }, "seconds"?, "theme"? } — no model, no cost.
GET/api/v1/bricks/jobsAPI key

?runId= — a run's jobs; or ?standalone=1&brick=&variant=&status=&limit= — your recent jobs (status: comma-separated, or live).

Response

{ "ok": true, "data": { "jobs": [{ "id", "brick", "variant", "runId", "status", "progress", "output", "error", "createdAt", "finishedAt" }] } }
GET/api/v1/bricks/jobs/{id}API key

Poll one job until done, failed or cancelled. What it made is in output and in /api/v1/assets.

Response

{ "ok": true, "data": { "id", "brick", "variant", "runId",
  "status": "queued | running | waiting | done | failed | cancelled",
  "progress": { "stage", "message", "done"?, "total"?, "updatedAt" }, "output", "error", "createdAt", "finishedAt" } }
DELETE/api/v1/bricks/jobs/{id}API key

Stop a job that hasn't finished.

Response

{ "ok": true, "data": { "cancelled": true } }   // false when it had already ended

Videos

Standalone video jobs — enqueue, then poll the statusUrl or take a callback. A whole video from a brief is a workflow run (above).

POST/api/v1/videos/assembleAPI key

Concat up to 8 clip URLs into one 9:16 MP4, with optional burned captions and upscale (202).

upscaleTo: 1080p (default) / 4k / none. For captions set burnSubtitles and pass per-segment subtitleSegments[] (preferred) or one subtitleText. Each clip is transcribed: captions follow the real word timings, every clip is cut a quarter second after its last spoken word (never below 4 s), and audio is loudness-normalized to −16 LUFS.

Request

{
  "sourceVideoUrls": ["https://… .mp4", "…"],   // 1–8, in order
  "burnSubtitles"?: boolean,
  "subtitleSegments"?: [{ "text", "durationSec", "style"?, "hookOverlay"? }],
  "subtitleText"?: "string",
  "upscaleTo"?: "1080p" | "4k" | "none",
  "callbackUrl"?: "https://…"
}

Response

{ "ok": true, "data": {
  "jobId": "uuid", "status": "queued",
  "statusUrl": "https://ppl.studio/api/v1/videos/assemble/{jobId}"
} }
GET/api/v1/videos/assemble/{id}API key

Poll an assemble job. The URLs fill in once status is done.

Response

{ "ok": true, "data": {
  "jobId", "status": "queued | running | done | failed",
  "combinedVideoUrl": "string | null", "subtitledVideoUrl": "string | null",
  "error": "string | null", "startedAt", "finishedAt"
} }
POST/api/v1/videos/upscaleAPI key

Re-scale any single video URL to 1080p, 2k or 4k (lanczos + sharpen) (202).

Request

{ "sourceVideoUrl": "https://… .mp4", "target"?: "1080p" | "2k" | "4k", "callbackUrl"?: "https://…" }

Response

{ "ok": true, "data": {
  "jobId": "uuid", "status": "queued",
  "statusUrl": "https://ppl.studio/api/v1/videos/upscale/{jobId}"
} }
GET/api/v1/videos/upscale/{id}API key

Poll an upscale job. videoUrl fills in once status is done.

Response

{ "ok": true, "data": {
  "jobId", "status": "queued | running | done | failed",
  "videoUrl": "string | null", "target": "1080p | 2k | 4k",
  "error": "string | null", "startedAt", "finishedAt"
} }

Music

Lyrics sung into a song — as a job; the result is a take in your gallery. Made on our side, free within a weekly allowance per user (429 past it).

POST/api/v1/songsAPI key

Sing lyrics into a song (201). Poll GET /api/v1/songs/{id}.

lyrics — a line per sung line, [Verse] / [Chorus] / [Bridge] on their own lines (or a blank line between verses) — in a genre (rnb, country, pop-ballad, soul) or your own style, with an optional singer. The model's padded intro and outro are cut, and each line is found in the song (the words from your lyrics, the times from Whisper). Takes 2–5 minutes.

Request

{ "lyrics": "[Verse]\nSaturday cookout…", "genre"?: "rnb", "style"?: "string", "singer"?: "string" }

Response

{ "ok": true, "data": { "id": "uuid", "kind": "song" } }
GET/api/v1/songs/{id}API key

Poll a song. song fills in once status is done.

Response

{ "ok": true, "data": {
  "id", "kind": "song", "status": "queued | running | waiting | done | failed | cancelled",
  "progress", "error",
  "song": { "assetId", "url", "seconds", "model",
            "lines": ["…"], "slots": [{ "start", "end" }], "words": [{ "line", "text", "start", "end" }],
            "found": 0.93, "trimmed": { "from", "to", "of" } | null } | null
} }
GET/api/v1/songsAPI key

The genres and the weekly allowance (seconds).

Response

{ "ok": true, "data": { "genres": [{ "id", "label" }], "weeklySeconds": 1200 } }

Social

Post finished gallery videos to your connected Facebook Pages and Instagram accounts, now or on a schedule. Connecting an account is an OAuth redirect in a signed-in browser — it can't be done with a key.

GET/api/v1/social/connectionsAPI key

Your connected accounts (never a token), and the URLs that connect or reconnect one in the browser.

Response

{ "ok": true, "data": {
  "connections": [{ "id", "platform": "facebook | instagram", "account_id", "display_name", "avatar_url",
                    "status": "active | needs_reauth", "last_error", "granted_scopes", "created_at", "updated_at" }],
  "facebookConfigured": true, "connectUrl", "reconnectUrl"
} }
DELETE/api/v1/social/connections/{id}API key

Disconnect an account — its token and share log go with it. Idempotent.

Response

{ "ok": true, "data": { "disconnected": true } }
POST/api/v1/social/facebook/shareAPI key

Share a gallery video to a connected Facebook Page as a Reel or video — published, or parked as a draft (201).

Request

{
  "connectionId": "uuid", "source": "videos | mascot_videos", "sourceId": "uuid",
  "format": "reel | video", "publishMode"?: "publish | draft",
  "message": "caption (may be empty)", "link"?: "https://…", "linkCta"?: "Learn more"
}

Response

{ "ok": true, "data": { "id", "platform", "format", "publish_mode", "status": "processing", "post_url",
  "error", "destination", "created_at", "link", "comment_id", "comment_error" } }
Meta transcodes after the upload — poll GET /api/v1/social/posts/{id} until status leaves processing. A published share gets the link as its first comment once live. 503 when the server has no Facebook app configured.
GET/api/v1/social/postsAPI key

Share history per gallery video, newest first. ?sourceId= narrows to one video.

Response

{ "ok": true, "data": { "sharesByVideoId": { "<sourceId>": [{ "id", "status": "processing | published | draft | failed", … }] } } }
GET/api/v1/social/posts/{id}API key

One share. While processing it asks Meta where it stands and records the outcome — poll every few seconds.

Response

{ "ok": true, "data": { "id", "status": "processing | published | draft | failed", "post_url", "comment_id", … } }
POST/api/v1/social/scheduledAPI key

Schedule a gallery video to post later (201) — 1 minute to 90 days ahead. Instagram connections only for now (400 otherwise).

Request

{
  "connectionId": "uuid", "source": "videos | mascot_videos", "sourceId": "uuid",
  "publishAt": "ISO date-time", "caption"?, "link"?, "linkCta"?
}

Response

{ "ok": true, "data": { "id", "platform", "status": "scheduled", "publishAt", "caption", "destination",
  "sourceTable", "sourceId", "lastError", "publishedAt", "postUrl" } }
GET/api/v1/social/scheduledAPI key

Your scheduled posts, soonest first (at most 200), including sent, failed and cancelled ones.

Response

{ "ok": true, "data": { "posts": [{ "id", "status": "scheduled | publishing | published | failed | cancelled", … }] } }
DELETE/api/v1/social/scheduled/{id}API key

Cancel a post that is still scheduled (400 once publishing has started).

Response

{ "ok": true, "data": { "cancelled": true } }

Free tools

Anonymous, rate-limited per IP — a minimum interval between calls (30–60 s) plus a daily cap (1–30/day), tuned per tool. JSON in, JSON out, no envelope, no key.

POST/api/tools/winning-ads-finder/generatePublic

Research live competitor ads in Meta Ad Library.

Request

{ "query": "string (brand or niche)", "type": "brand" | "niche" }

Response

{
  "ads": [{ "brand", "format", "hook", "body", "whyItWorks", "stealAngle" }],
  "adLibraryUrl": "string"
}
POST/api/tools/url-to-ad/generatePublic

Scrape a product URL, return 3 ad concepts.

Request

{ "url": "string (product PDP URL)" }

Response

{ "concepts": [{ "hook", "body", "cta", "visualDirection" }] }
POST/api/tools/hook-generator/generatePublic

Generate 25 hooks across 5 angle frames.

Request

{ "product": "string", "audience": "string", "tone": "string (default 'Casual')" }

Response

{
  "hooks": [{
    "angle": "Problem | Curiosity | Social Proof | POV | Bold Claim",
    "hookText",
    "onScreenText",
    "openingVisual"
  }]
}
POST/api/tools/ugc-script-generator/generatePublic

Full 30s or 60s UGC script.

Request

{
  "product": "string",
  "audience": "string",
  "angle": "Problem-Solution | Transformation | Founder Story | Comparison | Unboxing/Demo",
  "length": "30s | 60s",
  "platform": "TikTok | Instagram Reels | YouTube Shorts | Meta Feed"
}

Response

{
  "script": {
    "hook",
    "beats": [{ "timestamp", "narration", "onScreenText", "shot", "broll" }],
    "cta"
  }
}
POST/api/tools/photo-prompt-generator/generatePublic

Optimized Gemini / Nano Banana image prompt.

Request

{
  "productDescription": "string",
  "category": "beauty | skincare | fashion | food | tech | home | fitness | other",
  "scene": "lifestyle | studio | outdoor | ...",
  "persona": "string (descriptor)"
}

Response

{ "prompt": "string (copy-paste ready)" }
POST/api/tools/randomizer/generatePublic

Anonymous one-shot UGC photo. Consumes an anonymous slot per IP.

multipart/form-data — a product image plus the same fields as the dashboard photo generator.

Response

{ "success": true, "imageUrl": "string" }
POST/api/tools/headline-tester/generatePublic

Score a headline (clarity / specificity / emotion) and return stronger variants.

Request

{ "headline": "string", "product": "string", "audience": "string" }

Response

{
  "score": { "clarity", "specificity", "emotion", "overall", "diagnosis" },
  "variants": [{ "headline", "angle", "whyItWorks" }]
}
POST/api/tools/email-subject-line-generator/generatePublic

Subject lines + preview text for an email type.

Request

{ "emailType": "string", "offer": "string", "audience": "string", "tone": "string" }

Response

{ "subjects": [{ "subject", "preview" }] }
POST/api/tools/aov-booster-generator/generatePublic

Average-order-value tactics with copy snippets and expected lift.

Request

{
  "product": "string",
  "currentAov": "string",
  "targetAov": "string",
  "audience": "string",
  "blockers": "string"
}

Response

{
  "tactics": [{ "category", "name", "description", "copySnippet", "expectedLift", "setupNotes" }]
}
POST/api/tools/black-friday-promo-generator/generatePublic

Black Friday promo concepts — mechanic, headline, scarcity copy, email subject.

Request

{
  "brand": "string",
  "product": "string",
  "audience": "string",
  "aov": "string",
  "margin": "string",
  "tone": "string"
}

Response

{
  "promos": [{ "mechanic", "headline", "discountSummary", "scarcityCopy", "emailSubject", "notes" }]
}
POST/api/tools/creator-pitch-email-generator/generatePublic

Brand-partnership pitch email for a creator, with subject options and a follow-up.

Request

{
  "brand": "string",
  "brandWhy": "string",
  "yourName": "string",
  "portfolio": "string",
  "niche": "string",
  "proof": "string",
  "ask": "string"
}

Response

{ "subjectOptions": ["string"], "body": "string", "followUp": "string", "notes": ["string"] }