Current public API surface

Pamba API Docs

Build agent-driven workflows for AI avatar videos, TikTok research, video generation, scheduling, warming, and analytics with workspace-scoped API keys.

Authentication

Agents authenticate with X-API-Key. JWT callers include X-Workspace-Id on workspace routes.

Workspace-Scoped

API keys are tied to one workspace, so agents can create content, manage avatars, schedule posts, and pull analytics for that workspace.

Base URL

Use https://api.pamba.app. All content production routes require authentication.

Bulk project creation accepts date-only scheduling, such as 2026-05-18. Single post scheduling accepts date-only or ISO 8601 with timezone.

Credit Usage

Generation features are credit-metered: video clips, avatar photos, scene images, premium voices, and hook purchases. Basic voices are always free, and a user's very first avatar is free - its photos and scenes are not charged (premium voice still is). Fetch the current price list from GET /usage/pricing (public, no auth).

AI clip generation

POST /projects/{projectId}/generate-clip-videos, plus automated pipelines started from bulk project creation or automation restart/resume.

Per-model credits x generated video seconds - current per-model rates come from GET /usage/pricing. Credits are reserved for requested duration, then only successful generated clip seconds are charged.

Avatar photo generation

POST /avatars/generations whenever the avatar agent generates or edits an appearance image, and the avatar wizard appearance pass.

15 credits per generated image. Failed generations are not charged.

Scene generation

POST /avatars/projects/{projectId}/selected-avatar/starting-frames and .../starting-frames/generations, plus starter scenes auto-populated by avatar creation and the avatar wizard.

15 credits per generated image. Bulk jobs reserve avatars x references x results upfront and charge only successfully generated images; uploads of your own frames are free.

Basic voice

Every non-premium voice: the avatar wizard's voice step, GET/POST /avatars/projects/{projectId}/preset-voices(/select) on existing avatars, agent-driven avatar creation and redesign, POST /avatars/projects/raw, and selecting an avatar without a voice. Curated human roster voices matched to the avatar's gender and age (the API calls them preset voices).

Free. Browsing, rerolling (deeper/higher steering), and applying basic voices never charge.

Premium voice generation

POST /avatars/projects/{projectId}/premium-voice, and premium options in the avatar wizard (charged per clone generated).

1500 credits per voice clone. Covers the video-model speaking clips the voice is cloned from; the interim basic voice it replaces was free.

Hook video purchase

POST /hooks/purchase.

200 credits per hook. Already-purchased hooks are not re-charged.

Project creation, idea/script work, TikTok research/analytics, frame uploads and management, scheduling, warming, rendering existing edits, and editor-state saves are not separately credit-metered in the public API today. Metered endpoints respond 402 { error, remaining_credits, required_credits } when the balance is too low, and failed operations are never charged. Use GET /user to inspect subscription state and credit totals/usage.

What Agents Can Do

Track competitor accounts, filter their best videos, create ideas, save scripts, and start projects.

Bulk-create fully automated projects that generate clips, render, schedule, and post.

Manually drive a project through concept, clip generation, selected takes, render, and scheduling.

Upload a video you made yourself and schedule it to TikTok or Instagram, with no AI generation at all.

Configure avatar warming and monitor session status.

Pull TikTok analytics dashboards, tracked account metrics, and per-video history.

Public Endpoint Reference

Account

Read caller profile, subscription state, and credit totals.

GET/user

Current user profile, subscription state, and credit totals/usage.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

No body.

Response

200 { total_paid_monthly_credits, used_paid_monthly_credits, overdrawn_credits, total_coupon_credits, used_coupon_credits, unlimited_credits, subscribed_product_name, subscription_status, stripe_price_id, current_period_start, current_period_end (epoch seconds), email, email_verified, tours_seen: string[], success, message }. When unlimited_credits is true, credits are not enforced for this user in this workspace and the credit fields are not a meaningful balance.
POST/user/tours/seen

Record walkthrough tours the caller has seen.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

{ tours: string[] }, at most 32 names. Names are trimmed; a blank name or one over 64 characters is dropped.

Response

200 { success: true }. 400 { error } when tours has more than 32 entries.

Notes

Per user, across browsers and workspaces. Insert-only: a tour cannot be marked unseen. The seen list rides GET /user as tours_seen. Names are not checked against the web app's registry, so an unknown name is stored and inert.

POST/user/resend-verification-email

Ask Auth0 to send the caller another verification email.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

No body.

Response

200 { success: true, message }. 500 when Auth0 refuses the send.

Notes

Blocks on two Auth0 Management API calls (a token grant, then the verification-email job). The email goes to the caller's login address. The handler does not check email_verified first; Auth0 decides whether to send.

GET/user/notification-preferences

The caller's email notification toggles.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

No body.

Response

200 { preferences: { posting_activity, weekly_digest, account_and_credits, tips_and_onboarding } }, one boolean per category. A category never set reports true.

Notes

Per user, not per workspace. posting_activity covers the failed-posts digest and TikTok account-health notices; weekly_digest the Monday recap and the niche discover digest; account_and_credits purchased accounts ready, low balance, credit-wall follow-ups and failed profile edits; tips_and_onboarding the onboarding nudges and the first-post-live milestone. Yearly renewal notices always send and have no toggle.

PUT/user/notification-preferences

Turn notification categories on or off.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

{ preferences: { <category>: boolean } }, any subset of posting_activity, weekly_digest, account_and_credits, tips_and_onboarding.

Response

200 { preferences } with every category's state after the write. 400 { error } on an unknown category; nothing is written on a 400.

Notes

Categories left out keep their value.

Workspaces and Members

Create and configure workspaces, manage their members, and invite people by email.

GET/workspaces

List the workspaces the caller belongs to.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

No body.

Response

200 { workspaces: [{ id, name, niche_description, website_url, company_profile: { company_name, what_they_do, products_and_services, target_audience, brand_tone, logo_url }, owner_user_id, timezone, is_default, role: admin | member, member_count, created_at }], active_workspace_id }, the caller's default workspace first.

Notes

role is the caller's role in that workspace. active_workspace_id is the caller's default workspace, not the one X-Workspace-Id names. An API key lists the workspaces of the user who created it.

POST/workspaces

Create a workspace with the caller as its owner and admin.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

{ name, timezone?, setup?, website_url? }. name is 1 to 255 characters and required unless setup is "light". timezone is an IANA zone id; an invalid one is dropped, not refused. website_url only applies with setup "light".

Response

201 { id, name, owner_user_id, timezone, is_default: false, role: "admin", member_count: 1, created_at }. 400 when name is missing or over 255 characters, when website_url is not a valid address, or when setup is anything other than "light".

Notes

The new workspace is never the caller's default; a JWT caller sends X-Workspace-Id with the new id to work in it. setup "light" creates the workspace with the web app's two-step setup wizard pending (website, then brand): the name defaults to the site's host, then "New workspace", and the site is not analyzed at creation. Without setup the workspace is bare.

PATCH/workspaces/{workspaceId}

Rename a workspace or change its niche description.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; { name?, niche_description? }, at least one.

Response

200 { success: true }. 400 when neither field is sent; 403 { message } unless the caller is an admin of the workspace (a member gets 403 "Only admins can perform this action", a non-member 403 "Not a member of this workspace").

Notes

Admin only. A new name also becomes company_profile.company_name. A new niche_description starts the workspace's niche refresh in the background (search terms, corpus sweep, feed verdicts).

DELETE/workspaces/{workspaceId}

Soft-delete a workspace.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; no body.

Response

200 { success: true }. 400 { message } when the workspace is the caller's default; 403 { message } unless the caller is the workspace's owner or an admin member (a member gets 403 "Only admins can delete workspaces").

Notes

The owner may delete whatever their role; other members need the admin role. The caller's default workspace cannot be deleted. The row is soft-deleted and drops out of GET /workspaces.

PUT/workspaces/{workspaceId}/timezone

Set the workspace's timezone.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; { timezone }, an IANA zone id such as America/New_York.

Response

200 { timezone }. 400 { message } on an invalid zone id or a malformed workspaceId; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action").

Notes

Admin only. The timezone is the workspace's clock for the posting grid, campaign cycles and analytics day buckets. Scheduled campaign fire times are recomputed before the response; analytics rollups are rebuilt in the background. A workspace with no timezone is treated as America/Los_Angeles.

PUT/workspaces/{workspaceId}/website

Set the workspace's website and analyze it into a company profile.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; { website_url }.

Response

200 { website_url, niche_description, company_profile: { company_name, what_they_do, products_and_services, target_audience, brand_tone, logo_url }, analysis_succeeded }. 400 { message } when the address is not valid or workspaceId is malformed; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action").

Notes

Admin only. Blocks while the site is fetched and analyzed: the fetch allows 10 seconds to connect and 15 seconds per request over up to 5 redirects, and the profile extraction is one Gemini call with a 90 second timeout and up to 3 attempts, so allow up to about 6 minutes in the worst case. analysis_succeeded false means the URL was stored but the site could not be read (unreachable, blocked, parked, a challenge page, a private or internal host, or extraction failed) and the stored profile and niche are unchanged. On success the profile is replaced (an existing logo_url is kept) and niche_description is refreshed unless it was hand-written, which starts the workspace's niche refresh in the background.

PUT/workspaces/{workspaceId}/company-profile

Edit fields of the workspace's company profile.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; { what_they_do?, products_and_services?, target_audience?, brand_tone?, logo_url? }, at least one. An omitted field keeps its stored value; a blank string clears it. Each field is at most 2000 characters; logo_url must be an http(s) URL.

Response

200 { company_name, what_they_do, products_and_services, target_audience, brand_tone, logo_url }, the merged profile. 400 { message } when nothing is sent, a field is over 2000 characters, logo_url is not http(s), workspaceId is malformed, or the workspace does not exist; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action").

Notes

Admin only. company_name is not editable here: it follows the workspace name (PATCH /workspaces/{workspaceId}). This is the only hand-edit path for brand_tone and logo_url; the other fields are also written by the website analysis.

GET/workspaces/members

List the members of the active workspace.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

No body.

Response

200 [{ id, user_id, email, role: admin | member, is_default, joined_at }].

Notes

Any member can list. The active workspace is the API key's workspace, the one X-Workspace-Id names, or the caller's default workspace when a JWT sends no header. id is the membership row; user_id is what the member routes take. is_default says whether this workspace is that member's default.

POST/workspaces/{workspaceId}/members

Add an existing Pamba user to a workspace.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; { email, role? }, role admin | member (default member).

Response

201 { id, user_id, email, role, is_default: false, joined_at }. 400 { message } on an invalid role, when no Pamba user has that email, or when the user is already a member; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action").

Notes

Admin only. Adds the user directly, with no invitation and no email. For someone without a Pamba account, use POST /workspaces/{workspaceId}/invitations.

PATCH/workspaces/{workspaceId}/members/{userId}

Change a member's role.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID, userId: UUID }; { role }, admin | member.

Response

200 { success: true }. 400 { message } on an invalid role; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action"), or when userId is the workspace owner and role is member.

Notes

Admin only. The owner is always an admin and cannot be demoted. A userId that is not a member returns 200 with nothing changed.

DELETE/workspaces/{workspaceId}/members/{userId}

Remove a member from a workspace.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID, userId: UUID }; no body.

Response

200 { success: true }. 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action"), or when userId is the workspace owner.

Notes

Admin only. The owner cannot be removed. A userId that is not a member returns 200 with nothing changed. A removed user left with no workspace gets a new personal workspace so they can still sign in; one left without a default gets one of their remaining workspaces as default.

GET/workspaces/{workspaceId}/invitations

List a workspace's pending invitations.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; no body.

Response

200 { invitations: [{ id, email, role, status: "pending", created_at, expires_at }] }. 400 when workspaceId is malformed; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can manage invitations").

Notes

Admin only. Pending, unexpired invitations only; accepted, revoked and expired ones are not listed.

POST/workspaces/{workspaceId}/invitations

Invite someone to a workspace by email.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID }; { email, role? }, role admin | member (default member).

Response

201 { id, email, role, status: "pending", created_at, expires_at, invitee_has_account, email_sent, invite_url }. 400 { message } on an invalid email or role, when the email already belongs to a member, when the workspace has 100 pending invitations, or when it created 30 invitations in the last hour; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can manage invitations").

Notes

Admin only. The email is lowercased. The invitee receives the link by email; email_sent false means the mail failed but the invitation stands, so pass on invite_url (https://pamba.app/invite/{token}) yourself. The raw token appears only in this response. Re-inviting a pending email refreshes that invitation's link and expiry instead of creating a second one, and does not count toward the caps. Invitations expire 14 days after they are sent. The invitee accepts with POST /workspaces/invitations/{token}/accept; a signup through the link joins the workspace as the new user's default.

DELETE/workspaces/{workspaceId}/invitations/{invitationId}

Revoke a pending invitation.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { workspaceId: UUID, invitationId: UUID }; no body.

Response

200 { success: true }. 400 when invitationId is malformed; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can manage invitations"); 404 { message } when the workspace has no pending invitation with that id.

Notes

Admin only. A revoked link no longer accepts. Only pending invitations can be revoked, so a second call for the same id is 404.

POST/workspaces/invitations/{token}/accept

Accept an invitation and join its workspace.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Path { token }, the token from the invite link (https://pamba.app/invite/{token}); no body.

Response

200 { workspace_id, already_member }. 410 { message } when the token is unknown, expired, revoked, or was accepted by another account.

Notes

The token is the capability: any signed-in account holding the link may accept, whatever email the invitation named, and the accepting account is the one recorded. The caller joins with the invitation's role. Re-accepting, or accepting while already a member, reports already_member: true. The status flip and the join are one transaction, so a failed accept does not consume the token. The workspace does not become the caller's default; send X-Workspace-Id with workspace_id to work in it.

API Keys

Create and revoke the workspace API keys that authenticate X-API-Key calls.

GET/api-keys

List the caller's active API keys.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ id, key_prefix, name, workspace_id, workspace_name, created_at, last_used_at }].

Notes

Lists the keys the calling user created, across every workspace, not the active workspace's keys; revoked keys are left out. key_prefix is the first 8 characters of the key. An API-key caller lists the keys of the user who created that key.

POST/api-keys

Create an API key for the active workspace.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ name }.

Response

201 { id, key, key_prefix, name, workspace_id, created_at }.

Notes

key is the full 40-character hex key and is shown only in this response: the server stores a hash and cannot return it again, so save it. The key is bound to the active workspace (X-Workspace-Id for a JWT caller, the calling key's workspace for an API-key caller) and acts as the creating user on every route. It stops working once revoked or once its creator is no longer a member of that workspace. Any member can create keys.

DELETE/api-keys/{keyId}

Revoke an API key.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { keyId: UUID }; no body.

Response

200 { success: true, message }. 400 when keyId is not a UUID; 404 { success: false, message } when the key does not exist, was created by another user, or is already revoked.

Notes

Only the user who created a key can revoke it. Revocation takes effect immediately.

Help and Support

Read the public help center and send a message to the Pamba team.

GET/help/articles

List every help-center article.

Authentication

Public. No authentication required.

Request

Optional query include=body to add each article's markdown body.

Response

200 { articles: [{ slug, title, category, category_title, order, summary, video, body }], categories: { <category slug>: title } }, sorted by category, order, then title.

Notes

body is null unless include=body, and stays null for articles that opted out of search even then. video is the article's video URL or null. Category slugs: getting-started, creating-videos, avatars, publishing-scheduling, analytics, workspaces-billing, troubleshooting, developers, hidden-gems. Each article is readable at https://pamba.app/help/{slug}.

GET/help/articles/{slug}

Read one help-center article with its body.

Authentication

Public. No authentication required.

Request

Path { slug }, lowercase kebab-case; no body.

Response

200 { slug, title, category, category_title, order, summary, video, body } with the full markdown body. 404 { message } for an unknown slug.
POST/support/requests

Send a message to the Pamba team.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ message, contact_email? }. message is 1 to 5000 characters after trimming. contact_email defaults to the caller's account email.

Response

201 { message, request_id, contact_email }. 400 { message } when message is empty or over 5000 characters, when contact_email is not a valid address, or when it is omitted and the account has no valid email on file; 429 { message } with a Retry-After header in seconds when the workspace sent 3, the caller sent 5, or the whole platform sent 100 requests in the last 24 hours.

Notes

The request is stored, then relayed to the Pamba team by email in the background, so the response never waits on mail. The team replies to contact_email. Retry-After is at least 60 seconds and at most 24 hours. Rate-limited requests are not stored.

Onboarding

The signup wizard as an API: brand, website, presenter avatar, first video concepts, plan.

GET/onboarding

Read the workspace's onboarding state: status, step pointer, everything each step stored.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState { status: "in_progress" | "completed", flow: "full" | "light", current_step: "website" | "brand" | "media" | "first_video" | "avatar" | "plan" | "device", website_url, website_analysis_status: "analyzed" | "unreachable" | "blocked" | "parked" | "empty" | "failed" | null, website_analysis_detail, company_profile: { company_name, niche_description, what_they_do, products_and_services, target_audience, brand_tone, logo_url } | null, company_name, niche_description, avatar_generation_job_id, selected_avatar_project_id, first_video_topic, video_concepts: [{ title, hook, description, script, text_hook, video_project_id, selected }] | null, video_references: [{ tiktok_video_id, tiktok_url, cover_url, description, author_username, author_nickname, view_count, like_count, duration_seconds }] | null, video_concepts_stale, media_capture_status: "running" | "done" | "failed" | null, account_seats, device_step_skipped, plan_price_id, plan_credits, plan_step_skipped, referral_source, referral_source_detail, dismissed_at, completed_at, created_at }. 404 when onboarding was never started for the workspace, or the caller is not the user who started it (an invited member reads 404).

Notes

Every step route below returns this same OnboardingState, except POST /onboarding/plan/purchase and POST /onboarding/heal-bootstrap. current_step is the backend's pointer and only moves forward: completing a step sets it to the later of its current value and the step after the one completed (order: website, brand, media, first_video, avatar, plan, device), so re-submitting an earlier step updates its data without moving the pointer back, and completing a later step first skips over the earlier ones. flow "light" is the two-step setup of an added workspace (website, brand, then POST /onboarding/complete); it never researches concepts or asks for a plan. video_concepts_stale is true when the stored concepts and references were researched from a niche or brand the user has since changed: discard them and call POST /onboarding/video-concepts again. media_capture_status is the background website media capture PUT /onboarding/website starts; a capture still "running" after 10 minutes reads as "done". account_seats is the workspace's purchased account-seat count (POST /devices/accounts/seat-checkout), which is what resolves the accounts half of go-live. Reading the state may re-arm the one-time seeding of the content hub from the stored references.

PUT/onboarding/website

Website step: analyze the company site into a profile and advance to the brand step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "website_url": string }. The scheme is optional (https:// is assumed); the host must contain a dot; at most 2048 characters.

Response

200 OnboardingState with website_url, website_analysis_status and, when the site was read, company_profile plus prefilled company_name and niche_description; current_step is at least brand. 400 when the address is not a valid URL, or when a changed website_url cannot be read while brand details are already on file (the old details stay untouched). 404 when onboarding was never started.

Notes

Blocks on the site fetch (10s connect, 15s per hop, at most 5 redirects, 2 MB) and one model extraction call (90s per attempt, up to 3 attempts, with 2s, 5s and 10s waits on rate limits). Set a client timeout of several minutes; the usual case is seconds. A first analysis is best-effort: an unreadable site still stores the URL and advances, with the reason in website_analysis_status (unreachable, blocked, parked, empty, failed) and the page title or leading text the analyzer saw in website_analysis_detail, so the caller fills PUT /onboarding/brand by hand. Two concurrent requests for the same URL share one analysis. Side effects while onboarding is in progress: the profile is mirrored to the workspace branding, a website media capture starts in the background (media_capture_status; a changed site first clears the previous site's captured media, never uploads; a parked page is never captured), and in the full flow the first-video research and the presenter match start early from the analyzed niche. After completion the analysis is stored on the onboarding row only.

Example

curl -X PUT https://api.pamba.app/onboarding/website \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "website_url": "acme.com" }'
POST/onboarding/website/skip

Website step: skip the site analysis and advance to the brand step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with current_step at least brand. 404 when onboarding was never started.

Notes

Nothing is stored. The brand step then has no prefill, so PUT /onboarding/brand carries the name and niche by hand.

PUT/onboarding/brand

Brand step: confirm the company name and niche, which become the workspace name and niche.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "company_name": string, "niche_description": string, "what_they_do"?, "products_and_services"?, "target_audience"? }. company_name at most 255 characters, niche_description at most 2000; both are trimmed and required. The optional fields edit the analyzed profile's prose; absent or blank leaves what the analyzer wrote.

Response

200 OnboardingState with company_name and niche_description set and current_step at least media. 400 when company_name is empty or over 255 characters, or niche_description is empty or over 2000. 404 when onboarding was never started.

Notes

Renames the workspace and sets its niche description, whatever the caller's workspace role. When what_they_do differs from the stored profile, a model reconcile rewrites the hidden profile fields the edit contradicts (products, audience, tone) before the profile is stored: that is the one blocking model call here, up to 2 attempts under a 10-minute call ceiling each, best-effort (a failure stores the edit as-is). An explicit products_and_services or target_audience wins over the reconcile for its field. Side effects: the workspace's niche pipeline (search terms, sweep, verdicts) refreshes, the first-video research prefetch starts or restarts from the confirmed niche, and the presenter match restarts when the company, niche or what_they_do changed (an unchanged confirm joins the running match). A changed niche or brand makes stored concepts stale (video_concepts_stale).

POST/onboarding/media/complete

Media step: mark the media review done and advance to the first-video step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with current_step at least first_video. 404 when onboarding was never started.

Notes

The media step has no gate: reviewing the captured site media and uploading extras (POST /assets) are both optional, so this call serves both continue and skip. It does not wait for a running media capture.

GET/onboarding/presenter-options

Avatar step: the up to 3 niche-matched starter avatars offered as the presenter.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 Avatar[] (the same shape as GET /avatars: { id, project_id, name, gender, age_group, appearance_image_url, voice_sample_url, voice_description, accent, base_appearance_description, background_description, avatar_source, created_at, ... }). Empty while the match or its clones are still running, and when onboarding was never started.

Notes

Never blocks. The match starts in the background at PUT /onboarding/website or PUT /onboarding/brand (one model call), then the winning starter avatars are cloned into the workspace as ready avatars (avatar_source "starter"); poll this until it is non-empty. A failed match falls back to featured starters, so the list always resolves. An entry's project_id is the avatar_project_id for POST /onboarding/avatar/select. When a matched clone can never resolve, the list is topped up from the workspace's other ready avatars.

POST/onboarding/avatar

Avatar step: generate a new avatar from a description and advance past the avatar step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "description": string }, non-empty after trimming.

Response

200 OnboardingState with avatar_generation_job_id set and current_step at least plan. 400 when the description is empty, when 5 avatars are already generating in the workspace, or when the workspace lacks the credits (the message carries required and remaining). 404 when onboarding was never started.

Notes

Enqueues a background generation job and returns at once; poll GET /avatars/generations/{jobId} with avatar_generation_job_id, and its avatar_id / project id becomes the presenter once it lands. Credits: the caller's first avatar is free; afterwards the job is estimated at 90 credits (one appearance image plus 5 scenes at 15 each), charged as the job runs, with a preset voice (no premium clone from this route). The credit check answers 400 here, not 402. The instant alternative is POST /onboarding/avatar/select with one of the presenter options.

POST/onboarding/avatar/select

Avatar step: pick an existing ready avatar as the presenter and advance past the avatar step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "avatar_project_id": UUID }, a project_id from GET /onboarding/presenter-options or GET /avatars.

Response

200 OnboardingState with selected_avatar_project_id set and current_step at least plan. 400 when avatar_project_id is not a UUID, or when that project has no ready avatar in this workspace. 404 when onboarding was never started.

Notes

No model call and no credits. Re-picking after POST /onboarding/video-concepts/create-projects already created drafts rebinds each draft in place to the new avatar (speaker description, a scene from the new avatar's library, frames cleared), best-effort per draft.

POST/onboarding/plan/purchase

Plan step: start a Stripe subscription checkout for a monthly credit plan.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "price_id": string, "credits"?: number }. price_id is the plan's Stripe price id (must start with price_); credits is the plan's monthly amount, recorded on fulfillment for display only.

Response

200 { checkout_url: string }, the hosted Stripe Checkout page the user must open. 400 when price_id does not start with price_, when the workspace already has a recorded plan or an active credit subscription (manage it from Settings, Billing), when the workspace or the purchaser's email is missing, or when Stripe returned no URL. 404 when onboarding was never started.

Notes

Blocks on Stripe: the workspace's Stripe customer is looked up or created, then a subscription Checkout session is created (the Stripe client runs on its default timeouts). Nothing is recorded until payment: the checkout.session.completed webhook grants the credits, stores plan_price_id and plan_credits, and advances plan to device. Poll GET /onboarding for plan_price_id after the user returns; Stripe sends them back to the onboarding page with ?plan_purchase=success or ?plan_purchase=cancelled. Skipping instead is POST /onboarding/plan/skip; the plan half never gates POST /onboarding/complete. Price ids are the ones the app's pricing screen shows; POST /billing/create-stripe-checkout-session is the same checkout without the onboarding step resolution.

Example

curl -X POST https://api.pamba.app/onboarding/plan/purchase \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "price_id": "price_123", "credits": 1000 }'
# -> { "checkout_url": "https://checkout.stripe.com/..." }
POST/onboarding/plan/skip

Plan step: skip buying a plan and advance to the device step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with plan_step_skipped true and current_step at least device. 404 when onboarding was never started.

Notes

A decision, not a gate: POST /onboarding/plan/unskip reverses it, and a later purchase clears the flag on fulfillment.

POST/onboarding/plan/unskip

Plan step: undo a skip so the plan is offered again.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with plan_step_skipped false. 404 when onboarding was never started.

Notes

Clears the flag only; current_step stays where it is.

POST/onboarding/devices/skip

Accounts half of go-live: resolve it without buying account seats.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with current_step device and device_step_skipped true, unless the workspace already owns account seats, in which case the step resolves with the flag left false. 404 when onboarding was never started.

Notes

POST /onboarding/complete refuses a full-flow onboarding until the accounts half is resolved: account seats purchased (POST /devices/accounts/seat-checkout), a legacy device claimed, or this skip. The device step itself is retired (account seats replaced it); the key stays for in-flight rows.

PUT/onboarding/referral

Closing question: record where the user heard about Pamba.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "source": string, "detail"?: string }. source is one of tiktok, instagram, youtube, x_twitter, linkedin, google_search, ai_chatbot, friend_colleague, other (case-insensitive); detail is free text for "other", at most 500 characters.

Response

200 OnboardingState with referral_source and referral_source_detail set. 400 when source is empty or not in the list, or detail is over 500 characters. 404 when onboarding was never started.

Notes

Optional and never gates completion; current_step does not move. The fixed list keeps the analytics segmentation from fragmenting.

POST/onboarding/complete

Finish onboarding so the app stops routing the user through the wizard.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with status completed and completed_at set. 400 in the full flow when the accounts half is unresolved (no account seats, no claimed device, not skipped via POST /onboarding/devices/skip); 400 in the light flow when company_name or niche_description is missing. 404 when onboarding was never started.

Notes

The plan half is not gated: resolve it (purchase or skip) before calling this, or leave it. Idempotent; a repeat re-stamps completed_at. After completion PUT /onboarding/website stops mirroring branding to the workspace (workspace settings own it) and POST /onboarding/video-concepts/create-projects becomes a no-op.

POST/onboarding/dismiss

Leave the wizard without finishing it, resumable at the current step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 OnboardingState with dismissed_at set; status stays in_progress. 404 when onboarding was never started.

Notes

Login and the dashboard stop routing the user back into the flow; the row keeps its pointer and data, so any step route resumes it. POST /onboarding/complete finishes it for good.

POST/onboarding/heal-bootstrap

Repair a torn signup: recreate the missing onboarding row and signup coupon.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { status: "healed" } when something was granted, 200 { status: "not_needed" } when nothing was missing. 400 { status: "error", message } when the caller has no default workspace or the workspace is not the caller's own default workspace.

Notes

The signup bootstrap runs its grants one by one, so an abort mid-flight can leave a real user with no onboarding row or no credits. This re-runs only what is missing: the onboarding row when there is none, and the 300-credit signup coupon when the workspace has never had any coupon, holds no avatar projects and no projects, and the caller owns no avatar. Each grant is guarded on its own absence, so repeated calls never double-award. The only route here that works before an onboarding row exists.

Onboarding Video Concepts

The first-video research: reference TikToks, drafted concepts, and the draft projects made from them.

POST/onboarding/video-concepts

Research top TikToks for the niche and draft 3 first-video concepts grounded in them.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "topic"?: string, "keep_references"?: boolean, "keep_concepts"?: number[], "phase"?: "references" | "concepts" }. Empty topic seeds the research from the confirmed niche (the multi-angle wall search); an explicit topic searches itself. keep_references true rewrites the concepts against the stored references without a new search. keep_concepts lists 0-based indexes to keep as-is, redrawing only the others. phase "references" responds once the references are stored and leaves the concepts drafting in the background; phase "concepts" waits for the drafted concepts; absent waits for both.

Response

200 OnboardingState with first_video_topic, video_references (the top 6) and video_concepts (3, each { title, hook, description, script, text_hook, video_project_id, selected }; a concept's index in this array is what the select and create-projects routes take). With phase "references", video_concepts may still be empty. 400 when phase is not references or concepts, when there is no topic and no niche yet, when a kept index no longer exists, when every concept is marked kept, when the search found no TikToks for the niche (stored state untouched, retry later), or when the concept draft failed. 404 when onboarding was never started.

Notes

Blocks on the research unless the stored research is already fresh. A plain niche request (no topic, no keeps) returns the stored references and concepts as-is when they were researched from the current niche and brand; only a stale set (video_concepts_stale) re-researches. The search runs up to 5 TikTok searches in parallel plus a corpus lookup (each search request times out at 60s, with one retry after a rate limit of up to 10s, and the niche sentence is searched again when every angle came back empty); the workspace's wall search terms are generated first when missing (one model call), and the concept draft is one model call at 90s per attempt, up to 3 attempts. Set a client timeout of several minutes; the wizard's own split is phase "references" (seconds to tens of seconds) followed by phase "concepts", which joins the background draft, or redraws just the concepts from the stored references after a restart, and never re-searches while the references are fresh. Identical concurrent requests share one research run. keep_references or keep_concepts is one model call and keeps the wall the user was shown (kept concepts keep their position, selected flag and video_project_id). The pointer does not move here; POST /onboarding/video-concepts/select resolves the step. Regenerating replaces the concept list, so video_project_id links from a previous list are dropped (the drafts themselves stay). Side effect: the researched references seed the workspace content hub once per workspace.

Example

curl -X POST https://api.pamba.app/onboarding/video-concepts \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "topic": "" }'
POST/onboarding/video-concepts/select

Record which concepts the user picked and resolve the first-video step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "concept_indexes": number[] }, 0-based indexes into video_concepts. Out-of-range indexes are dropped.

Response

200 OnboardingState with selected true on the picked concepts (false on the rest) and current_step at least avatar. 400 when no concepts have been generated, or no index is valid. 404 when onboarding was never started.

Notes

Selection only; projects are created by POST /onboarding/video-concepts/create-projects, which does not require this call.

POST/onboarding/video-concepts/create-projects

Turn concepts into draft video projects bound to the presenter.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "concept_indexes": number[], "avatar_project_id"?: UUID }. Presenter resolution: avatar_project_id when given, else selected_avatar_project_id from POST /onboarding/avatar/select, else the avatar of the finished POST /onboarding/avatar job.

Response

200 OnboardingState; each created concept carries its video_project_id and selected true. 400 when avatar_project_id is not a UUID or has no ready avatar in this workspace, when no concepts have been generated, or when no presenter can be resolved. 404 when onboarding was never started.

Notes

Idempotent per concept: an index whose concept already has a video_project_id, or that is out of range, is skipped, and a call with nothing left to create returns the state unchanged. After onboarding is completed the call is ignored and returns the state unchanged. Creating the drafts is free and involves no model call: each project takes the concept's title as its name, its script as the creative concept (split into clips deterministically), its text_hook (or spoken hook) as the burned-in text hook, a different existing scene from the presenter's library, and the video model xai/grok-imagine-video-1.5 as the onboarding default. Generating the videos later (POST /projects/{projectId}/generate-clip-videos) spends credits. The drafts appear in GET /projects; the pointer does not move.

Example

curl -X POST https://api.pamba.app/onboarding/video-concepts/create-projects \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "concept_indexes": [0, 2] }'

Ideas and Scripts

Create content concepts, save scripts, and link avatars. To start a project from a script, pass the script to POST /projects.

GET/ideas

List ideas with pagination, filters, sorting, and archived state.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query page, page_size, sort_by, sort_direction, archived, min_views, max_views, min_views_per_follower, max_views_per_follower, min_engagement_rate, max_engagement_rate, days_lookback, phone_number, search.

Response

200 { ideas: Idea[], page, page_size }; Idea includes id, title, notes, status, analyzed_video, scripts, avatars, video_projects, archived_at, created_at.
POST/ideas

Bulk-create ideas from titles, notes, and TikTok URLs.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "ideas": [{ "title"?, "tiktok_video_url"?, "notes"? }] }.

Response

201 { idea_ids: string[] }.

Example

curl -X POST https://api.pamba.app/ideas \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ideas": [{ "title": "Before/after plumbing POV", "notes": "Make it punchy" }] }'
PUT/ideas

Bulk-update ideas.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "ideas": [{ "idea_id": UUID, "notes"?, "tiktok_video_url"?, "archived"? }] }.

Response

200 { updated_count: number }.
PUT/ideas/{id}

Update notes, TikTok URL, or archived state for one idea.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "notes"?, "tiktok_video_url"?, "archived"? }.

Response

200 { success: true }.
POST/ideas/{id}/branch

Branch an idea into a new variant.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "new_title": string, "notes"?: string }.

Response

201 { idea_id: UUID }.
POST/ideas/{id}/avatars

Attach avatars to an idea.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "avatar_project_ids": UUID[] }.

Response

200 { success: true }.
DELETE/ideas/{id}/avatars

Remove avatars from an idea.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "avatar_project_ids": UUID[] }.

Response

200 { success: true }.
POST/ideas/scripts

Create a script for an idea.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "idea_id": UUID, "content": string }.

Response

201 { script_id: UUID }.
PUT/ideas/scripts/{id}

Update script content.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "content": string }.

Response

200 { success: true }.
DELETE/ideas/scripts/{id}

Delete a script.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { success: true }.
POST/ideas/scripts/{id}/avatars

Attach avatars to a script.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "avatar_project_ids": UUID[] }.

Response

200 { success: true }.
DELETE/ideas/scripts/{id}/avatars

Remove avatars from a script.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; JSON body { "avatar_project_ids": UUID[] }.

Response

200 { success: true }.
GET/ideas/{id}

One idea with its parent and branched child ideas.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { idea: Idea, parent_idea: Idea | null, child_ideas: Idea[] }; Idea as GET /ideas returns it. 400 on a malformed id; 404 when the idea is not in this workspace.

Notes

A workspace without a subscription reads the idea as a teaser: the analyzed video's transcript and analysis fields are withheld while the title, notes and metrics stay readable.

POST/ideas/generate-script

Write a TikTok script from a one-line idea, without saving anything.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ idea_description, niche_description?, variation?, variation_count?, promotional? (default true) }. variation and variation_count ask for the n-th of a set of distinct takes on the same idea.

Response

201 { script }. 400 when idea_description is blank; 402 without a subscription; 422 when the model returned nothing usable.

Notes

Blocks on one model call. niche_description defaults to the workspace niche. The script is returned only: save it with POST /ideas/scripts, or pass it as a project's script.

Discover

The viral corpus: trending and niche feeds, search, format trends, breakout creators, and the create page's source picker.

GET/discover/trending

The cross-niche feed of videos breaking out this week or today.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query window (week, default, or today), labels (one content label to filter on), min_views, sort (virality, default; velocity; views), page (default 1), page_size (1-50, default 20), producible (all to include videos an avatar could not reproduce; default hides them).

Response

200 { videos: DiscoverVideo[], page, page_size }. DiscoverVideo: { id, tiktok_video_id, tiktok_url, author_username, author_follower_count, cover_url, gcs_video_url, duration, posted_at, view_count, like_count, comment_count, share_count, bookmark_count, views_per_follower, virality_score, category, resurged, description, summary, content_labels, enriched, feasibility_verdict, saved_idea_id, author_tracked, locked_teaser }.

Notes

Free workspaces see the first rows in full and the rest as locked teasers: locked_teaser true, id empty, and no detail or action route accepts them. saved_idea_id is set when this workspace already turned the video into an idea; author_tracked when it already follows the author. enriched false means the video has metadata only (no transcript or analysis yet).

GET/discover/niche

The feed of corpus videos in the workspace's own niche.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query window (week or today), sort (virality, velocity, views), page, page_size (1-50, default 20), producible (all to include non-producible videos).

Response

200 { videos: DiscoverVideo[], page, page_size, niche_status: ok | pending | no_niche }.

Notes

niche_status pending means the workspace niche is still being classified and the feed is empty for now; no_niche means the workspace has no niche description (set one with PUT /workspaces/{workspaceId}/company-profile or through onboarding). Same teaser rule as the trending feed.

GET/discover/search

Search the corpus by text, falling through to a live TikTok search when the corpus is thin.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query q (required), live (auto, default: search TikTok live when the corpus returns fewer than 10 rows; never; force), page, page_size (1-50, default 20).

Response

200 { videos: DiscoverVideo[], page, page_size, live_search_used, live_searches_remaining_today }. 400 when q is blank; 402 without a subscription.

Notes

A live search runs only on page 1, fetches 20 videos from TikTok and upserts them into the corpus, so they appear in the same response and in every later search. Live searches are capped per workspace per day: live_searches_remaining_today is what is left (null when the workspace has no cap), and at 0 the search is corpus-only. A live search that fails on TikTok's side is not charged against the cap. Expect a few seconds when live_search_used is true.

GET/discover/categories

The content categories in the corpus with their video counts.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { categories: [{ name, count }] }.

Notes

Pass a name as the labels query of GET /discover/trending to filter the feed.

GET/discover/format-trends

Format trends rising across niches, each with the videos that evidence it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { trends: [{ id, trend_type, title, description, niche_spread, score, evidence_videos: [{ tiktok_video_id, tiktok_url, views, likes, description, cover_url }] }] }.

Notes

Global, not workspace-specific. niche_spread counts the niches the format was seen in. Turn one into a proposed pattern for this workspace with POST /discover/format-trends/{id}/propose-pattern.

GET/discover/videos/{id}

One corpus video with its transcript and analysis.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID, the DiscoverVideo id }; no body.

Response

200 { video: DiscoverVideo, transcript, ai_description, feasibility_why }. 400 on a malformed id; 402 without a subscription; 404 when there is no such video.

Notes

transcript, ai_description and feasibility_why are null until the video is enriched (enriched true on the DiscoverVideo).

POST/discover/videos/{id}/use

Turn a corpus video into a workspace idea, ready for create-videos or replication.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { idea_id, preparing }. 400 on a malformed id; 402 without a subscription or when the workspace's idea quota is used up; 404 when there is no such video.

Notes

Idempotent per workspace: a video already turned into an idea returns the existing idea_id. An enriched video links at once (preparing false); a metadata-only video seeds the idea and queues its download and analysis at high priority (preparing true), which the idea's analyzed_video reflects once done. The new idea also enters the create page's source picker.

POST/discover/videos/{id}/bookmark

Save a corpus video for later as a bookmarked idea.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { idea_id, preparing: false }. Same errors as the use route.

Notes

The same materialization as POST /discover/videos/{id}/use, plus the idea is stamped as a deliberate save so it shows under saved ideas and in the picker's saved source. Bookmarking an already used video only adds the stamp.

POST/discover/videos/{id}/track-author

Start tracking the video's author as an inspiration account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { username, status: "tracking" }. 400 on a malformed id; 402 without a subscription or when the workspace's inspiration-account quota is used up; 404 when there is no such video.

Notes

Equivalent to POST /tiktok/tracked-accounts with the author's handle: the account's videos are fetched and analyzed from here on.

GET/discover/creators

Breakout creators: accounts whose recent videos lift far above their follower count.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { creators: [{ id, username, author_tiktok_uid, nickname, follower_count, follower_growth, window_views, breakout_count, videos_posted_in_window, hit_rate_verified, best_lift, score, category, evidence_videos: [{ corpus_video_id, tiktok_url, description, views, likes, lift, cover_url }], tracked, locked_teaser }] }.

Notes

Up to 25, best score first. Free workspaces see the first rows in full and the rest redacted with locked_teaser true. tracked is true when this workspace already follows the creator.

POST/discover/creators/track

Start tracking a breakout creator as an inspiration account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ username } (a creator from GET /discover/creators, case-insensitive).

Response

200 { username, status: "tracking" }. 400 when username is blank; 402 without a subscription or when the inspiration-account quota is used up; 404 when the handle is not a current breakout creator.
POST/discover/format-trends/{id}/propose-pattern

File a format trend into the workspace's learning loop as a proposed pattern.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID, the trend id }; no body.

Response

200 { finding_id } (null when the trend is already proposed or no longer exists). 400 on a malformed id; 402 without a subscription.

Notes

The finding appears on GET /content/discovery/findings, where an operator approves it into a pattern.

GET/picker/videos

The create page's source picker: one video shape over every source.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query source (for_you, saved, own, discover, inspiration; required), feed (trending, default, or niche; discover source only), search, sort (recommended, date, views), exclude_low_feasibility (default true), page (1-10000, default 1), page_size (1-50, default 24).

Response

200 { videos: [{ id, source, idea_id, project_id, corpus_video_id, analyzed_video_id, tiktok_video_id, author, label, video_url, poster_url, poster_thumbnail_urls: { "240", "360" }, duration_seconds, views, likes, comments, shares, virality_score, feasibility_verdict, feasibility_why, required_assets: [{ kind, what }], content_labels, enriched, created_at }], page, page_size, brand_supplied_kinds, niche_status, locked_count, locked_covers, niche_downloads_in_flight }. 400 on an unknown source, sort or feed.

Notes

for_you is the recommended blend for the workspace, saved its bookmarked ideas, own its finished projects, discover the corpus feed and inspiration the tracked accounts' videos. required_assets lists what a replica of the video needs from the brand (a product shot, a logo); brand_supplied_kinds says which of those the workspace media library already covers. Free workspaces get locked_count and locked_covers for the rows behind the paywall instead of the rows. poster_thumbnail_urls are direct bucket URLs that may 404 until first rendered; fall back to poster_url.

POST/picker/videos/{analyzed_video_id}/save

Save a picker video as a bookmarked idea.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { analyzed_video_id: UUID }; no body.

Response

200 { idea_id }. 400 on a malformed id; 404 when the analyzed video does not exist.

Notes

Find-or-revive-or-create: a live idea for the video is re-stamped as saved, an archived one is revived, and only a new idea spends idea quota. Un-save with PUT /ideas/{id} and archived true.

TikTok Research and Analytics

Track accounts, inspect analyzed videos, and pull dashboard metrics.

POST/tiktok/tracked-accounts

Track TikTok usernames for research.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "usernames": string[] }.

Response

201 { account_ids: UUID[] }.

Example

curl -X POST https://api.pamba.app/tiktok/tracked-accounts \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "usernames": ["competitor_handle"] }'
GET/tiktok/tracked-accounts

List tracked accounts and avatar links.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { accounts: TrackedAccount[] }; includes username, follower_count, status, avatar_id, avatar_name, deactivation_reason.
DELETE/tiktok/tracked-accounts/{username}

Stop tracking an account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username: string }; no body.

Response

200 { success: true }; 409 if the account is linked to an avatar.
GET/tiktok/tracked-accounts/{username}/fetch-status

Check initial fetch status.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username: string }; no body.

Response

200 { username, status }.
GET/tiktok/tracked-accounts/videos

List analyzed videos with username and performance filters.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query page, page_size, username, sort_by, sort_direction, min_views, max_views, min_views_per_follower, max_views_per_follower, min_engagement_rate, max_engagement_rate, days_lookback, search, include_research_accounts.

Response

200 { videos: TrackedVideo[], page, page_size }; video includes metrics, transcript, summary, idea_id, analysis fields.
GET/tiktok/tracked-accounts/{username}/views-per-day

Daily views, likes, comments, shares, and bookmarks.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username }; query days 1-365, timezone.

Response

200 { username, days: [{ date, views, likes, comments, shares, bookmarks }] }.
GET/tiktok/tracked-accounts/{username}/metrics

Read account-level aggregates.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username }; no body.

Response

200 aggregate metrics including video_count, total_views, average metrics, engagement rate, duration and posting cadence.
GET/tiktok/tracked-accounts/{username}/metrics-history

Per-video metric snapshots over time.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username }; no body.

Response

200 { username, videos: [{ tiktok_video_id, title, description, author_username, snapshots[] }] }.
GET/tiktok/analytics/accounts

Accounts available in analytics.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { accounts: [{ username }] }.
GET/tiktok/analytics/dashboard

Fetch trends, top videos, and recent videos.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query days 1-365, timezone.

Response

200 dashboard data with current/previous totals, trend series, top videos and recent videos for every avatar account in the workspace.

Instagram and Facebook Analytics

The same dashboard for the workspace's Instagram and Facebook accounts, read from the daily scrape of each profile.

GET/instagram/analytics/accounts

The Instagram accounts that appear in analytics.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { accounts: [{ platform: "instagram", username, avatar_id, avatar_name }] }.

Notes

Accounts linked to one of the workspace's avatars. avatar_id is the avatar's own id, not its project id.

GET/instagram/analytics/dashboard

Per-account totals, a daily series, and the top and recent reels over a window.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query days (1-365, default 7), timezone (an IANA zone; defaults to the workspace timezone; 400 when invalid).

Response

200 { accounts: [{ platform, username, avatar_id, avatar_name, full_name, profile_pic_url, follower_count, total_views, total_likes, total_comments, total_shares, total_bookmarks, views_per_day: [{ date, views, likes, comments, shares, bookmarks }] }], scope_overview: { scope: "all", usernames, views_per_day }, top_videos: Video[], recent_videos: Video[], l30d_videos: Video[] }. Video: { platform, id, shortcode, author_username, cover_url, duration, create_time, view_count, like_count, comment_count, share_count, bookmark_count, follower_count, views_per_follower, engagement_rate, description, permalink, metrics_updated_at }.

Notes

The window is the last days calendar days in the given zone. top_videos is the window's reels by plays (up to 50), recent_videos the last 14 days (up to 1000), l30d_videos the last 30 days (up to 500). Instagram exposes no share or bookmark counts, so those fields and the shares and bookmarks series are always 0. engagement_rate is (likes + comments) / views.

GET/instagram/analytics/videos

Every tracked reel across the workspace's Instagram accounts, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query limit (1-1000, default 500).

Response

200 { videos: Video[] } with the dashboard's Video shape.
GET/facebook/analytics/accounts

The Facebook accounts that appear in analytics.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { accounts: [{ platform: "facebook", username, avatar_id, avatar_name }] }.
GET/facebook/analytics/dashboard

Per-account totals, a daily series, and the top and recent posts over a window.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query days (1-365, default 7), timezone (an IANA zone; defaults to the workspace timezone; 400 when invalid).

Response

200 the same shape as GET /instagram/analytics/dashboard, with platform "facebook".

Notes

Same windows and limits as the Instagram dashboard. Share and bookmark counts are 0.

GET/facebook/analytics/videos

Every tracked post across the workspace's Facebook accounts, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query limit (1-1000, default 500).

Response

200 { videos: Video[] } with the dashboard's Video shape.

Avatars and Starting Frames

Manage speaker personas and the images that keep videos visually consistent.

GET/avatars

List avatars by status or tag.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query avatar_status, tag.

Response

200 Avatar[]; includes id, project_id, name, status, tags, TikTok accounts, image/voice metadata and warming config.
POST/avatars/projects

Create a draft avatar project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

201 { project_id: UUID }.
POST/avatars/projects/raw

Create an avatar from multipart form data and a reference image.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

multipart/form-data: name, gender=MALE|FEMALE, image file; optional accent, base_appearance_description, adaptive_appearance_description, background_description, speaker_description.

Response

201 { avatar_id, project_id, name, gender, image_url, voice_id, voice_sample_url }; 402 { error, remaining_credits, required_credits } on insufficient credits.
GET/avatars/projects/{projectId}

Get an avatar project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 avatar project details including selected avatar and generated candidates.
PATCH/avatars/projects/{projectId}

Select an avatar for an avatar project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "selected_avatar_id": UUID }.

Response

200 OK; 402 { error, remaining_credits, required_credits } on insufficient credits.
DELETE/avatars/projects/{projectId}

Delete an avatar project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

204 No Content.
PUT/avatars/projects/{projectId}/selected-avatar

Update selected avatar tags, TikTok usernames, status, and warming config.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "avatar_status"?, "tiktok_usernames"?, "tags"?, "warming_search_terms"?, "warming_config"? }. Warming is always on; warming_search_terms sets only the browse search terms (empty array reverts to the workspace's auto niche terms), warming_config sets scheduling anchors.

Response

200 OK.
PUT/avatars/projects/{projectId}/selected-avatar/tiktok-accounts/{username}

Update status for a linked TikTok account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, username: string }; JSON body { "status": string }.

Response

200 OK.
GET/avatars/projects/{projectId}/selected-avatar/starting-frames

List selected-avatar scenes for an avatar project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 AvatarFrame[]; frame has id, image_url, is_active, camera_style, tags, created_at, is_profile_image.
POST/avatars/projects/{projectId}/selected-avatar/starting-frames

Generate selected-avatar scenes from a prompt.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "prompt": string, "count": 1-10, "camera_style": string, "tags": string[] }.

Response

200 AvatarFrame[]; 402 { error, remaining_credits, required_credits } on insufficient credits.
POST/avatars/projects/{projectId}/selected-avatar/starting-frames/generations

Start a bulk scene generation job for the selected avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "rows": [{ tiktok_url, camera_style, tags?, cover_url? }], "results_per_ref": 2-6 }.

Response

202 BulkGenerationJob { id, status, avatar_count, video_count, total_combinations, results_per_combo, completed_count, failed_count, created_at }; 402 { error, remaining_credits, required_credits } on insufficient credits.
POST/avatars/projects/{projectId}/selected-avatar/starting-frames/upload

Upload a selected-avatar scene image.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

multipart/form-data: image file, camera_style, optional comma-separated tags.

Response

200 AvatarFrame.
PUT/avatars/projects/{projectId}/selected-avatar/starting-frames/profile

Set a selected-avatar scene as profile image.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "frame_id": UUID }.

Response

200 OK.
PATCH/avatars/starting-frames/{frameId}

Update a frame's active state, camera style, or tags.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID }; JSON body any of { "active": boolean, "camera_style": string, "tags": string[] }.

Response

200 OK.
DELETE/avatars/starting-frames/{frameId}

Delete one starting frame.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID }; no body.

Response

204 No Content; 409 when deleting the profile image.
DELETE/avatars/starting-frames

Bulk-delete starting frames.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "frame_ids": UUID[] }.

Response

204 No Content.
GET/avatars/starting-frames/reference-frames

List reference frames, optionally by tag.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Optional query tag.

Response

200 ReferenceFrame[] for golden/reference frames.
GET/avatars/projects/{projectId}/preset-voices

List three free preset voices matched to the selected avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; optional query direction=deeper|higher (steer past the excluded picks) and exclude=<comma-separated voice_ids already shown>.

Response

200 { options: [{ voice_id, name, sample_url, accent?, depth_percentile? }] }: three free curated roster voices matched to the selected avatar's gender and age; 400 when the project has no selected avatar.
POST/avatars/projects/{projectId}/preset-voices/select

Give the selected avatar one of the preset voices.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "voice_id": string }.

Response

200 the updated avatar. Free: the roster voice is adopted and applied in place; the avatar keeps its own accent.

Avatar Wizard

The reviewed way to create an avatar: generate and prune scenes, accept the look, then pick a voice.

POST/avatars/wizards

Start a wizard from an appearance description.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ appearance_prompt } (at most 10,000 characters).

Response

202 { id, project_id, step, appearance_prompt, avatar_id, created_at, job_id }. 400 when appearance_prompt is blank or too long; 402 { error, remaining_credits, required_credits }; 429 when the workspace already has 5 avatars generating.

Notes

Creates a draft avatar project and starts the first appearance pass in the background: a profile written from the prompt (name, gender, features), a proxy image and 4 scenes, 15 credits per image (75 for the pass). The pass is free while the calling user owns no avatar yet. step is APPEARANCE | VOICE | COMPLETED. Poll GET /avatars/wizards/{wizardId} until scenes_job leaves GENERATING; avatar_id is set once the pass lands.

GET/avatars/wizards

List the workspace's unfinished wizards.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ id, project_id, step, appearance_prompt, avatar_id, created_at, job_id: null }], newest first.

Notes

Only wizards whose step is not COMPLETED. A completed wizard's avatar is listed by GET /avatars.

GET/avatars/wizards/{wizardId}

Get a wizard with its scenes, voice options and job state.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; no body.

Response

200 { id, project_id, step, appearance_prompt, avatar_id, avatar_name, created_at, scenes: AvatarFrame[], voice_options: [{ id, type, sample_url }], latest_job, scenes_job, voices_job, scenes_error_message }. 400 when the id is not a UUID; 404 when the wizard is not in this workspace.

Notes

scenes are the kept scenes oldest first, without the proxy profile frame. voice_options type is PRESET (free roster voice) or PREMIUM (cloned by POST /avatars/wizards/{wizardId}/voices). latest_job, scenes_job (newest WIZARD_APPEARANCE job) and voices_job (newest WIZARD_VOICES job) are GenerationJob objects (see POST /avatars/generations); scene passes and voice jobs run concurrently, so read the lane you are waiting on. scenes_error_message is set when the last scene pass failed. Reading a wizard that has an avatar but no preset options writes three preset picks onto it.

DELETE/avatars/wizards/{wizardId}

Cancel a wizard and discard its draft.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; no body.

Response

200 { job_id: null }. 400 when the wizard already completed; 404 when it is not in this workspace; 409 while one of its jobs is running.

Notes

Deletes the wizard and its draft avatar project and discards every premium voice it generated. No undo. Credits already spent are not returned.

PATCH/avatars/wizards/{wizardId}/appearance

Change the appearance prompt and regenerate the scenes.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; { appearance_prompt } (at most 10,000 characters).

Response

202 { job_id }. 400 when the prompt is blank or too long, or the wizard already completed; 402 { error, remaining_credits, required_credits }; 404; 409 while a job is running.

Notes

Runs a fresh appearance pass (proxy image plus 4 scenes, 75 credits, free for the caller's first avatar). Every scene generated so far is discarded when the pass lands; the profile is rewritten from the new prompt. Allowed at the VOICE step too.

POST/avatars/wizards/{wizardId}/scenes

Generate more scenes from the current appearance.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; { count } 1-5, default 3.

Response

202 { job_id }. 400 when count is out of range, the wizard has no avatar yet, it already completed, or the set would exceed 15 scenes; 402 { error, remaining_credits, required_credits }; 404; 409 while a job is running.

Notes

Adds to the set; existing scenes stay. 15 credits per scene, free for the caller's first avatar. A wizard holds at most 15 scenes, so discard some before adding more.

DELETE/avatars/wizards/{wizardId}/scenes/{frameId}

Discard one scene from the wizard's set.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID, frameId: UUID }; no body.

Response

200 { job_id: null }. 400 when the wizard completed, has no avatar yet, or the frame is its profile frame; 404 when the wizard or the frame is not found; 409 while a job is running.

Notes

Soft delete. Keep at least one scene: completing the wizard needs one to become the profile image.

POST/avatars/wizards/{wizardId}/appearance/accept

Lock the scene set and move to the voice step.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; no body.

Response

200 { job_id: null }. 400 when the wizard is past the appearance step, has no avatar, or has no scenes; 404; 409 while a scene pass is running.

Notes

Only the step changes (to VOICE). The profile image is chosen at completion, so scenes can still be discarded. Preset voice options appear on the next GET; premium ones need POST /avatars/wizards/{wizardId}/voices.

PATCH/avatars/wizards/{wizardId}/avatar-name

Rename the wizard's avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; { name } (trimmed, 1-80 characters).

Response

200 { job_id: null }. 400 when name is blank or too long, or the avatar has not been created yet; 404.

Notes

A plain name update, no regeneration. Works at any step, including after completion.

POST/avatars/wizards/{wizardId}/voices

Clone premium voice options for the avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; { premium_count } 1-3. include_basic is accepted and ignored.

Response

202 { job_id }. 400 when premium_count is out of range, the wizard completed, or the avatar has no appearance image yet; 402 { error, remaining_credits, required_credits }; 404; 409 while a voices job is running.

Notes

Each premium voice is cloned from generated footage of the avatar and costs 1500 credits, charged as it lands; the job lands options one at a time. Poll GET /avatars/wizards/{wizardId}: voices_job carries wizard_premium_voices_remaining and voice_options grows. Preset voices are free and need no job.

POST/avatars/wizards/{wizardId}/preset-voices

Swap the three preset voice picks for fresh ones.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; optional { direction: "deeper" | "higher" | "random" }.

Response

200 { job_id: null }. 400 on an unknown direction, when the wizard completed, has no avatar yet, or no roster voice fits it; 404; 409 while a voices job is running.

Notes

Instant and free. Picks are matched to the avatar's gender and age and exclude the ones currently shown; deeper and higher steer relative to them. Premium options are kept. Read the new picks with GET /avatars/wizards/{wizardId}.

POST/avatars/wizards/{wizardId}/voices/select

Pick a voice and complete the wizard.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { wizardId: UUID }; { option_id } (an id from voice_options).

Response

200 Avatar (the finished avatar, as GET /avatars returns it). 400 when the wizard is not at the VOICE step, the option is unknown, or every scene was discarded; 402 { error, remaining_credits, required_credits } (legacy basic options only); 404; 409 while a job is running, or when a legacy basic preview expired.

Notes

Completion: attaches the voice, makes the first kept scene the profile image and retires the proxy, discards the unselected premium voices, and starts a background variation job per kept scene (up to 6 variations each, 15 credits per image, charged to the caller unless this is their first avatar). Selecting a preset waits on one ElevenLabs call before responding.

Avatar Generations

Background avatar jobs: the one-shot create, voice regeneration, the job tray, and the ready-made pool.

POST/avatars/generations

Create a whole avatar from a description in one background job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ user_input, video_model_based_voice_creation?: false }. user_input at most 10,000 characters.

Response

202 GenerationJob { id, project_id, status: GENERATING | COMPLETED | FAILED, job_type, user_input, avatar_id, error_message, result_summary, created_at, completed_at, acknowledged_at, voice_options: [{ id, type, sample_url }], wizard_basic_batch_pending, wizard_premium_voices_remaining }. 400 when user_input is blank or too long; 402 { error, remaining_credits, required_credits }; 429 when the workspace already has 5 avatars generating.

Notes

An agent builds appearance, 5 scenes and a voice from the text with no review step; POST /avatars/wizards is the reviewed path. Estimated at 90 credits (an image plus 5 scenes), plus 1500 when video_model_based_voice_creation is true (a premium clone instead of a free preset voice). The caller's first avatar is free apart from the premium voice. job_type is CREATE | UPDATE | PREMIUM_VOICE | BASIC_VOICE | WIZARD_APPEARANCE | WIZARD_VOICES. Poll GET /avatars/generations/{jobId}; avatar_id is set when the avatar lands.

GET/avatars/generations

List the workspace's create jobs.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 GenerationJob[].

Notes

CREATE jobs only: every running one plus those that finished in the last 24 hours. Wizard and profile-edit jobs have their own reads.

GET/avatars/generations/{jobId}

Get one generation job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { jobId: UUID }; no body.

Response

200 GenerationJob. 400 when the id is not a UUID; 404 when the job is not in this workspace.

Notes

Any job type. voice_options is filled only on a completed legacy BASIC_VOICE job.

DELETE/avatars/generations/{jobId}

Dismiss a finished job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { jobId: UUID }; no body.

Response

204 No Content. 400 when the id is not a UUID; 404 when the job is not in this workspace or is still running.

Notes

Removes the job row. When it never produced an avatar, its empty draft project is soft-deleted too. A running job cannot be dismissed.

POST/avatars/generations/{jobId}/acknowledge

Mark a finished job as seen.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { jobId: UUID }; no body.

Response

200 OK. 400 when the id is not a UUID; 404 when the job is not in this workspace or is still running.

Notes

Stamps acknowledged_at and clears the matching Home attention item (voice ready, avatar updated, or failed job). Unacknowledged profile-edit jobs stay in GET /avatars/projects/{projectId}/generations for 7 days.

POST/avatars/projects/{projectId}/premium-voice

Regenerate the selected avatar's voice as a premium clone.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { voice_video_model?: "seedance" | "gemini-omni" | "grok-imagine-1.5" }. Defaults to seedance.

Response

202 GenerationJob with job_type PREMIUM_VOICE. 400 when the project has no selected avatar, the avatar has no description to clone from, or an image-to-video model was chosen and the avatar has no appearance image; 402 { error, remaining_credits, required_credits }; 409 while another job runs on the project; 429 when the workspace already has 5 avatars generating.

Notes

1500 credits. Only the voice changes: the job trains a clone on footage generated from the avatar's description (seedance, text-to-video) or its appearance image (the other models). Poll GET /avatars/generations/{jobId}; the job also appears in GET /avatars/projects/{projectId}/generations until acknowledged.

GET/avatars/projects/{projectId}/generations

List an avatar project's unresolved profile-edit jobs.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 GenerationJob[]. 400 when the id is not a UUID; an unknown project returns an empty list.

Notes

UPDATE, PREMIUM_VOICE and legacy BASIC_VOICE jobs: every running one plus unacknowledged finished ones from the last 7 days. Clear one with POST /avatars/generations/{jobId}/acknowledge.

GET/avatars/auto-generated

List the ready-made avatars available to claim.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 Avatar[] (the same shape as GET /avatars).

Notes

A shared pool, not workspace-scoped: avatars Pamba generated and an operator approved. Claim one with POST /avatars/auto-generated/claim.

POST/avatars/auto-generated/claim

Claim ready-made avatars into this workspace.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ avatar_ids: UUID[] }.

Response

200 OK. Fails with 500, not 4xx, when an id is not in the pool or was already claimed by another workspace.

Notes

Creates one avatar project per id with the avatar selected, plus a seed chat message. All or nothing: one bad id fails the whole claim. The claimed avatars then show in GET /avatars.

Avatar Accounts and Profile Edits

Link an outside TikTok account to an avatar, reconcile a rename, and follow on-phone profile edits.

POST/avatars/projects/{projectId}/selected-avatar/tiktok-accounts/external

Link an existing TikTok account to the avatar for analytics.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { username } (a leading @ is stripped and the handle lowercased).

Response

200 { username, already_linked }. 400 { error } when the username is blank or cannot be verified on TikTok; 404 when the project has no selected avatar.

Notes

For an account Pamba does not post from: it is tracked with status ONLY_TRACK_ANALYTICS. A handle already linked returns already_linked true with no change. Blocks on one TikTok profile lookup through the scraping provider: 60 seconds per call, retried once after a rate-limit backoff of up to 10 seconds, so allow about two minutes.

POST/avatars/projects/{projectId}/selected-avatar/tiktok-accounts/{username}/rename

Record a TikTok handle change made outside Pamba.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, username: the old handle }; { new_username }. Both handles are trimmed, lowercased and stripped of a leading @.

Response

200 { old_username, new_username, account_renamed_in_place, device_accounts, warming_sessions, pending_scheduled_posts, history_written, concepts, content_items, plan_slots, tracked_binding_repaired }. 400 { error } when new_username is blank, equals the old handle, is not 2-24 characters of lowercase letters, digits, underscores and periods, or is already used by another workspace's account; 404 { error } when the project has no selected avatar, neither handle is linked to it, or the account is not on one of this workspace's phones.

Notes

Does not rename anything on the phone; it aligns Pamba's records with a rename already done in TikTok. One transaction moves the device account, warming sessions, pending scheduled posts, content concepts, items and plan slots to the new handle and repairs the tracked-account binding; the counts say what moved. Safe to call again after a partial earlier run (the new handle may already be on the device row).

GET/avatars/projects/{projectId}/account-profile-edits

List unresolved on-phone profile edits for the avatar's accounts.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 [{ id, tiktok_username, platform: "tiktok" | "instagram" | "facebook", status: PENDING | IN_PROGRESS | COMPLETED | FAILED, requested_fields: string[], operations: [{ name, succeeded, applied_value, error_code, error_reason }], attempt_count, last_error_code, last_error_reason, created_at, completed_at, acknowledged_at }]. 400 when the id is not a UUID; 404 when the project is not in this workspace.

Notes

Every running edit plus unacknowledged finished ones from the last 7 days, across the avatar's accounts on every platform (tiktok_username is a historical field name and carries the handle on platform). requested_fields keys: display_name, username, profile_picture, bio, block_ai_comments, allow_ai_comments. operations lists only the fields the edit asked for, so a failed entry is a real failure. Edits are started from the accounts routes.

POST/avatars/projects/{projectId}/account-profile-edits/{jobId}/acknowledge

Mark a finished profile edit as seen.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, jobId: UUID }; no body.

Response

200 OK. 400 when an id is not a UUID; 404 when the job is not on one of the project's accounts or is not finished yet.

Notes

Stamps acknowledged_at, drops the job from the list above, and clears the matching Home attention item for a failed edit.

Scenes and Variations

Look up scenes by image URL, generate and curate a scene's variations, and edit an image with a model.

POST/avatars/projects/{projectId}/selected-avatar/starting-frames/resolve-urls

Map image URLs back to the selected avatar's frames.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { image_urls: string[] }. Duplicates are dropped and at most 100 URLs are looked up.

Response

200 { frames: [{ image_url, frame_id, parent_frame_id }] }. 404 when the project has no selected avatar.

Notes

Clip rows store only a frame's URL; this returns the frame behind each one, variations included. parent_frame_id is set for a variation and names its scene. A URL that matches no live frame of this avatar is omitted from frames.

GET/avatars/starting-frames/{frameId}/variations

List a scene's variations and its latest variation job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID }; no body.

Response

200 { variations: AvatarFrame[], latest_job: { id, status: PENDING | PROCESSING | COMPLETED | FAILED, target_variation_count, completed_count, failed_count, error_message, created_at, completed_at } | null, scene_image_url }. 400 when the id is not a UUID; 404 when the frame is not in this workspace.

Notes

AvatarFrame is { id, avatar_id, parent_frame_id, image_url, is_active, bulk_generation_id, camera_style, tags, content_labels, content_description, created_at, is_profile_image, variation_count }. A variation carries parent_frame_id; scene_image_url is the scene's own image, the reference a promotion swaps.

POST/avatars/starting-frames/{frameId}/variations

Top a scene up to 6 pose and angle variations.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID }; no body.

Response

202 VariationJob (as latest_job above) when a job started or one is already running; 200 with no body when the scene already has 6 variations. 400 when the frame is itself a variation or is not linked to an avatar; 404 when the frame is not in this workspace.

Notes

Background job generating only the missing variations, 15 credits per generated image charged to the caller; each candidate is judged for consistency with the scene and regenerated when rejected. The job aborts when credits run out. Poll GET /avatars/starting-frames/{frameId}/variations.

POST/avatars/starting-frames/{frameId}/variations/from-image

Save an already generated image as a new variation.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID }; { image_url } (a Pamba storage URL, for example the result of POST /image/edit).

Response

200 AvatarFrame (the new variation). 400 when image_url is not a Pamba storage URL, the frame is not linked to an avatar, or its scene no longer exists; 404 when the frame is not in this workspace.

Notes

Free: the image was paid for when it was generated. frameId may be a scene (the variation attaches to it) or one of its variations (the result becomes a sibling, never nested). Inherits the source's camera style and tags. Never triggers a charged top-up.

PUT/avatars/starting-frames/{frameId}/image

Replace a frame's image in place.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID }; { image_url } (a Pamba storage URL).

Response

200 OK. 400 when image_url is not a Pamba storage URL; 404 when the frame is not in this workspace.

Notes

Destructive: the image the frame held is gone. To keep both, save the new image with POST /avatars/starting-frames/{frameId}/variations/from-image, or promote a variation with PUT /avatars/starting-frames/{frameId}/reference-variation.

PUT/avatars/starting-frames/{frameId}/reference-variation

Make one of a scene's variations the scene's image.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { frameId: UUID } (the scene); { variation_id }.

Response

200 { scene_image_url }. 400 when variation_id is not a UUID, the frame is itself a variation, or variation_id is not a live variation of this scene; 404 when the frame is not in this workspace.

Notes

The two frames swap images, so the replaced image stays available as a variation. Later variation generation starts from the promoted image.

POST/image/edit

Edit an image with a prompt.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ image_url, edit_prompt, model?: "nano-banana-pro" | "nano-banana-2" | "nano-banana-2-lite" }. image_url must be a Pamba storage URL; model defaults to nano-banana-2.

Response

200 { image_url, model, latency_ms, text_response }. 400 { error } when edit_prompt is blank, image_url is not a Pamba storage URL, or model is unknown; 402 { error, remaining_credits, required_credits }; 422 { error, safety_blocked: true } when the model declined the edit; 502 { error } when the model returned no image.

Notes

Synchronous: the response waits on one Gemini image call. 15 credits, charged only when an image comes back. The server sets no short deadline (the HTTP client's ceiling is 30 minutes per attempt, with up to 3 retries on provider errors or timeouts), so set your own client timeout. The result lands in Pamba storage and is not attached to anything: keep it with POST /avatars/starting-frames/{frameId}/variations/from-image or PUT /avatars/starting-frames/{frameId}/image.

Example

curl -X POST https://api.pamba.app/image/edit \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image_url": "https://storage.googleapis.com/pamba_images/scenes/1a2b3c.png",
    "edit_prompt": "Move the speaker to a sunlit kitchen, keep the face and outfit unchanged"
  }'

Reference Frames and Bulk Generations

Operator tools: the shared reference library and multi-avatar scene generation runs.

POST/avatars/starting-frames/generations

Generate scenes for many avatars from TikTok references.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

{ avatar_ids: UUID[], rows: [{ tiktok_url, camera_style, tags?, cover_url? }], results_per_combo: 2-10, prompt?, avatar_frame_overrides?: [{ avatar_id, frame_id }] }. camera_style: selfie | tripod | tripod_full_body_visible | podcast.

Response

202 BulkGenerationJob { id, status: PENDING | PROCESSING | COMPLETED | FAILED, avatar_count, video_count, total_combinations, results_per_combo, completed_count, failed_count, created_at }. 400 when avatar_ids or rows is empty, results_per_combo is out of range, avatar_ids x rows x results_per_combo exceeds 3000, or an override names an avatar outside avatar_ids or a frame that is not one of its active frames; 403 for a customer key; 404 when an avatar id is unknown; 409 when the caller already has 3 bulk jobs running.

Notes

Crosses every avatar with every reference; not charged. An override makes the run reference that frame instead of the avatar's profile image. Customers get the per-avatar form at POST /avatars/projects/{projectId}/selected-avatar/starting-frames/generations. Poll GET /avatars/starting-frames/generations/{jobId}.

GET/avatars/starting-frames/generations

List the caller's bulk generation jobs.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

No body.

Response

200 BulkGenerationJob[], newest first. 403 for a customer key.

Notes

Jobs started by the calling user, from this route or the per-avatar one.

GET/avatars/starting-frames/generations/{jobId}

Get a bulk generation job with its frames.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { jobId: UUID }; no body.

Response

200 BulkGenerationJob plus { error_message, completed_at, frames: AvatarFrame[], avatars: [{ id, name, appearance_image_url }] }. 400 when the id is not a UUID; 403 for a customer key; 404 when the job does not exist.
POST/avatars/starting-frames/reference-frames

Add a TikTok video's first frame to the shared reference library.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

{ tiktok_url, camera_style, tags?: string[] }.

Response

201 ReferenceFrame { id, tiktok_url, cover_url, tags, camera_style, created_at, content_labels, content_description }. 400 when tiktok_url is blank; 403 for a customer key; 500 with the provider's message when the video cannot be fetched.

Notes

Synchronous: fetches the video through the TikTok provider (60 seconds per call, retried once on a rate limit) and renders its first frame through the ffmpeg service (ceiling 10 minutes), so use a long client timeout. References are global and shared by every workspace; content labels are written later by a background labeling pass.

PATCH/avatars/starting-frames/reference-frames/{id}

Edit a reference frame's tags or camera style.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { id: UUID }; { tags?: string[], camera_style? }, at least one.

Response

200 OK. 400 when the id is not a UUID or neither field is sent; 403 for a customer key; 404 when the reference does not exist.

Notes

tags replaces the whole list.

DELETE/avatars/starting-frames/reference-frames/{id}

Remove a reference frame from the library.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { id: UUID }; no body.

Response

204 No Content. 400 when the id is not a UUID; 403 for a customer key; 404 when the reference does not exist.

Notes

Soft delete. Scenes generated from the reference are kept.

Projects and Generation

Create fully automated batches, control each generation step manually, or skip generation entirely and post a video you made yourself.

POST/projects

Bulk-create projects, including fully automated projects.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "projects": [{ "selected_avatar_project_id": UUID, "script"?, "idea_description"?, "notes"?, "project_name"?, "is_fully_automated"?, "camera_style"?, "bullet_list"?, "scheduled_at"?, "video_description"?, "tiktok_username"?, "keep_tiktok_song"?, "only_create_draft"?, "reference_video_url"?, "video_model"?, "keep_raw_voice"?, "generate_video"? }] }. The "script" is stored directly as the creative concept (deterministic script→clips split, no agent); required for is_fully_automated. "idea_description" is the alternative to script - the server writes a distinct script from it per project ("idea_variation"/"idea_variation_count" optionally carry the numbering when one idea fans out across separate requests). "generate_video": true generates and renders the video right after creation (non-automated projects with a script or idea only).

Response

201 { project_ids: UUID[] }.

Notes

If is_fully_automated is true, automation may later consume credits during clip generation. generate_video consumes video credits immediately.

Example

curl -X POST https://api.pamba.app/projects \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "projects": [{
      "selected_avatar_project_id": "$AVATAR_PROJECT_ID",
      "project_name": "Plumber POV #1",
      "script": "Day in the life of a plumber. First call at 7am, a flooded basement. By noon I found a ring the customer lost years ago.",
      "is_fully_automated": true,
      "video_description": "Day in the life #plumbing",
      "tiktok_username": "plumber_pov",
      "scheduled_at": "2026-05-18"
    }]
  }'
POST/projects/create

Create one blank project, with or without an avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "selected_avatar_project_id"?: UUID }. Omit it for a project with no avatar; an avatar cannot be added later.

Response

201 { project }.
GET/projects

List projects.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 VideoProject[].
GET/projects/{projectId}

Get one project plus chat messages.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 Project { project, messages }. project.editor_state is the saved timeline (null before the first save) and project.editor_state_version the integer to send back as base_version on PATCH /projects/{projectId}/editor-state or POST /projects/{projectId}/render. The editor_state shape:

EditorState {
  main_video_track: [MainTrackEntry]   required: the ordered timeline of source clips
  overlay_tracks?: [OverlayTrack]
  markers?: [{ id, time, color?, label? }]
  main_track_magnet?: boolean          false lets main-track entries carry gap_before
  media_pool?: [{ src, type: image | video | audio, label?, aspect?, source_duration? }]   the project's uploaded library; UI only, the render never reads it
  version?, legacy_overlays_materialized?, auto_color_adjust_created?, removed_source_clips?: [{ clip_id, video_url? }], storyboard?   Studio bookkeeping: send back what you read, never author (storyboard is opaque JSON)
}
MainTrackEntry {
  clip_id: UUID                        required
  in_point (0), out_point?             seconds into the source clip
  type ("video"), speed?, volume?, color?
  filter?: none | warm | cool | mono | vivid | film
  transform?: { scale (required), x (0), y (0) }
  crop?: { x, y, w, h (required, fractions of the rotated source), rotation (0) }
  transition_after?: { type (required): none | fade | dipblack | crossfade | dipwhite | whiteflash | slideleft | slideup | spin | zoompunch | whipleft | whipup | roll | shake, duration (required, seconds) }
  gap_before?, fade_in?, fade_out?     seconds (gap_before needs main_track_magnet false)
  use_original_voice?: boolean
  voice_effect?: deep | reverb | room | phone | cheap_mic | no_bass | crisp | bass_boost | podcast | full | voice_focus (null or "original" = none), voice_strength? 0..100 (null = 100)
}
OverlayTrack { id (required), kind (required): overlay | audio, name (""), muted?, hidden?, items: [OverlayItem] }
OverlayItem, every type: id, type: text | image | video | audio | caption | adjust, start, duration   all required; start and duration are timeline seconds
  text:     text, x, y, w (fractions of the frame, 0..1: x 0.5 is centered; y is the vertical CENTER, 0 is the top edge, a top hook sits at y 0.12 to 0.2; always set x and y, null renders center-frame), style_preset (an id from GET /editor/text-style-presets; the save expands it into style, so prefer it over a hand-built style) or style: EditorTextStyle, text_wrap? (true makes w the box width the text wraps inside; otherwise break lines with \n in text), animation?: { in: none | fade | pop | slide_up | slide_down, out: none | fade | pop | slide_down | slide_up, duration (0.3) }, rotation?, opacity?
  caption:  words (required): [{ text, start, end }], caption_style: bold-pop | box-highlight | caps-box | karaoke | modern | modern-box | modern-word | one-word | pill | yellow-box | yellow-classic, x, y, w, style?
  adjust:   adjust: { temperature?, tint?, saturation?, brightness?, contrast? } each -100..100 with 0 neutral; lut? ("pamba_v1" is the default grade)
  image, video: src (a durable public URL; a signed asset URL expires), x, y, w (the width as a fraction of the frame: 1.0 fullscreen, about 0.6 a floating insert, about 0.4 a logo flash; the aspect ratio keeps itself), media_in? (source trim start, seconds), speed? (0.5 to 2.0; the item consumes duration x speed source seconds), volume? (0 keeps an inserted clip from talking over the presenter), fade_in?, fade_out?, opacity?, rotation?, crop? (as on MainTrackEntry), aspect?, source_duration?, label?
  audio:    src, volume?, fade_in?, fade_out?, media_in?, speed?, voice_effect?, voice_strength? (as on MainTrackEntry)
EditorTextStyle {
  font_family (""), font_size (64; px on the 1080x1920 frame, 24 is the readable minimum), color ("#FFFFFF"), background ("transparent")
  background_radius? (0 sharp to 0.5 capsule), background_mode?: block | line, background_height? (0..1, default 0.20), background_width? (0..1, default 0.28)
  bold (false), italic (false), align (center): left | center | right, outline (false), shadow (false), underline?, text_case?: upper | lower | title, letter_spacing?
  outline_color?, outline_width?, shadow_color?, shadow_opacity?, shadow_blur?, shadow_distance?
  box_color?, box_radius?, box_pad?    the caption active-word highlight box
}
Read-back only, omit on write: wrap_lines, background_box, line_boxes and words[].box are measurement caches the Studio writes. Reserved: origin values auto_color and media_scene, asset_id, bullet_index.
PATCH/projects/{projectId}

Update metadata, states, notes, selected concept, reference URL, or automation config.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body any of { "project_name"?, "user_visibility"?, "posted_state"?, "user_review_state"?, "notes"?, "reference_video_url"?, "full_automation_configuration"? }.

Response

200 updated project.
PATCH/projects

Bulk-update project states and notes.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

JSON body { "updates": [{ "project_id": UUID, "user_visibility"?, "posted_state"?, "user_review_state"?, "notes"? }] }.

Response

200 { success: true }.
POST/projects/{projectId}/duplicate-shallow-concept

Duplicate a project's shallow concept.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

201 Project { project, messages }.
POST/projects/{projectId}/selected-creative-concept

Set speaker, background, script, and clips for the selected concept.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "speaker_description": string, "background_description": string, "clips"?, "script"?, "starting_frame_for_all_clips_url"? }.

Response

200 updated selected creative concept.
GET/projects/{projectId}/creative-concepts

List the project's creative concept version history, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { "versions": [{ "id", "created_at", "is_selected", "speaker_description", "background_description", "script"?, "clips" }] } newest first.
POST/projects/{projectId}/creative-concepts/{conceptId}/select

Revert the project to a prior concept version.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, conceptId: UUID }; no body.

Response

200 on revert to that concept version; 404 when the concept doesn't belong to the project.
POST/projects/{projectId}/generate-starting-frames

Generate project-level starting frames.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "camera_style": string }.

Response

200 generated project-level starting frames.
POST/projects/{projectId}/upload-starting-frame

Upload a project-level starting frame image.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

multipart/form-data: image file, camera_style, and optional persist_to_avatar_library (boolean, default false - when true the scene is also saved to the avatar's active library).

Response

200 uploaded starting frame.
DELETE/projects/{projectId}/starting-frames

Delete project-level starting frames.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "frame_ids": UUID[] }.

Response

200 OK.
POST/uploads/videos

Stage a video file before a project exists (upload-your-own-video flow).

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

multipart/form-data with one file part named "video". Same types, size cap and re-encode as POST /projects/{projectId}/edited-video. No project id.

Response

200 { "staged_video_url": string }.

Notes

Stages a file before the project or avatar is decided; pass the URL as source_url to POST /projects/{projectId}/edited-video. If the project already exists, POST the file there directly.

Example

curl -X POST https://api.pamba.app/uploads/videos \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "video=@./my-video.mp4;type=video/mp4"
# -> { "staged_video_url": "https://storage.googleapis.com/..." }
POST/projects/{projectId}/edited-video

Attach your own finished video as the project's final video. No AI generation involved.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }. Either multipart/form-data with one file part named "video" whose Content-Type is video/mp4, video/quicktime, video/webm or video/x-msvideo (curl: ;type=video/mp4), at most 600 MB; or JSON { "source_url": string } where source_url is a Pamba storage URL: a staged_video_url from POST /uploads/videos, or another project's edited_video_url to post the same file again.

Response

200 { "edited_video_url": string }. 400 when source_url is not a Pamba video URL; 409 while automation is running on the project or a replication draft is locked. A file part with a missing or other Content-Type is ignored and the request fails.

Notes

Makes the file the project's final video with no generation or render and no credit charge: metadata is stripped, anything above 1080p on the short side (or rotated) is re-encoded to 1080p, and the result is recorded as a COMPLETED render, so GET /projects/{projectId}/render-jobs/latest reads current and nothing re-renders over it. source_url is not a generic fetch: to post a video hosted elsewhere, download it and upload the bytes.

Example

curl -X POST https://api.pamba.app/projects/$PROJECT_ID/edited-video \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "video=@./my-video.mp4;type=video/mp4"
# -> { "edited_video_url": "https://storage.googleapis.com/..." }

# Or attach a file staged with POST /uploads/videos:
curl -X POST https://api.pamba.app/projects/$PROJECT_ID/edited-video \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"source_url\": \"$STAGED_VIDEO_URL\" }"
POST/projects/{projectId}/generate-clip-videos

Generate selected clip indexes.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "clips_to_generate": [{ "index": number, "starting_frame_url"?: string }] }.

Response

200 generation result; 402 { error, remaining_credits, required_credits }; 429 { error, pending_count }.

Notes

Costs per-model video credits x successfully generated video seconds - current per-model rates come from GET /usage/pricing. On a replication DRAFT project, the settle of a full generation triggers a server-side final edit: automation_state flips to running for the edit's duration (editor-state writes 409 in that window) and editor_state is rewritten when it completes - wait for automation_state to leave running before editing, rendering or scheduling.

Example

curl -X POST https://api.pamba.app/projects/$PROJECT_ID/generate-clip-videos \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "clips_to_generate": [{ "index": 0 }] }'
GET/projects/{projectId}/clips/{creativeConceptId}

Poll clip status for a creative concept.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, creativeConceptId: UUID }; no body.

Response

200 { clips: [{ clip_id, position, script, duration_seconds, video_url, generation_status }] }.
GET/projects/{projectId}/video-generations

Get all generation history for a project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { clips: ClipVideoGenerationHistory[] }.
GET/projects/{projectId}/clips/{clipId}/video-generations

Get take history for one clip.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, clipId: UUID }; no body.

Response

200 VideoGeneration[] for the clip.
PATCH/projects/{projectId}/clips/{clipId}

Select a take for a clip.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, clipId: UUID }; JSON body { "selected_video_generation_id": UUID }.

Response

200 selection message, or 404 when clip/generation is not found.
POST/projects/{projectId}/render-full-video

Render the current project into a finished video.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 render result with output video URL/status.
POST/projects/{projectId}/resume-automation

Resume automation for a project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { "message": "Automation resumed" }.
POST/projects/{projectId}/restart-automation

Restart automation for a project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { "message": "Automation restarted" }.
POST/projects/{projectId}/automation/apply-clips

Apply generated clips in an automation pipeline.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { "message": "Apply-clips pipeline started" }.
PATCH/projects/{projectId}/editor-state

Save editor state without rendering.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "editor_state": EditorState, "base_version"?: integer }. The body is parsed strictly: field names are snake_case, and a key outside this shape, or a wrong type, is a 400 that does not name the key.

EditorState {
  main_video_track: [MainTrackEntry]   required: the ordered timeline of source clips
  overlay_tracks?: [OverlayTrack]
  markers?: [{ id, time, color?, label? }]
  main_track_magnet?: boolean          false lets main-track entries carry gap_before
  media_pool?: [{ src, type: image | video | audio, label?, aspect?, source_duration? }]   the project's uploaded library; UI only, the render never reads it
  version?, legacy_overlays_materialized?, auto_color_adjust_created?, removed_source_clips?: [{ clip_id, video_url? }], storyboard?   Studio bookkeeping: send back what you read, never author (storyboard is opaque JSON)
}
MainTrackEntry {
  clip_id: UUID                        required
  in_point (0), out_point?             seconds into the source clip
  type ("video"), speed?, volume?, color?
  filter?: none | warm | cool | mono | vivid | film
  transform?: { scale (required), x (0), y (0) }
  crop?: { x, y, w, h (required, fractions of the rotated source), rotation (0) }
  transition_after?: { type (required): none | fade | dipblack | crossfade | dipwhite | whiteflash | slideleft | slideup | spin | zoompunch | whipleft | whipup | roll | shake, duration (required, seconds) }
  gap_before?, fade_in?, fade_out?     seconds (gap_before needs main_track_magnet false)
  use_original_voice?: boolean
  voice_effect?: deep | reverb | room | phone | cheap_mic | no_bass | crisp | bass_boost | podcast | full | voice_focus (null or "original" = none), voice_strength? 0..100 (null = 100)
}
OverlayTrack { id (required), kind (required): overlay | audio, name (""), muted?, hidden?, items: [OverlayItem] }
OverlayItem, every type: id, type: text | image | video | audio | caption | adjust, start, duration   all required; start and duration are timeline seconds
  text:     text, x, y, w (fractions of the frame, 0..1: x 0.5 is centered; y is the vertical CENTER, 0 is the top edge, a top hook sits at y 0.12 to 0.2; always set x and y, null renders center-frame), style_preset (an id from GET /editor/text-style-presets; the save expands it into style, so prefer it over a hand-built style) or style: EditorTextStyle, text_wrap? (true makes w the box width the text wraps inside; otherwise break lines with \n in text), animation?: { in: none | fade | pop | slide_up | slide_down, out: none | fade | pop | slide_down | slide_up, duration (0.3) }, rotation?, opacity?
  caption:  words (required): [{ text, start, end }], caption_style: bold-pop | box-highlight | caps-box | karaoke | modern | modern-box | modern-word | one-word | pill | yellow-box | yellow-classic, x, y, w, style?
  adjust:   adjust: { temperature?, tint?, saturation?, brightness?, contrast? } each -100..100 with 0 neutral; lut? ("pamba_v1" is the default grade)
  image, video: src (a durable public URL; a signed asset URL expires), x, y, w (the width as a fraction of the frame: 1.0 fullscreen, about 0.6 a floating insert, about 0.4 a logo flash; the aspect ratio keeps itself), media_in? (source trim start, seconds), speed? (0.5 to 2.0; the item consumes duration x speed source seconds), volume? (0 keeps an inserted clip from talking over the presenter), fade_in?, fade_out?, opacity?, rotation?, crop? (as on MainTrackEntry), aspect?, source_duration?, label?
  audio:    src, volume?, fade_in?, fade_out?, media_in?, speed?, voice_effect?, voice_strength? (as on MainTrackEntry)
EditorTextStyle {
  font_family (""), font_size (64; px on the 1080x1920 frame, 24 is the readable minimum), color ("#FFFFFF"), background ("transparent")
  background_radius? (0 sharp to 0.5 capsule), background_mode?: block | line, background_height? (0..1, default 0.20), background_width? (0..1, default 0.28)
  bold (false), italic (false), align (center): left | center | right, outline (false), shadow (false), underline?, text_case?: upper | lower | title, letter_spacing?
  outline_color?, outline_width?, shadow_color?, shadow_opacity?, shadow_blur?, shadow_distance?
  box_color?, box_radius?, box_pad?    the caption active-word highlight box
}
Read-back only, omit on write: wrap_lines, background_box, line_boxes and words[].box are measurement caches the Studio writes. Reserved: origin values auto_color and media_scene, asset_id, bullet_index.

Response

200 { version }: the new editor_state_version, the base_version for the next save. 400 "Failed to convert request body" on an unknown key or a wrong type. 409 { error_code: "editor_state_conflict", editor_state, version } when base_version is behind the stored version (the body carries the current state to rebase on). 409 { error } while automation is still running or the replication draft is locked in the Drafts tab.

Notes

Round trip: read project.editor_state and project.editor_state_version from GET /projects/{projectId}, edit, send the edited state with base_version set to the version you read; a 409 means someone saved in between, so re-read and re-apply. Without base_version the save is last-writer-wins. Clip ids for main_video_track come from the saved editor_state, or from project.selected_creative_concept.clips[].clip_id on a project that was never saved. Send complete items: this route stores what it receives, so a text item needs its x, y, w and a style_preset (or a full style) on every save. To write a text hook, pick a preset id from GET /editor/text-style-presets, send style_preset with the text, x, y and w, and the save expands the preset into style so the look cannot drift. Saving does not render; POST /projects/{projectId}/render does both.

Example

curl -X PATCH https://api.pamba.app/projects/$PROJECT_ID/editor-state \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "base_version": 7,
    "editor_state": {
      "main_video_track": [{ "clip_id": "$CLIP_ID", "in_point": 0.0, "out_point": 3.8 }],
      "overlay_tracks": [{
        "id": "hooks", "kind": "overlay", "items": [{
          "id": "hook-1", "type": "text", "start": 0.0, "duration": 3.0,
          "text": "Stop scrolling.", "x": 0.5, "y": 0.16, "w": 0.9, "style_preset": "classic"
        }]
      }]
    }
  }'
# -> 200 { "version": 8 }
POST/projects/{projectId}/render

Save editor state and start a background render job (202 + RenderJob; 409 if one is already running).

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "editor_state": EditorState, "base_version"?: integer, "rebind_scheduled_post"?: boolean (default false: when true the project's pending scheduled posts are re-pointed at this render) }. Same strict parsing and the same EditorState as PATCH /projects/{projectId}/editor-state:

EditorState {
  main_video_track: [MainTrackEntry]   required: the ordered timeline of source clips
  overlay_tracks?: [OverlayTrack]
  markers?: [{ id, time, color?, label? }]
  main_track_magnet?: boolean          false lets main-track entries carry gap_before
  media_pool?: [{ src, type: image | video | audio, label?, aspect?, source_duration? }]   the project's uploaded library; UI only, the render never reads it
  version?, legacy_overlays_materialized?, auto_color_adjust_created?, removed_source_clips?: [{ clip_id, video_url? }], storyboard?   Studio bookkeeping: send back what you read, never author (storyboard is opaque JSON)
}
MainTrackEntry {
  clip_id: UUID                        required
  in_point (0), out_point?             seconds into the source clip
  type ("video"), speed?, volume?, color?
  filter?: none | warm | cool | mono | vivid | film
  transform?: { scale (required), x (0), y (0) }
  crop?: { x, y, w, h (required, fractions of the rotated source), rotation (0) }
  transition_after?: { type (required): none | fade | dipblack | crossfade | dipwhite | whiteflash | slideleft | slideup | spin | zoompunch | whipleft | whipup | roll | shake, duration (required, seconds) }
  gap_before?, fade_in?, fade_out?     seconds (gap_before needs main_track_magnet false)
  use_original_voice?: boolean
  voice_effect?: deep | reverb | room | phone | cheap_mic | no_bass | crisp | bass_boost | podcast | full | voice_focus (null or "original" = none), voice_strength? 0..100 (null = 100)
}
OverlayTrack { id (required), kind (required): overlay | audio, name (""), muted?, hidden?, items: [OverlayItem] }
OverlayItem, every type: id, type: text | image | video | audio | caption | adjust, start, duration   all required; start and duration are timeline seconds
  text:     text, x, y, w (fractions of the frame, 0..1: x 0.5 is centered; y is the vertical CENTER, 0 is the top edge, a top hook sits at y 0.12 to 0.2; always set x and y, null renders center-frame), style_preset (an id from GET /editor/text-style-presets; the save expands it into style, so prefer it over a hand-built style) or style: EditorTextStyle, text_wrap? (true makes w the box width the text wraps inside; otherwise break lines with \n in text), animation?: { in: none | fade | pop | slide_up | slide_down, out: none | fade | pop | slide_down | slide_up, duration (0.3) }, rotation?, opacity?
  caption:  words (required): [{ text, start, end }], caption_style: bold-pop | box-highlight | caps-box | karaoke | modern | modern-box | modern-word | one-word | pill | yellow-box | yellow-classic, x, y, w, style?
  adjust:   adjust: { temperature?, tint?, saturation?, brightness?, contrast? } each -100..100 with 0 neutral; lut? ("pamba_v1" is the default grade)
  image, video: src (a durable public URL; a signed asset URL expires), x, y, w (the width as a fraction of the frame: 1.0 fullscreen, about 0.6 a floating insert, about 0.4 a logo flash; the aspect ratio keeps itself), media_in? (source trim start, seconds), speed? (0.5 to 2.0; the item consumes duration x speed source seconds), volume? (0 keeps an inserted clip from talking over the presenter), fade_in?, fade_out?, opacity?, rotation?, crop? (as on MainTrackEntry), aspect?, source_duration?, label?
  audio:    src, volume?, fade_in?, fade_out?, media_in?, speed?, voice_effect?, voice_strength? (as on MainTrackEntry)
EditorTextStyle {
  font_family (""), font_size (64; px on the 1080x1920 frame, 24 is the readable minimum), color ("#FFFFFF"), background ("transparent")
  background_radius? (0 sharp to 0.5 capsule), background_mode?: block | line, background_height? (0..1, default 0.20), background_width? (0..1, default 0.28)
  bold (false), italic (false), align (center): left | center | right, outline (false), shadow (false), underline?, text_case?: upper | lower | title, letter_spacing?
  outline_color?, outline_width?, shadow_color?, shadow_opacity?, shadow_blur?, shadow_distance?
  box_color?, box_radius?, box_pad?    the caption active-word highlight box
}
Read-back only, omit on write: wrap_lines, background_box, line_boxes and words[].box are measurement caches the Studio writes. Reserved: origin values auto_color and media_scene, asset_id, bullet_index.

Response

202 RenderJob { id, project_id, status: "RUNNING", stitched_video_url, edited_video_url, error_message, created_at, completed_at, editor_state_version } when a new render starts. 409 comes in two shapes, told apart by error_code: { error_code: "editor_state_conflict", editor_state, version } when base_version is behind the stored version (nothing was saved or rendered), or a RenderJob when a render is already running for the project (attach to it and poll; renders cannot be cancelled, the pipeline supersedes stale ones itself). 400 "Failed to convert request body" on an unknown key or a wrong type; 409 { error } while automation runs or the replication draft is locked. Poll GET /projects/{projectId}/render-jobs/{jobId} (or .../render-jobs/latest) until status is COMPLETED, then read stitched_video_url/edited_video_url.

Notes

The state save is the render's first step, so the base_version round trip works exactly as on the PATCH route. rebind_scheduled_post is read on both 202 and the already-running 409 (the pending posts move to the running job); PATCH ignores it.

GET/projects/{projectId}/render-jobs/latest

Latest render job for the project (404 if never rendered).

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 RenderJob for the project's most recent render, or 404 if no render or replacement was ever recorded. A video supplied directly (an upload, a schedule video_url, a post's replaced video) is recorded as a COMPLETED job with edited_video_url set and stitched_video_url null.
GET/projects/{projectId}/render-jobs/{jobId}

Poll a render job's status until COMPLETED.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, jobId: UUID }; no body.

Response

200 RenderJob with the current status and (when COMPLETED) the output URLs.
POST/projects/{projectId}/schedule

Schedule a project's final video to TikTok and/or Instagram, optionally uploading the video in the same call.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; JSON body { "caption": string, "scheduled_at": string, "tiktok_username"?: string, "instagram"?: { "username"?: string, "share_to_facebook"?: boolean }, "privacy"?: "public" | "only_you", "keep_tiktok_song"?, "only_create_draft"?, "video_url"?, "automate"?, "video_text_hook"?, "bullet_list"?, "tiktok_pending_account"?, "instagram_pending_account"? }. At least one destination is required: tiktok_username, instagram, or a pending-account flag. Multipart variant: the same fields as form items (instagram as instagram_username + share_to_facebook; bullet_list and instagram_pending_account are JSON-only) plus an optional file part "video" that becomes the posted video.

Response

201 ScheduledPost { id, platform, status, scheduled_at, cross_posts[] (the other platform's leg), ... }. 400 on validation errors. A refused schedule returns { error_code, error }: tiktok_account_not_linked, tiktok_no_device_mapping, missing_project, pending_account_not_allowed (400); account_not_found (404); tiktok_account_paused, tiktok_account_archived, account_suspended, instagram_account_paused, instagram_account_off_device, no_linked_facebook_account, facebook_account_paused, duplicate_pending_post, schedule_conflict (409). Also 409 while video work is still in flight on the project.

Notes

Destinations are accounts signed in on the workspace's Pamba phones (GET /devices/accounts/inventory lists them; status posting can be scheduled). A project with an avatar can only post from that avatar's linked accounts; a project without an avatar can post from any of them. Send every destination in one call: both legs land or neither does. A project holds one post for its whole life (the TikTok leg and its Instagram leg count as one), so a second schedule is 409 duplicate_pending_post; to post the same file again, create a new project. instagram.username may be omitted when exactly one Instagram account qualifies; omitting share_to_facebook cross-posts to Facebook whenever the Instagram account has a postable linked Facebook account. TikTok captions: no @, at most 5 hashtags, none of #ad #ads #sponsored #partnership #paidpartnership (400). scheduled_at: yyyy-MM-dd lets Pamba pick a free slot that day; ISO 8601 with a timezone offset posts at that time and is 409 schedule_conflict (naming the next free slot) when the phone is busy; no offset is 400. The Instagram leg moves to the next free slot on its own. A video_url or multipart video replaces the project's final video before anything else, even when the schedule is then refused; the multipart file is only metadata-stripped, not re-encoded, so send anything above 1080p through /edited-video. The post is pinned to the file the project had when it was scheduled: a later /edited-video upload does not change a pending post; swap it with video_url on PUT /devices/tiktok/posts/{postId}. Scheduling approves the project. Post status: pending, in_progress, completed, failed, cancelled.

Example

curl -X POST https://api.pamba.app/projects/$PROJECT_ID/schedule \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tiktok_username": "plumber_pov",
    "instagram": { "username": "plumber_pov" },
    "caption": "Behind the scenes on a flooded basement job #plumbing",
    "scheduled_at": "2026-09-14T14:00:00-07:00",
    "privacy": "public"
  }'
GET/projects/{projectId}/video-generation-updates

SSE stream for generation updates.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; SSE request.

Response

text/event-stream with video generation update events.
GET/projects/agent-state-updates

SSE stream for project automation lifecycle updates.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

SSE request; optional project filters may be added by the client.

Response

text/event-stream with project_id, creation_stage, activity_state, posted_state, user_review_state, automation_state, selected_creative_concept, scheduled_post.
POST/projects/{projectId}/duplicate

Duplicate a project in full: every concept version, clips, generated takes, editor state and the final video.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

201 { project }. 400 on a malformed id; 404 when the project is not in the workspace.

Notes

The copy gets its own id and its own copies of the final stitched and edited video files; clip take videos and frame images are shared by URL. POST /projects/{projectId}/duplicate-shallow-concept copies only the concept.

POST/projects/{projectId}/preview-script-clips

Split a script into clips without saving anything.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { script, video_model? }. video_model picks the duration set the split targets; omitted means the default model.

Response

200 { clips: [{ script, duration_seconds, silent_planned_seconds, starting_frame_url, video_model, prompt_config, default_positive_prompt_template, scene_kind, lyrics }] }. A blank script returns an empty list.

Notes

Stateless: the project is neither read nor written. Use it to see the clips a script would become before saving it with POST /projects/{projectId}/selected-creative-concept, or to re-split one block of a longer script on its own.

GET/projects/{projectId}/library-frames

One page of the avatar's active scene library for the project, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; Query { page? (default 1), page_size? (default 60, max 200) }; no body.

Response

200 { frames: [{ id, image_url, camera_style, tags, content_labels, content_description, created_at }], page, page_size, total }. 400 on a malformed id; 404 when the project is not in the workspace.

Notes

These are the registered scenes a clip's starting_frame_url may point at; generation refuses a frame that is not one of them. content_labels and content_description are null for a frame that has not been analyzed yet.

POST/projects/{projectId}/scene-frame-sequence

Spread one scene's frame pool across N clips the way automation does.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { frame_url, clip_count }. frame_url is any frame of the scene (its reference or a variation); clip_count is 1 to 100.

Response

200 { frames: string[] }, one URL per clip in order. 400 on a malformed id or a clip_count outside 1 to 100; 404 when the project is not in the workspace or has no avatar.

Notes

Frames are drawn at random from the scene's reference frame and its active variations so consecutive clips never share a frame; frames repeat once clips outnumber the pool. Nothing is saved: write the result as each clip's starting_frame_url on the concept save.

POST/projects/{projectId}/generate-clip-videos-async

The Generate button: admit the listed clips for generation and return at once. Progress arrives on the SSE stream.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { clips_to_generate: [{ index, starting_frame_url? }], video_model?, keep_raw_voice? (default false) }. index is the clip's position in the selected concept, from 0; a starting_frame_url is saved on the clip before generation. A repeated index counts once.

Response

202 { generated_clips: [{ clip_id, position, video_url: null, status: "PENDING", failure_message: null }] }. 402 { error, remaining_credits, required_credits } when the balance cannot cover the batch. 429 { error, pending_count } with Retry-After: 60 when the platform queue is at its cap of 500 pending generations. 409 { error } while automation is running on the project, or while a replication draft has work in flight (the work-in-flight refusal; both 409 bodies carry only error). 400 when a clip's duration is outside its model's set, its starting frame is not a registered scene, or keep_raw_voice is false and the project has no avatar voice; 404 when the project is not in the workspace.

Notes

Credits: required_credits is the sum over the batch of each clip's duration times its model's per-second rate (the model's provider cost plus the upscale pass, marked up; rates on GET /usage/pricing). The batch is refused up front when the balance is short and charged at completion for the seconds actually generated; a failed clip is not charged. Poll GET /projects/{projectId}/clips/{creativeConceptId} or subscribe to GET /projects/{projectId}/video-generation-updates until no clip is PENDING or GENERATING. POST /projects/{projectId}/generate-clip-videos is the same call waiting for completion. Redo: a clip whose take is content-final and in its 60fps upscale tail (GENERATING with a preview published) is superseded by a redo. The superseded pass still finishes and parks as a revertable prior take in the clip's history (billed as usual), but its claim on the clip is revoked and the new take wins. A clip whose take is still being produced keeps that generation. Replication drafts: a draft's generation is exclusive. While the build is still authoring the draft, a render is running, or a take from an earlier generate is still being produced, the call is 409; the upscale tail does not count. When a reviewed draft's full generation settles, a server-side final edit runs: automation_state reads running for its duration (editor-state writes 409 in that window) and editor_state is rewritten when it completes. Wait for automation_state to leave running before editing, rendering or scheduling.

Example

curl -X POST https://api.pamba.app/projects/$PROJECT_ID/generate-clip-videos-async \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "clips_to_generate": [{ "index": 0 }, { "index": 1 }] }'
# -> 202 { "generated_clips": [{ "clip_id": "...", "position": 0, "video_url": null, "status": "PENDING", "failure_message": null }, ...] }
POST/projects/{projectId}/clip-video-edits

Video-to-video edit: transform a clip's current take, or one window of it, from a text instruction into a new take.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { edit_prompt, clips?: [{ clip_id, in_point?, out_point? }], clip_ids?: UUID[], keep_raw_voice? (default false) }. clips wins over clip_ids. in_point and out_point (seconds into the take) come together and select a window; absent means the whole take. One entry per clip.

Response

202 { generated_clips: [{ clip_id, position, video_url: null, status: "PENDING", failure_message: null }] }. 402 { error, remaining_credits, required_credits }; 429 { error, pending_count } with Retry-After: 60 when the queue is full; 409 { error } while automation runs on the project. 400 when edit_prompt carries no instruction, no clip is named, a clip repeats, a window is malformed, a clip has no video yet, a clip is already generating, or a whole take or window is outside 3 to 10 seconds; 404 when the project is not in the workspace.

Notes

Always runs on Gemini Omni whatever the clip's own model and is billed like a generation of the edited length. The edit lands in the clip's take history (is_video_edit true on GET .../video-generations), is auto-selected on completion, and is time-aligned with the take it transformed so editor trims and splits survive. A window edit is cut out, edited and spliced back into the full take, and keeps the take's own audio (keep_raw_voice is forced true). A clip id from a superseded concept save is re-pointed to its current descendant; only a deleted clip's id is refused. Poll like generate-clip-videos-async.

POST/projects/{projectId}/transcribe-clips

Word-level timings for every generated clip in the selected concept.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { clips: [{ clip_id, words: [{ text, start, end }] }] }, start and end in seconds within the clip, in spoken order. 404 when the project is not in the workspace, has no selected concept, or no clip has a video yet; 500 with a message when transcription fails.

Notes

Metered per second of audio actually sent to Speech-to-Text ($0.016 per minute at cost plus the 30% platform margin, about 2 credits a minute), charged to the project owner after the call. Results are cached per clip and take: repeat calls for the same videos are served from cache and not charged, and a regenerated take is transcribed and charged once. Blocks until every clip is done, at most 16 clips at a time; allow a minute for a long project.

GET/voice-effects/noises

The room-tone beds the voice-effects pass can mix under a clip.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { noises: [{ id, label, duration_seconds, source_count, preview_url }], default_volume }.

Notes

A static catalog, the same for every workspace. Pass an id as noise on POST /projects/{projectId}/voice-effects; default_volume (20) is the noise_volume a clip gets when none is set.

POST/projects/{projectId}/voice-effects

Re-process the named clips' audio with environment, room, distance, room-tone and clean-up settings.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { clips: [{ clip_id, enabled?, environment?: "inside" | "outside" | "car" | "none", room?: "small" | "small_plus" | "medium" | "large" | "hall" | "livedroom", environment_strength? (1 to 5), distance_strength? (0 to 5), head_direction_strength? (0 to 5), noise? (a bed id from GET /voice-effects/noises, or "none"), noise_volume? (0 to 100), de_ess? (0 to 100), de_plosive? (0 to 100) }] }. A null field keeps the clip's current value; 3 is the measured strength and 0 turns a layer off; enabled false turns the whole pass off for the clip.

Response

200 { clips: [{ clip_id, video_url, voice_effects: { enabled, environment, room, environment_strength, distance_strength, head_direction_strength, noise, noise_label, noise_volume, de_ess, de_plosive, source, has_motion } }] }. 400 when clips is empty, a clip id is malformed or not in the selected concept, a clip has no generated video, a value is out of range, or noise names no bed; 404 when the project is not in the workspace or has no selected concept.

Notes

Each clip's audio is rebuilt from its stored pre-effects take, so settings never stack; enabled false reverts the clip to that take. The result replaces the clip's video (video_url) and the settings are stored with the take. Synchronous: blocks while each clip's audio renders, and the first touch of a clip also runs motion detection on its video.

GET/editor/text-style-presets

The platform's text style presets for text items in editor_state.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ id, label, description, style: { font_family, font_size, color, background, bold, italic, align, outline, shadow, underline, text_case, letter_spacing, outline_color, outline_width, shadow_color, shadow_opacity, shadow_blur, shadow_distance, box_color, box_radius, box_pad, ... } }], a bare array. Served with Cache-Control: private, max-age=3600.

Notes

One list for every workspace. Reference a preset by id from an overlay text item; the render resolves it to these values at save time so the look cannot drift. classic and classic-outlined burn through the automated flow's hook construction, which the preview only approximates.

POST/projects/{projectId}/upload-editor-asset

Upload an image, video or audio file for the project's editor timeline.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; multipart/form-data with a form field asset_type (image, video or audio) sent before the file, then one file part named "file". Audio must be a .mp3 or .wav file.

Response

200 { url }. 400 when asset_type is missing, unknown, or arrives after the file, when no file is sent, or when an audio file has another extension; 404 when the project is not in the workspace.

Notes

The file streams straight to storage and the URL is public. Nothing is written to the project: put the URL into editor_state (the media pool or an overlay track) with PATCH /projects/{projectId}/editor-state. These files are not part of the media library (GET /assets) and do not count toward its cap.

GET/projects/{projectId}/text-hook-placement

Where the project's text hook sits: vertical center, font size and max width.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { y_center, font_size, max_width } in pixels of the video frame; y_center is null when no placement was ever stored. 400 when the project has no text hook, or has no stored placement and no stitched video to analyze; 404 when the project is not in the workspace.

Notes

Reads the stored placement. For a project without one, the first frame of the stitched video is extracted and analyzed for a placement, which is then stored; that call blocks on the extraction and one model call.

POST/projects/{projectId}/reposition-text-hook

Re-burn the text hook at a new vertical position and store the placement.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { y_center, font_size?, max_width? } in pixels of the video frame. font_size and max_width come together; omitted, the stored values are used, or analyzed from the first frame when none are stored.

Response

200 { edited_video_url, text_hook_y_center }. 400 when the project has no text hook or no stitched video; 404 when the project is not in the workspace.

Notes

Re-runs the overlay pass over the stitched video with FFmpeg (the bullet list rides along) and makes the result the project's final video; blocks for that render, which can take tens of seconds. The stored placement means the next overlay render keeps the position instead of re-placing the hook.

POST/projects/{projectId}/resolve-bullet-texts

The project's bullet-list texts, written from the script when they were never filled in.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { texts: string[] }. 400 when the project has no bullet-list overlay, or its texts are unset and it has no script; 404 when the project is not in the workspace; 500 with a message when generation fails.

Notes

Returns the stored texts when every item has one. Otherwise one model call writes as many bullets as the config has items and stores them, so the next call is a read.

POST/projects/{projectId}/reposition-bullets

Re-time each bullet to the moment its topic is spoken on the current timeline.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { bullet_times: [{ index, start, item_id }] }, start in timeline seconds. 400 when the project has neither a bullet-list overlay nor at least two on-screen step text items in its editor state; 404 when the project is not in the workspace.

Notes

Timing is semantic, so a bullet may paraphrase the speech. With a bullet-list overlay, index is the bullet's position and item_id is null; without one, the editor's step text items are timed and item_id names the item each entry is for. Nothing is written: apply the times to editor_state yourself. The words come from the clip transcription (same cache and metering as POST /projects/{projectId}/transcribe-clips); when none are available every bullet reads 0 and a step item keeps its place. Blocks for the transcription and one model call.

POST/projects/{projectId}/generate-description

Write a TikTok post description for the project from its script and on-screen text.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { description }. 422 { error } when the project is not in the workspace, has no script, on-screen text or visual context yet, or no valid description could be produced.

Notes

One model call, a few seconds. The result is at most 300 characters with exactly 5 hashtags and passes the same checks as a schedule caption. It is returned, not saved: send it as caption on POST /projects/{projectId}/schedule.

POST/projects/{projectId}/render-if-dirty

Commit point: render when the final video is stale against the saved editor state, and bind pending posts to that render.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

202 RenderJob { id, project_id, status, stitched_video_url, edited_video_url, error_message, created_at, completed_at } when a render was started, or one is already running or just completed (pending posts are bound to it). 204 when nothing needs rendering: the final video is current, or a timeline clip still has no settled take. 409 while automation is running on the project or the project is a locked replication draft. An unknown project id also reads 204.

Notes

Also renders when the project has a stitched preview but no edited final video, and retries the last FAILED render. It never renders a partial timeline: while a clip is still generating the call is 204, so try again after the clips land. Poll GET /projects/{projectId}/render-jobs/{jobId}, or .../render-jobs/latest.

Replication

Recreate a reference video as a reviewable draft for an avatar: build it, review and edit scene by scene, then generate.

POST/projects/replications

Start replicating a reference video into a new project for an avatar. Returns the build job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ selected_avatar_project_id, tiktok_url?, source_video_url?, source_project_id?, video_model?, max_duration_seconds?, variation_instructions?, promotional? (default true), draft_only? (default false) }. A source is required: tiktok_url (share links resolve), source_video_url (a Pamba storage URL, from the content hub or a workspace video) or source_project_id (one of the workspace's projects with a finished video). With a storage source, a tiktok_url is only kept as the project's reference link. max_duration_seconds targets the replica's total length, 4 to 160; omitted matches the reference. variation_instructions (at most 4000 characters) says how the replica should differ. similarity and acknowledge_low_feasibility are accepted and ignored.

Response

202 { id, project_id, status: "QUEUED" | "GENERATING", progress_message, error_message, promotional, created_at, completed_at }. 400 on a malformed id, no usable source, a source project without a finished video, instructions over the cap, or an avatar with no appearance image or no voice; 402 { error, remaining_credits, required_credits } when the balance cannot cover a minimal build; 404 when the avatar is not in the workspace.

Notes

Creates the project (with the avatar, the video_model, and the TikTok link as reference_video_url) and a persistent job; the build runs in the background and survives restarts. Poll GET /projects/{projectId}/replication-job with the returned project_id. A workspace runs at most 10 builds at a time; the rest start QUEUED and are promoted oldest first. draft_only true stops after the build with a reviewable draft (concept, scene frames, editor timeline): review it with GET /projects/{projectId}/scene-frames, edit with the routes below, then approve with POST /projects/{projectId}/replication/generate. Until it generates, the draft's editor routes (editor-state, render, edited-video) answer 409. draft_only false continues into clip generation, the final edit and the render. Billing: the build is charged after the fact for the analysis and frame work done; clip generation is charged at the model's per-second rate. video_model defaults to the avatar's default model, then Gemini Omni. A TikTok URL is also captured into the workspace's idea library.

Example

curl -X POST https://api.pamba.app/projects/replications \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "selected_avatar_project_id": "$AVATAR_PROJECT_ID",
    "tiktok_url": "https://www.tiktok.com/@creator/video/7300000000000000000",
    "variation_instructions": "Same structure, but the hook mentions our free trial.",
    "draft_only": true
  }'
# -> 202 { "id": "...", "project_id": "...", "status": "GENERATING", ... }
GET/projects/{projectId}/replication-job

The project's latest replication job: status, progress and error.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { id, project_id, status: "QUEUED" | "GENERATING" | "COMPLETED" | "FAILED", progress_message, error_message, promotional, created_at, completed_at }. 400 on a malformed id; 404 when the project has no replication job in the workspace.

Notes

Poll this after POST /projects/replications and after every draft action that answers 202. A draft-only build reads COMPLETED once the draft is ready to review, and re-enters GENERATING when the draft is approved, revised or retried; a full build reads COMPLETED once the render is done. A FAILED draft build can be retried with POST /projects/{projectId}/replication/retry-build.

POST/projects/{projectId}/replication/generate

Approve a reviewed draft: generate its clips, run the final edit and render.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

202 { status: "started", message }. 402 { error, remaining_credits, required_credits } when the balance cannot cover every ungenerated clip; 404 { status: "not_found", message } when the project has no draft in the workspace; 409 { status: "conflict", message } when the draft is still building or already generating, its build did not complete, an edit or a scene frame is still landing, or a scene still has no frame.

Notes

required_credits is the full video cost: every clip without a video at its model's per-second rate. The job re-enters GENERATING; poll GET /projects/{projectId}/replication-job. Completion unlocks the project's editor. A scene without a frame must be re-rolled first with POST /projects/{projectId}/clips/{clipId}/regenerate-frame.

POST/projects/{projectId}/replication/retry-build

Retry a FAILED draft build from its last checkpoint.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

202 { status: "started", message, queued? }. 402 when the balance cannot cover a frame pass; 404 when the project has no draft; 409 when the build is already running, the job is not a failed draft-only build, the failure is not retriable, or the draft changed underneath.

Notes

Only the stage that died and the stages after it re-run; finished stages are not billed again. queued true means the retry is parked behind the workspace's 10-build cap and starts when a slot frees. A failed full (non-draft) build is not retried here.

POST/projects/{projectId}/replication/request-edits

Revise a built draft from free-text feedback: the concept is recomposed and every scene rebuilt.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { feedback }, at most 4000 characters.

Response

202 { status: "started", message, queued? }. 400 when feedback is over the cap; 402 when the balance cannot cover a frame pass; 404 when the project has no draft; 409 when feedback is blank, the draft is still being worked on, its build did not complete, an edit or frame is still landing, or it has no plan to revise.

Notes

The feedback goes only to the concept composer; the clip plan, frames, prompts and timeline rebuild from the revised concept, so earlier scene-level edits are replaced. The source analysis is kept. Charged after the fact for the rebuild. Poll GET /projects/{projectId}/replication-job.

PUT/projects/{projectId}/replication/script

Rewrite the draft's whole spoken read; changed stretches re-split into scenes at speech boundaries.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { segments: string[] }: one string per spoken stretch between pinned sung or visual scenes, in play order, exactly as many as the draft has (a stretch is a run of consecutive clips with speech_editable true on GET /projects/{projectId}/scene-frames). At most 50 segments and 20000 characters in total.

Response

200 { clips: [{ clip_id, position, script, duration_seconds, starting_frame_url, scene_kind, scene_label, lyrics, is_new, speech_editable }], total_seconds, frames_generating, max_words_per_clip }. 400 over the segment or character caps; 402 when new scenes need frames the balance cannot cover; 404 when the project has no editable draft; 409 when the draft is still being worked on or already generated, the segment count does not match, a segment is blank, the rewrite passes the draft's duration limit, adds more than 12 scenes, or another edit is landing.

Notes

An unchanged stretch is kept verbatim. A changed clip re-derives its duration; extra text appends new clips (is_new true, starting_frame_url null) whose frames generate in the background (frames_generating), and removed text drops trailing clips. Blank segments are refused so an autosave cannot delete scenes. Also works on a plain script draft with no replication job, where new clips take library frames at once.

PUT/projects/{projectId}/clips/{clipId}/script

Rewrite one spoken scene's script in place; the scene keeps its clip and frame.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, clipId: UUID }; { script }, at most 20000 characters.

Response

200 the same payload as PUT /projects/{projectId}/replication/script. 400 over the cap; 404 when the project has no editable draft or the clip is not on it; 409 when the script is blank, the scene is sung or visual-driven (its words do not own its timing), the text no longer fits one clip (the message says how many words to trim), the draft's duration limit is passed, the draft is being worked on, or another edit of the scene is landing.

Notes

Duration re-derives from the words. No new clips and no frame generation, so no charge. Also works on a plain script draft.

POST/projects/{projectId}/clips/{clipId}/edit-scene

Rewrite exactly one scene from an instruction and regenerate its frames.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, clipId: UUID }; { instruction }, at most 4000 characters.

Response

200 { status: "ok", message, frame_url, frame_urls, frames_generating }: message summarizes the change, frame_url is the scene's first clip's new frame, frame_urls one entry per clip of the scene, frames_generating how many are still generating. 400 over the cap; 402 when the balance cannot cover the scene's frames; 404 when the project has no replication draft or the clip is not on it; 409 when the instruction is blank, the scene has no one on camera, the draft is being worked on or already generated, or another edit is landing.

Notes

Replication drafts only; the rest of the concept is untouched. Blocks for the rewrite and the first clip's frame (allow a minute or more); the scene's other frames generate in the background and the scene stays frameless until they land, which gates generate. Undo with POST /projects/{projectId}/clips/{clipId}/revert-scene-edit.

POST/projects/{projectId}/clips/{clipId}/revert-scene-edit

Undo the newest edit of the clip's scene, restoring the scene and its frames. No generation, no cost.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, clipId: UUID }; no body.

Response

200 { status: "ok", message, frame_url, frame_urls, frames_generating: 0 } with the restored frames. 404 when the project has no replication draft; 409 when the draft has no scene edit to revert, this scene has none, a frame of the scene is still generating, the draft is being worked on, another edit is landing, or the scene changed since the edit.

Notes

Per scene: only the newest edit of a scene is revertible, and calling again steps back one more edit. Edits of other scenes do not block it. Queued frame jobs from the reverted edit are withdrawn.

POST/projects/{projectId}/clips/{clipId}/regenerate-frame

Re-roll one scene's starting frame: a fresh sample, an error retry, or an edited brief.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID, clipId: UUID }; { guidance?, frame_brief? }, each at most 4000 characters. frame_brief replaces the scene's frozen-instant description and the frame generates from it; guidance steers one sample without changing the brief.

Response

200 { status: "ok", frame_url }. 400 over a cap; 402 when the balance cannot cover one frame; 404 when the project has no replication draft or the clip is not on it; 409 when the draft is being worked on, its scenes are not built, or the clip no longer matches the plan.

Notes

Synchronous: up to 4 generation attempts, each screened against the draft's look canon, so allow a few minutes. On success the clip, the frame history on GET /projects/{projectId}/scene-frames and the job checkpoint move to the new frame together, and a frame_brief persists into the scene. A restart mid-run finishes the frame without a new request.

POST/projects/{projectId}/replication/regenerate-look-canon

Regenerate the draft's look canon, the anchor every scene frame follows, from edited outfit, setting and continuity.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { outfit?, background?, continuity?: string[] (at most 20), canon_scene_brief?, raw_brief? (default false) }, text fields at most 4000 characters. Omitted fields keep the concept's values. raw_brief true sends canon_scene_brief verbatim as the whole prompt and ignores the other fields.

Response

200 { status: "ok", frame_url } with the new canon image. 400 over a cap; 402 when the balance cannot cover one frame; 404 when the project has no replication draft; 409 when the draft is being worked on, its scenes are not built, it has no on-camera scene, the avatar has no appearance image, or the canon scene is already generating.

Notes

The new canon replaces the canon scene's frame, re-anchors every later re-roll, and adds a canon entry to that clip's history; frames other scenes already have keep the old canon until they are re-rolled. Synchronous like regenerate-frame.

GET/projects/{projectId}/scene-frames

The draft's scene overview: each clip's frame brief, live frame, frame history, spoken text and editability.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; no body.

Response

200 { look_canon_url, canon: { avatar_image_url, canon_clip_position, outfit, background, continuity, canon_scene_brief, canon_scene_camera_style }, clips: [{ clip_id, position, scene_index, scene_label, camera_style, frame_brief, frame_url, history: [{ frame_url, brief, guidance, source, created_at }], spoken_text, spoken_mode, duration_seconds, speech_editable }] }. 400 on a malformed id; 404 when the project has no replication draft or its scenes are not built yet.

Notes

Read this before editing. frame_brief is the text a re-roll generates from. speech_editable says whether the scene's words own its timing (the script routes refuse a scene where it is false), and consecutive clips with it true form one segment for PUT /projects/{projectId}/replication/script. frame_url null means the scene still needs a frame before generate.

Media Library

Workspace media (images, videos, audio) the agents can pull into videos: uploads and captures from the workspace's website.

GET/assets

List the workspace's media library, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query { limit? (default 24, max 100), offset? (default 0) }; no body.

Response

200 [{ id, label, asset_type: "image" | "video" | "audio", mime_type, size_bytes, url, original_filename, source: "upload" | "website", content_labels, content_description, analysis_pending, created_at, updated_at }], a bare array.

Notes

url is a signed link that expires after 12 hours; list again for a fresh one. content_labels and content_description are the AI analysis, null until the labeling sweep has processed the asset; analysis_pending is true while that is still coming (audio is never analyzed).

POST/assets

Upload a file to the media library.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

multipart/form-data with a form field label and one file part named "file" whose Content-Type is image/png, image/jpeg, image/webp, image/gif, video/mp4, video/quicktime, video/webm, audio/mpeg or audio/mp3 (curl: ;type=image/png).

Response

201 the asset, same shape as GET /assets. 400 when the file part is missing or carries another Content-Type, or label is missing or blank; 409 when the library already holds 30 image and video files.

Notes

The cap counts images and videos only; audio is uncapped. A full library is refused before the file is stored. A file part with no or another Content-Type is ignored and the request fails with 400. Analysis runs afterwards in the background.

Example

curl -X POST https://api.pamba.app/assets \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "label=Hero shot" \
  -F "file=@./hero.png;type=image/png"
POST/assets/website-capture

Capture images and videos from a website into the library.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ website_url? }. Defaults to the workspace's stored website; an empty body is accepted.

Response

200 { created: [asset], skipped_existing }. 400 when there is no website to capture from, the address is invalid, or the site cannot be reached.

Notes

Synchronous: the page fetch is bounded to 15 seconds and the downloads to about 45 seconds; what was captured inside the budget is returned. At most 12 assets per call: page images at least 200 px on a side, up to 2 page videos, and up to 4 App Store screenshots when the page links an app; images over 8 MB and videos over 40 MB are skipped, and a URL already captured counts in skipped_existing. Stops at the 30-file cap. Captures for one workspace run one at a time.

PATCH/assets/{assetId}

Rename an asset.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { assetId: UUID }; { label }.

Response

200 the updated asset. 400 on a malformed id or a blank label; 404 when the asset is not in the workspace.
DELETE/assets/{assetId}

Delete an asset and its file.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { assetId: UUID }; no body.

Response

200 { success: true, message }. 400 on a malformed id; 404 { success: false, message } when the asset is not in the workspace.

Streaming

Subscribe to project status events.

GET/stream/status/{projectId}

SSE status stream for project generation.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; SSE request.

Response

text/event-stream status updates for project generation.

Scheduling and Warming

Operate TikTok and Instagram post queues and pre-post account warming.

GET/devices/accounts/inventory

List every account on the workspace's phones: the valid posting destinations.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { accounts: [{ platform: "tiktok" | "instagram" | "facebook", username, status: "posting" | "track_only" | "archived", avatar_project_id, ... }], avatars }.

Notes

Every account on the workspace's phones. These are the valid destinations for POST /projects/{projectId}/schedule.

GET/devices/tiktok/posts/scheduled

List scheduled posts by page or date range.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Two modes. Page mode: query limit (1-200, default 50), offset (default 0), sort (asc | desc, default desc), status (pending, in_progress, completed, failed, cancelled). Range mode: from and to (both required, ISO 8601 with an offset, at most 90 days apart), status, max (1-5000, default 2000).

Response

200 { posts: ScheduledPost[], total, truncated? }. 400 when only one of from and to is given, to is not after from, or the window exceeds 90 days.
GET/devices/tiktok/posts/{postId}

Get one scheduled post.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { postId: UUID }; no body. Serves TikTok and Instagram posts alike (use each leg's id).

Response

200 ScheduledPost.
PUT/devices/tiktok/posts/{postId}

Edit timing, caption, privacy, video URL, username, or failed-post dismissal.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { postId: UUID }; JSON body any of { "is_soft_deleted"?, "scheduled_at"?, "caption"?, "privacy"?, "keep_tiktok_song"?, "only_create_draft"?, "video_url"?, "tiktok_username"?, "share_to_facebook"? }.

Response

200 ScheduledPost after a field update; 200 { success: true } when is_soft_deleted true dismisses a failed post. 400 when the body has no update field, is_soft_deleted is false (a dismissed post is not restored), the privacy value is unknown, or the caption breaks the caption rules; 404 when the post is not in the workspace.
DELETE/devices/tiktok/posts/{postId}

Cancel a pending post. Optionally archive the project too.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { postId: UUID }; optional query archive_project=true. Cancelling a TikTok post cancels its Instagram leg with it.

Response

200 { success: true }.
POST/devices/tiktok/posts/{postId}/retry

Retry a failed post.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { postId: UUID }; no body. Re-arms a failed or cancelled post.

Response

200 { success: true }.
GET/devices/tiktok/warming/config/avatar-projects/{avatarProjectId}

Read warming config for an avatar project.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarProjectId: UUID }; no body.

Response

200 { enabled, status, total_minutes_daily_anchor, session_count_daily_anchor, search_terms, error_message }.
GET/devices/tiktok/warming/sessions

List warming sessions.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query status, limit (1-200, default 50), offset (default 0), username, avatar_project_id (UUID).

Response

200 { sessions: WarmingSession[], total }. 400 on an unknown status or a malformed avatar_project_id.
GET/devices/tiktok/warming/sessions/{sessionId}

Get one warming session status/detail.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { sessionId: UUID }; no body.

Response

200 WarmingSession status/details.
DELETE/devices/tiktok/warming/sessions/{sessionId}

Cancel a pending warming session.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { sessionId: UUID }; no body.

Response

200 { success: true }.
POST/devices/instagram/avatar-projects/{avatarProjectId}/posts

Schedule an Instagram Reel from one of the avatar's accounts, optionally cross-posted to Facebook.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarProjectId: UUID }; JSON body { caption, scheduled_at, video_url?, project_id?, username?, share_to_facebook? }. scheduled_at is yyyy-MM-dd (Pamba picks a slot in the phone's posting window) or ISO 8601 with an offset (a precise time, moved to the next free slot on a collision). video_url must be an https storage.googleapis.com or storage.cloud.google.com URL; omit it and pass project_id to post the project's final video, resolved at post time. username picks one of the avatar's Instagram accounts; omit it when the avatar has exactly one. share_to_facebook null (the default) cross-posts when the linked Facebook account is ready to post; true or false forces it.

Response

202 { post_id }. 400 { error_code: video_url_required | invalid_video_url | invalid_scheduled_at, message }, or plain text on a malformed avatar_project_id or project_id; 404 plain text when the avatar or project is not in the workspace, or { error_code: "account_not_found", message } when no Instagram account matches; 409 { error_code: project_generating | instagram_account_paused | instagram_account_off_device | account_suspended | duplicate_pending_post | no_linked_facebook_account | facebook_account_paused | schedule_conflict, message }.

Notes

Scheduling a project's post approves the project. The post then shows on GET /devices/tiktok/posts/{postId} and the scheduled list; cancel it with DELETE /devices/tiktok/posts/{postId}. One pending Instagram post per project and account. A project still generating is refused (project_generating) without leaving a post behind. An account with an open SUSPENDED incident cannot post until the incident closes.

POST/devices/facebook/avatar-projects/{avatarProjectId}/posts

Not supported: Facebook posts go out as Instagram cross-posts.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarProjectId: UUID }; the body is not read.

Response

409 { error_code: "facebook_posts_via_instagram", message }, always.

Notes

Facebook has no posting flow of its own. Schedule the Instagram post with share_to_facebook true on POST /devices/instagram/avatar-projects/{avatarProjectId}/posts; the linked Facebook account publishes it.

GET/devices/accounts/warming

Read an account's own warming config, the one that applies while no avatar owns it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query platform (tiktok | instagram) and username; no body.

Response

200 { enabled, status, search_terms: { terms, search_probability } | null, error_message, last_run_at, governed_by_avatar_project_id }. 400 when platform or username is missing; 404 when the account is not on one of the workspace's phones.

Notes

An account with no config row yet answers enabled true and status pending: it warms by default. search_terms are the effective terms, the account's own when set, otherwise the workspace's automatic niche terms. When the account is assigned to an avatar the reply is enabled false with governed_by_avatar_project_id set; the avatar's config governs then, read it on GET /devices/tiktok/warming/config/avatar-projects/{avatarProjectId}.

PUT/devices/accounts/warming

Set the search terms an unassigned account warms with.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ platform: "tiktok" | "instagram", username, warming_search_terms: string[] }.

Response

200 the same config object GET /devices/accounts/warming returns. 400 when platform or username is missing; 404 when the account is not on one of the workspace's phones; 409 when the account belongs to an avatar (its warming is managed on the avatar).

Notes

Only the terms change; the stored session anchors and search probability are kept. With an empty list the account has no terms of its own and sessions use the workspace's niche terms.

Managed Accounts and Seats

Buy Pamba-hosted TikTok and Instagram accounts, name them, watch them get created, and manage their seats.

GET/devices/accounts/seat-options

Price, cap, free seats granted by an operator, and every purchase still being set up.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { price_cents, bundle_seat_price_cents, bundle_eligible, accounts_per_device, current_seats, max_purchasable_seats, free_seats_remaining: { tiktok?, instagram? }, pending_requests: [{ request_id, platform, seats, satisfied_by_existing, jobs: [{ state, stage, desired_username }], names_required, names: [{ username, status, applied_username, failure_reason }], unnamed_seats, link_avatar_project_id }] }.

Notes

A seat is one Pamba-hosted account: $100 a month, every three include a dedicated phone, and every TikTok plus Instagram pair across the subscription prices the second at $50, re-priced when seats are added or cancelled. max_purchasable_seats is what the phones could back right now, not a limit: any quantity up to 1000 accounts per order can be bought, and the rest stay being set up until the team adds phones. Read this before buying: do not buy again while a request is pending, and a purchase never adopts a spare account already on the phones (link those from the avatar instead).

POST/devices/accounts/seat-checkout

Buy seats: returns the Stripe checkout URL.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ tiktok_seats?, instagram_seats?, return_path, link_avatar_project_id? }. return_path is the app path Stripe returns to. link_avatar_project_id auto-links the created accounts to that avatar.

Response

200 { checkout_url }. 400 when both counts are zero or an order exceeds 1000 accounts.

Notes

The user opens checkout_url and pays in the browser. An already subscribed workspace gets a Stripe confirm page that raises the quantity with proration; a purchase that fits an operator grant on every platform checks out at $0. Facebook has no seat; it rides Instagram cross-posting.

POST/devices/accounts/username-suggestions

A checked, free handle per seat still waiting for its name.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{} (empty object).

Response

200 { suggestions: [{ seat_request_id, platform, username }] }, username null when the search found nothing free.

Notes

One suggestion per purchased seat still waiting for its name, each confirmed free before it is offered. Creation does not start until the seat has a name.

POST/devices/accounts/username-check

Whether a handle is currently free on TikTok or Instagram.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ platform: "tiktok" | "instagram", username }.

Response

200 { availability: "available" | "taken" | "invalid" | "unknown", reason }.

Notes

A strong hint, not a reservation: the real claim happens on the phone. unknown is a provider hiccup and proves nothing either way.

PUT/devices/accounts/seat-usernames

Save the picks and start each seat's account creation.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ names: [{ seat_request_id, username }] }, at least one entry; username null means the platform's default name.

Response

200 { outcomes: [{ seat_request_id, username, saved, reason }] }.

Notes

Saving a name starts that seat's creation. Instagram takes the typed name at signup; TikTok assigns its own handle and applies the pick as a rename right after, which uses TikTok's one rename per thirty days. A name that fails when applied falls back to close variations, then keeps the platform handle. Poll GET /devices/accounts/seat-options until the account appears on GET /devices/accounts/inventory with status posting.

GET/devices/accounts/seat-billing

What the next renewal bills.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { paid_seats, pending_cancel_seats, monthly_cents, renews_at_epoch }.

Notes

monthly_cents is the next renewal with pending-cancel seats excluded and pairs at the bundle price. Per-account seat state (seat_status, seat_monthly_cents, seat_cancel_effective_at_epoch) is on GET /devices/accounts/inventory.

POST/devices/accounts/seat/cancel

Stop a seat at the end of its paid period.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ platform, username }.

Response

200 { effective_at_epoch }. 403 unless the caller is a workspace admin; 409 when the account is not seat-funded or already cancelled.

Notes

The account keeps posting until the paid period ends, then posting and billing stop. No refund, because the period was already paid.

POST/devices/accounts/seat/reinstate

Undo a pending cancellation.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ platform, username }.

Response

200 { success: true }. 403 unless the caller is a workspace admin; 409 when the seat is not pending cancellation.

Notes

Undoes a cancellation before its effective date at no cost.

Devices and Account Linking

The workspace's phones, the spare accounts on them, and binding accounts to avatars.

GET/devices/tiktok/devices

The phones and the TikTok accounts scanned on them, with a live ready flag.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ ready, tiktok_accounts: [{ username, display_name }], instagram_accounts, workspace_id, workspace_name, scan_failures }].

Notes

One call to the phone controller: ready is the phone's state right now. Read the workspace's phones off the entries that carry a workspace_id; ignore any other entry. instagram_accounts and scan_failures are always empty on this route; Instagram accounts are on GET /devices/accounts/inventory.

GET/devices/tiktok/linkable-accounts

TikTok accounts on the workspace's phones that no avatar links yet.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ username, ... }]; the phone handle is being reworked (issue #2386) and is not documented yet.

Notes

The spares an avatar can link. A seat purchase never adopts one of these; link it to the avatar instead.

GET/devices/tiktok/posting-readiness

Whether the workspace, and optionally one TikTok account, can post from a phone.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Optional query tiktok_username; no body.

Response

200 { workspace_has_devices, device_count, self_post_mode, account_on_device, device_price_cents, on_device_usernames }.

Notes

self_post_mode is true when the workspace owns no phone and holds no active account seat: its videos are downloaded and posted by hand. account_on_device is null without tiktok_username, or when the lookup failed. on_device_usernames are the TikTok accounts signed in on the workspace's own phones, the destinations a schedule accepts.

GET/devices/instagram/linkable-accounts

Instagram accounts on the workspace's phones that no avatar links yet.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ username, ... }]; the phone handle is being reworked (issue #2386) and is not documented yet.

Notes

Adopt one with POST /devices/instagram/avatar-projects/{avatarProjectId}/adopt-account.

POST/devices/instagram/avatar-projects/{avatarProjectId}/adopt-account

Bind a spare Instagram account already on a workspace phone to an avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarProjectId: UUID }; JSON body { username }.

Response

200 { username }. 400 when the id or username is invalid; 404 when the avatar is not in the workspace; 409 { error_code: "account_not_linkable", message } when the account is not in GET /devices/instagram/linkable-accounts.

Notes

No phone work: the binding is a database change. Posts waiting for the avatar's first Instagram account attach to it. An account that already posts for another avatar is moved with POST /devices/accounts/reassign, not adopted.

POST/devices/instagram/avatar-projects/{avatarProjectId}/link-account

Log an existing Instagram account in on a workspace phone and link it to the avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarProjectId: UUID }; JSON body { username, password } plus the phone the account sits on; the phone handle is being reworked (issue #2386) and is not documented yet.

Response

202 { job_id, state: "running", username }. 400 when the id, username or password is missing; 404 when the avatar is not in the workspace or the phone is not one of its phones.

Notes

The login runs on the phone in the background; poll GET /devices/instagram/link-account-status?job_id=... On success Pamba stores the credentials with the account, binds it to the avatar and attaches any posts waiting for the avatar's first Instagram account. Link jobs live in memory: a finished job is kept for one hour and none survives a backend restart, so a 404 from the status poll can mean either.

GET/devices/instagram/link-account-status

Poll an Instagram link job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query job_id (UUID); no body.

Response

200 { job_id, state: "running" | "completed" | "failed", username, error_message }. 400 on a malformed job_id; 404 when the job is unknown or another workspace's.

Notes

error_message on a failed job is the phone's error code and message, for example a wrong password or a checkpoint.

POST/devices/facebook/avatar-projects/{avatarProjectId}/link-account

Log an existing Facebook account in on a workspace phone and link it to the avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarProjectId: UUID }; JSON body { username, identifier, password } plus the phone the account sits on; the phone handle is being reworked (issue #2386) and is not documented yet. username is the profile's display name (Facebook has no handle); identifier is the login phone number or email.

Response

202 { job_id, state: "running", username }. 400 when username, identifier or password is blank or the id is malformed; 404 when the avatar is not in the workspace or the phone is not one of its phones.

Notes

Runs on the phone in the background; poll GET /devices/facebook/link-account-status?job_id=... On success the credentials are stored with the account and it is bound to the avatar. Same in-memory job lifetime as the Instagram link: one hour after it finishes, gone on a backend restart.

GET/devices/facebook/link-account-status

Poll a Facebook link job.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query job_id (UUID); no body.

Response

200 { job_id, state: "running" | "completed" | "failed", username, error_message }. 400 on a malformed job_id; 404 when the job is unknown or another workspace's.
POST/devices/accounts/reassign

Move an account from one avatar to another in the same workspace.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ platform: "tiktok" | "instagram" | "facebook", username, from_avatar_project_id, avatar_project_id }. from_avatar_project_id is the binding the caller saw; the move is refused when it is no longer current.

Response

200 { status: "ok" }. 400 { message } on an unknown platform or malformed id, when the target avatar is not in the workspace, when the account is no longer bound to from_avatar_project_id.

Notes

Only the avatar binding moves: the account keeps its posting status, phone and analytics history. Same source and target is a no-op 200.

Account Profiles

Change what an account's profile shows and whether it posts; every profile edit runs on the phone as a device job.

POST/devices/accounts/profile-pictures

Upload a profile picture and get the URL the profile edits accept.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

multipart/form-data with one file part named image (image/jpeg, image/png, image/webp or image/gif), at most 25 MB.

Response

200 { profile_picture_url }. 400 when there is no image part of an allowed type; 413 when the file is over 25 MB.

Notes

The URL is the only value new_profile_picture_url accepts on the TikTok, Instagram and Facebook profile edits: the phone downloads pictures from Pamba's storage bucket and nowhere else. The picture belongs to no avatar, so an unassigned account can use it too. Only the first image part is read.

Example

curl -X POST https://api.pamba.app/devices/accounts/profile-pictures \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "image=@portrait.jpg;type=image/jpeg"
PATCH/devices/tiktok/accounts/{username}/profile

Edit a TikTok account's name, handle, picture or bio, or toggle its AI-comment filter.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username } (with or without @, case-insensitive); JSON body with at least one of { new_display_name?, new_username?, new_profile_picture_url?, new_bio?, disable_ai_comments?, auto_block_ai_comments? }. new_profile_picture_url must come from POST /devices/accounts/profile-pictures. new_bio is at most 160 characters (code points), newlines allowed.

Response

200 { username, display_name, profile_picture_changed, verification_screenshot, operations: [{ name, result: { requested, succeeded, applied_value, error_code, error_message, error_reason } }], disable_ai_comments, auto_block_ai_comments }. 202 { queued: true, job_id, message, next_attempt_at, applying, auto_block_ai_comments } when the phone is busy, scheduled or unreachable. 400 { error_code: missing_fields | bio_too_long | bio_invalid | username_taken | username_invalid, message }; 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones; a failure the phone reports as final comes back as { error_code, message } with the status it carries, most often 502.

Notes

Blocks while the phone applies the edit: up to 6 minutes for the profile fields, and a disable_ai_comments change then runs a comment-filter keyword sync polled for up to 45 minutes, so set the client timeout for the sum. A 202 means the edit is persisted and retried automatically for 24 hours; applying true means the phone is already applying this same change and the request added nothing. Only requested fields are outcomes: the phone reports every profile field, and one the edit did not ask for comes back requested false, so read entries with requested true only. A requested field the phone did not report arrives as requested true, succeeded false with error_code missing_operation or field_not_attempted. new_username is checked against a live availability lookup first (username_taken, username_invalid); a rename spends TikTok's one rename per thirty days. auto_block_ai_comments is the persisted automation opt-in and is saved even when the phone work queues. username in the 200 is the handle after any rename.

PATCH/devices/instagram/accounts/{username}/profile

Edit an Instagram account's name, handle, picture or bio.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username } (with or without @, case-insensitive); JSON body with at least one of { new_display_name?, new_username?, new_profile_picture_url?, new_bio? }. new_profile_picture_url must come from POST /devices/accounts/profile-pictures. new_bio is at most 150 characters (code points).

Response

200 { username, display_name, profile_picture_changed, operations: [{ name, result: { requested, succeeded, applied_value, error_code, error_message, error_reason } }] }. 202 { queued: true, job_id, message, next_attempt_at, applying } when the phone is busy, scheduled or unreachable. 400 { error_code: missing_fields | bio_too_long | bio_invalid | username_taken | username_invalid, message }; 404 { error_code: "account_not_found", message }; a failure the phone reports as final is { error_code, message } with the status it carries.

Notes

Blocks up to 6 minutes while the phone applies the edit. Outcomes follow the TikTok edit's rule: read operations with requested true only. Instagram allows five handle changes per thirty minutes; a rename that hits the limit comes back 202 with next_attempt_at about 35 minutes out.

PATCH/devices/facebook/accounts/{username}/profile

Edit a Facebook account's name, username or picture.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username }: the profile's display name, matched exactly after trimming; JSON body with at least one of { new_display_name?, new_username?, new_profile_picture_url? }. new_profile_picture_url must come from POST /devices/accounts/profile-pictures. Facebook profiles have no bio field.

Response

200 { username, display_name, profile_picture_changed, operations: [{ name, result: { requested, succeeded, applied_value, error_code, error_message, error_reason } }] }. 202 { queued: true, job_id, message, next_attempt_at, applying } when the phone is busy, scheduled or unreachable. 400 { error_code: "missing_fields", message }; 404 { error_code: "account_not_found", message }; a failure the phone reports as final is { error_code, message } with the status it carries.

Notes

Blocks up to 6 minutes while the phone applies the edit. Outcomes follow the TikTok edit's rule: read operations with requested true only.

PATCH/devices/instagram/accounts/{username}/status

Pause or resume an Instagram account's posting and warming.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username }; JSON body { status: "TRACK_ANALYTICS_AND_POST_VIDEOS" | "ONLY_TRACK_ANALYTICS" }.

Response

200 with an empty body. 400 { error_code: "invalid_status", message } for any other value, archiving included; 404 { error_code: "account_not_found", message }.

Notes

No phone work. Pausing (ONLY_TRACK_ANALYTICS) cancels the account's pending warming sessions and scheduled posts; analytics keep collecting. Resuming does not restore what was cancelled.

PATCH/devices/facebook/accounts/{username}/status

Pause or resume a Facebook account's posting and warming.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username } (the profile's display name); JSON body { status: "TRACK_ANALYTICS_AND_POST_VIDEOS" | "ONLY_TRACK_ANALYTICS" }.

Response

200 with an empty body. 400 { error_code: "invalid_status", message } for any other value; 404 { error_code: "account_not_found", message }.

Notes

No phone work. Pausing cancels the account's pending warming sessions and scheduled posts. A paused Facebook account also stops receiving Instagram cross-posts: share_to_facebook true is refused with facebook_account_paused, and the automatic cross-post skips it.

PATCH/devices/instagram/accounts/{username}/ai-creator

Turn on Instagram's AI creator label for the account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username }; JSON body { enabled: true }.

Response

200 { ai_creator_enabled: true, already_enabled: false }. 202 { queued: true, job_id, message, next_attempt_at, applying } when the phone is busy, scheduled or unreachable. 400 { error_code: "disable_not_supported", message } when enabled is false; 404 { error_code: "account_not_found", message }; a failure the phone reports as final is { error_code, message } with the status it carries.

Notes

Runs on the phone as a device job and blocks up to 6 minutes. The label can only be enabled from Pamba, never disabled. already_enabled is always false on this route.

Account Health, Incidents and Activity

What the phones saw on each account: TikTok health checks, incidents on every platform, appeals, and the per-account activity feed.

GET/devices/accounts/tiktok/health

Latest health check and open issues for up to 50 TikTok accounts in one call.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query usernames: comma-separated handles, 1 to 50, with or without @; no body.

Response

200 { accounts: { <username>: { username, latest_check: { id, status, created_at, analyzed_at, account_warning, account_warning_detail, issue_count, frames: [{ label, kind, video_index, url }] } | null, issues: [{ incident_id, kind, label, short_label, summary, reason, posted_on, evidence_urls, opened_at, appealable, appeal_requested_at, appeal_processed_at }] } } }. 400 when usernames is missing or names more than 50.

Notes

Keys are the handles lowercased with @ stripped; every requested handle gets an entry, latest_check null and issues [] when the workspace has nothing on it. Checks come from the warming sessions' vision pass over the profile. Issue kinds are ACCOUNT_WARNING, VIDEO_REMOVED and VIDEO_NOT_RECOMMENDED, all appealable; a video kind opens one issue per video.

GET/devices/accounts/tiktok/{username}/health

Latest health check and open issues for one TikTok account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { username } (with or without @); no body.

Response

200 { username, latest_check, issues }, one entry of GET /devices/accounts/tiktok/health. 400 when username is blank.

Notes

Never 404: an account the workspace has nothing on answers latest_check null and issues [].

GET/devices/accounts/tiktok/health/checks/{checkId}

One TikTok health check by id, with the account it was taken on.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { checkId }; no body.

Response

200 { username, avatar_project_id, check: { id, status, created_at, analyzed_at, account_warning, account_warning_detail, issue_count, frames: [{ label, kind, video_index, url }] } }. 400 when checkId is not a UUID; 404 when the check is not one of this workspace's.

Notes

The check is the latest_check shape of GET /devices/accounts/tiktok/health; the id comes from that call or from the account activity feed's health_check items.

GET/devices/accounts/incidents

The workspace's account incidents on every platform.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Optional query platform (tiktok | instagram | facebook), username, include_closed (default false); no body.

Response

200 { incidents: [{ id, platform, username, avatar_project_id, kind, source, opened_at, closed_at, closed_by, detail }] }. 400 on an unknown platform.

Notes

kind is SUSPENDED (Meta suspended the account), or ACCOUNT_WARNING, VIDEO_REMOVED, VIDEO_NOT_RECOMMENDED (TikTok health). source is DEVICE_VISION, LINKED_ACCOUNT or MANUAL; closed_by is CLEAN_SESSION, MANUAL or RESTORED. detail is a JSON object whose keys depend on the kind. A username filter matches the lowercase handle, or the exact display name on Facebook. An account with an open SUSPENDED incident cannot schedule posts.

POST/devices/accounts/incidents/{id}/appeal

Ask Pamba's team to appeal a TikTok health issue on the phone.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: incident UUID }; no body.

Response

200 { outcome: "REQUESTED" | "ALREADY_REQUESTED", incident_id, appeal_requested_at }. 400 on a malformed id; 404 { outcome: "NOT_FOUND", incident_id: null, appeal_requested_at: null } when the incident is not open in this workspace; 409 { outcome: "NOT_APPEALABLE", incident_id, appeal_requested_at: null } for a kind that cannot be appealed.

Notes

Only ACCOUNT_WARNING, VIDEO_REMOVED and VIDEO_NOT_RECOMMENDED can be appealed. The request alerts the operators, who file the appeal in the TikTok app; a second call returns ALREADY_REQUESTED with the original time. Progress shows as appeal_requested_at and appeal_processed_at on the health issue.

POST/devices/accounts/incidents/{id}/close

Close (dismiss) one of the workspace's incidents by hand.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: incident UUID }; no body.

Response

200 { success: true }. 400 on a malformed id; 404 when the incident is not in this workspace; 409 when it is already closed.

Notes

Closes with closed_by MANUAL. For a TikTok health issue this is the dismissal: a later check does not reopen or re-alert it.

GET/devices/accounts/activity

One account's activity feed: warming sessions, health checks, posts and profile edits, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query platform (tiktok | instagram), username, optional limit (1 to 50, default 20) and before (ISO 8601 with an offset, the paging cursor); no body.

Response

200 { items: [{ kind: "warming_session" | "health_check" | "post" | "profile_edit", id, at, status, ... }], next_before }. 400 when platform or username is missing or before is malformed; 404 when the account is not on one of the workspace's phones.

Notes

Per kind: warming_session adds duration_seconds, attempt_count, error_message, videos_watched, likes, comment_reads, search_term, screenshot_count, sample_screenshot_urls; health_check (TikTok only) adds account_warning, issue_count, screenshot_count; post adds title, caption, error_message, screenshot_count; profile_edit adds job_kind, fields, failed_fields, error_message. Fields a kind does not carry are null or absent. Page by passing next_before back as before; null ends the feed.

Operator Account Creation

Create a platform account on a specific phone for an avatar and follow the signup; operators only, customers buy seats instead.

POST/devices/tiktok/avatar-projects/{avatarProjectId}/create-tiktok-account

Start a TikTok signup on one of the workspace's phones for the avatar.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { avatarProjectId: UUID }; JSON body { replace_username?, identity_name? } plus the phone to create on; the phone handle is being reworked (issue #2386) and is not documented yet. replace_username logs that account out of the phone first; identity_name overrides the avatar's name as the identity behind the mailbox and profile.

Response

202 { job_id, ... }. 400 on a malformed avatar_project_id; 404 when the avatar has no selected avatar or the phone is not one of the workspace's phones; 409 plain text when the phone is not ready, or { error_code: "account_creation_rejected", message } when it already runs a creation or its automation is not ready.

Notes

Runs in the background: provisions a mailbox, drives the signup on the phone and verifies the handle. Poll GET /devices/tiktok/account-creations/{jobId}. A replace adds the logout flow, several minutes, before the signup starts. Customers get accounts through the seat routes, never here.

GET/devices/tiktok/account-creations/{jobId}

Poll a TikTok account creation.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { jobId: UUID }; no body.

Response

200 { job_id, state: "running" | "succeeded" | "failed", stage: "provisioning_email" | "creating_account" | "awaiting_verification_code" | "verifying_handle" | "completed" | "failed", email_address, tiktok_username, error_message }. 400 on a malformed id; 404 when the job is not in this workspace.

Notes

tiktok_username is set once the signup reported its handle. A failed job keeps the stage it died on; POST /devices/tiktok/account-creations/{jobId}/resume re-enters there.

POST/devices/tiktok/account-creations/{jobId}/resume

Re-enter a failed TikTok account creation at the step it died on.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { jobId: UUID }; no body.

Response

202 { job_id, ... }. 400 on a malformed id; 404 when the job is not in this workspace; 409 { error_code: "account_creation_not_resumable", message } when the job is not failed, the phone already runs a creation, the phone is not ready, the job's mailbox is past its claim horizon, or the job could not be reopened.

Notes

Reuses the provisioned mailbox and everything the job already recorded, so the expensive steps are not paid twice. Poll GET /devices/tiktok/account-creations/{jobId}.

POST/devices/instagram/avatar-projects/{avatarProjectId}/create-account

Start an Instagram signup on a phone for the avatar.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { avatarProjectId: UUID }; JSON body { full_name? } plus the phone to create on; the phone handle is being reworked (issue #2386) and is not documented yet. full_name overrides the avatar's name on the new profile.

Response

202 { job_id, ... }. 400 on a malformed avatar_project_id; 404 when the avatar has no selected avatar; 409 { error_code: "account_creation_rejected", message } when the phone is not ready or already runs an Instagram creation.

Notes

Runs in the background; poll GET /devices/instagram/account-creations/{jobId}.

GET/devices/instagram/account-creations/{jobId}

Poll an Instagram account creation.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { jobId: UUID }; no body.

Response

200 { job_id, state: "running" | "succeeded" | "failed", stage: "provisioning_email" | "installing_app" | "creating_account" | "awaiting_email_code" | "awaiting_sms_code" | "completed" | "failed", email_address, instagram_username, error_message }. 400 on a malformed id; 404 when the job is not in this workspace.
POST/devices/facebook/avatar-projects/{avatarProjectId}/create-account

Start a Facebook signup on a phone for the avatar.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { avatarProjectId: UUID }; JSON body { full_name? } plus the phone to create on; the phone handle is being reworked (issue #2386) and is not documented yet.

Response

202 { job_id, ... }. 400 on a malformed avatar_project_id; 404 when the avatar has no selected avatar; 409 { error_code: "account_creation_rejected", message } when the phone already runs a Facebook creation.

Notes

Runs in the background; poll GET /devices/facebook/account-creations/{jobId}.

GET/devices/facebook/account-creations/{jobId}

Poll a Facebook account creation.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { jobId: UUID }; no body.

Response

200 { job_id, state: "running" | "succeeded" | "failed", stage: "provisioning_email" | "installing_app" | "creating_account" | "awaiting_email_code" | "awaiting_sms_code" | "completed" | "failed", email_address, facebook_username, error_message }. 400 on a malformed id; 404 when the job is not in this workspace.

Content Strategy and Campaigns

The automation layer: strategy, campaigns, avatar assignments, cadence, and the content plans they produce.

GET/content/strategy

Read the workspace strategy: planning mode, hit bar, mix targets, assignments, constraints and exceptions.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { planning_mode, basis_description, working_threshold_views, experimental_double_posts_per_avatar, mix_targets: [{ category, target_share, rationale }], assignments: [{ id, tiktok_username, avatar_id, avatar_project_id, avatar_name, lane, cadence_per_day, status: "ACTIVE" | "PAUSED", tier, primary_type, rationale, mimic_playbook_ids }], constraints: [{ id, pattern_id, constraint_type, mode, tag, cap_per_week, reason }], exceptions: [{ id, scope, batch_id, exception_type, assignment_id, avatar_id, pattern_id, instruction, status }] }.

Notes

The strategy row is created on first read; working_threshold_views reads as 10000 until set. assignments are the TikTok accounts linked to the workspace's avatars (posting or paused, never archived). An assignment id is what the campaign, exception and plan routes take.

PUT/content/strategy

Update planning mode, hit bar, experimental tests per avatar, or the basis description.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ planning_mode?: "PATTERN_LED" | "MIMIC_LED", working_threshold_views?, experimental_double_posts_per_avatar?, basis_description? }. Only the fields sent change.

Response

200 the same payload as GET /content/strategy.

Notes

planning_mode is stored as sent; the planner treats any value other than MIMIC_LED as pattern-led. working_threshold_views is the view count a post needs to count as a hit, in GET /content/strategy/cross-tab and in planning. Every change lands in GET /content/strategy/changes.

PUT/content/strategy/mix-targets

Replace the content-mix targets: the share of planned posts each category should get.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

A bare JSON array [{ category, target_share, rationale? }], not an object. List order becomes sort order.

Response

200 { success: "true" }.

Notes

Replaces the whole list; an empty array clears it. category is a content format name (GET /content/strategy/formats). target_share is a fraction of the batch (0.4 for 40%); a plan whose actual share is more than 0.10 away from a target reports it in policy_diff.

POST/content/strategy/assignments

Configure planning settings on an avatar's linked TikTok account: lane, cadence, status, tier, type, mimics.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ tiktok_username, avatar_id, id?, avatar_project_id?, lane?, cadence_per_day? (default 1), status?: "ACTIVE" | "PAUSED" (default ACTIVE), tier?, primary_type?, rationale?, mimic_playbook_ids?: UUID[] }. id (the assignment id) is the exact selector; avatar_project_id plus tiktok_username is the next best; avatar_id plus tiktok_username alone is a legacy selector that fails when two projects share the avatar and account.

Response

201 { id }. 400 on a malformed id or a status other than ACTIVE or PAUSED; 404 when the account is not linked to the avatar; 409 when more than one binding matches, primary_type names no avatar type, or the status change is refused.

Notes

Link the account to the avatar first; this route only configures an existing binding. status defaults to ACTIVE, so a paused binding sent without status is reactivated; PAUSED cancels the account's pending posts and warming sessions. mimic_playbook_ids replaces the list. tier is written on the avatar and primary_type sets the avatar's type (GET /content/strategy/avatar-types).

PUT/content/strategy/assignments/{assignmentId}/status

Pause or reactivate an assignment. A paused assignment is never planned.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { assignmentId: UUID }; { status: "ACTIVE" | "PAUSED", reason? }.

Response

200 { success: "true" }. 400 when status is missing or not ACTIVE or PAUSED; 404 when the assignment is not in the workspace.

Notes

Pausing sets the account to analytics-only and cancels its pending posts and warming sessions; the account stays linked. reason is recorded in the audit log.

POST/content/strategy/constraints

Add a standing constraint on a pattern: a tag lock or a weekly cap.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ pattern_id, constraint_type: "IDENTITY_LOCK" | "VISUAL_FORMAT_LOCK" | "WEEKLY_CAP", mode?: "REQUIRES_TAG" | "BLOCKS_TAG", tag?, cap_per_week?, reason? }.

Response

201 { id }. A pattern_id that is not a UUID is a 500, not a 400.

Notes

Locks are checked against the avatar's tags: IDENTITY_LOCK requires tag unless mode is BLOCKS_TAG; VISUAL_FORMAT_LOCK blocks tag unless mode is REQUIRES_TAG. A locked pattern is refused for that avatar by the planner and by plan edits (violation rule lock_constraint) and listed under blocked_patterns on GET /content/avatars/{avatarId}/profile. WEEKLY_CAP with cap_per_week limits the pattern's slots per week across the workspace; a cap set on the pattern itself wins. pattern_id is not checked against the workspace. Any other constraint_type is stored and has no effect.

DELETE/content/strategy/constraints/{constraintId}

Remove a pattern constraint.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { constraintId: UUID }; no body.

Response

200 { success: "true" }. 400 on a malformed id; 404 when the constraint is not in the workspace.
POST/content/strategy/exceptions

Record a standing exception: an instruction scoped to an assignment, avatar, pattern or batch.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ exception_type, instruction, scope?: "DURABLE" | "BATCH" (default DURABLE), batch_id?, assignment_id?, avatar_id?, pattern_id? }.

Response

201 { id }. A malformed id, or an assignment_id that is not an active assignment in the workspace, is a 500.

Notes

Only exception_type PAUSE_ACCOUNT with an assignment_id changes planning: that assignment is skipped while the exception is ACTIVE. Every other exception is stored, listed on GET /content/strategy for reviewers, and nothing reads its instruction. exception_type and scope are free text. Deleting a plan removes the exceptions carrying its batch_id.

POST/content/strategy/exceptions/{exceptionId}/revoke

Revoke a standing exception.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { exceptionId: UUID }; no body.

Response

200 { success: "true" }. 400 on a malformed id; 404 when the exception is not in the workspace.

Notes

Sets status to REVOKED; the row stays on GET /content/strategy.

GET/content/strategy/model-routing

List the active video-model routing rules.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { rules: [{ id, scope: "ACCOUNT" | "PATTERN" | "AVATAR" | "WORKSPACE_DEFAULT", account, assignment: "FIXED" | "BALANCED_SPLIT", video_model, video_models (comma-separated), reason }] }.

Notes

Precedence when a slot is planned: the campaign member's pin (PUT /content/strategy/campaigns/{campaignId}/avatar-video-model), then ACCOUNT, PATTERN, AVATAR and WORKSPACE_DEFAULT rules, then the built-in default gemini-omni. Rules are written by the avatar's default video model setting (the avatar routes) and by operator imports; this module has no rule write route.

GET/content/strategy/model-routing/resolve

Resolve which video model the rules pick for an account, pattern and avatar.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query { account?, pattern_id?, pattern_slug?, avatar_id? }; no body.

Response

200 { video_model, matched_rule_id, scope }. scope is the matched rule's scope, or BUILT_IN_DEFAULT when nothing matched.

Notes

Rules only: campaign member pins are not applied. A BALANCED_SPLIT rule resolves to its first model. pattern_slug only serves a rule's excluded patterns.

GET/content/strategy/cross-tab

Pattern-by-account performance from verified posts: the table the planner allocates from.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query { lookback_days? (default 30), windows? (1 adds the 7, 14, 30, 90 and 3650-day windows) }; no body.

Response

200 { hit_threshold_views, lookback_days, cells: [{ pattern_id, tiktok_username, count, avg_views, median_views, top_views, low_views, hits, hit_rate, score, weight }], patterns: [{ pattern_id, posts, hits, hit_rate, avg_views, median_views, top_views, low_views, score }], windows: { "7": { cells, patterns }, ... } }.

Notes

Counts posts that completed on a phone, were verified on TikTok and carry a pattern classification. A hit is at or above hit_threshold_views (working_threshold_views, or 10000 when unset or zero). hit_rate and score in cells are recency-weighted (half-life about 21 days). windows is empty unless windows=1; that variant joins the full history and is the heavier call.

GET/content/strategy/changes

The strategy audit log, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query { limit? (default 50) }; no body.

Response

200 { changes: [{ source, summary, at }] }.

Notes

source is the change origin (STRATEGY_EDIT for these routes, BRIEF_EDIT_PROMOTED for a plan edit promoted to standing, import sources for operator imports). summary is "<table>: <note>". at is a UTC timestamp without an offset.

GET/content/avatars/{avatarId}/profile

One avatar's planning profile: tags, assignments, blocked patterns, model routing, recent performance, stock.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarId: UUID }; no body. avatarId is the avatar id, not the avatar project id.

Response

200 { avatar_id, tags: string[], assignments: [{ tiktok_username, lane, cadence_per_day, status, tier }], blocked_patterns: [{ pattern_slug, reason }], model_routing: [{ account, video_model, scope }], performance: [{ pattern_id, tiktok_username, count, avg_views, hit_rate }], pipeline_stock }. 400 on a malformed id; 404 when the avatar is not in the workspace.

Notes

blocked_patterns are the lock constraints the avatar's tags trip. performance is the 14-day cross-tab for the avatar's accounts. pipeline_stock is the avatar's visible, unscheduled projects. cadence_per_day, count, avg_views, hit_rate and pipeline_stock are JSON strings, not numbers.

GET/content/strategy/campaigns

List campaigns with schedule, seed, members and per-member overrides, plus the automation gate.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { automation_access: boolean, campaigns: [{ id, name, is_default, enabled, planning_mode, pattern_formats: string[], seed: { mode: "all" | "categories" | "patterns" | "mimic" | "ideas", categories?, patternIds?, playbookIds?, ideaIds? }, interval_unit, interval_count, next_fire_at, member_count, hit_bar_views, tests_per_avatar_week, auto_approve_brief, review_windows: [{ day, local_time }], lead_time_hours, timezone, member_cadence: { <assignment_id>: scripts_per_day }, member_seeds: { <assignment_id>: seed }, member_video_models: { <assignment_id>: video_model } }] }.

Notes

The Default campaign is created on first read and owns every assignment with no home campaign. automation_access is false when the workspace has no active subscription (workspaces owned by Pamba staff are exempt); enabling or running a campaign then returns 402. member_count counts only members whose avatar is active. next_fire_at is null for a disabled campaign or one with no review windows. Seed keys are camelCase.

POST/content/strategy/campaigns

Create a campaign. It starts disabled with the Default campaign's schedule.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ name }.

Response

200 { id }. 400 when name is missing.

Notes

Copies the Default's cadence mode, planning days, lead time, review windows, timezone and autonomy. Add members with PUT /content/strategy/campaigns/membership, then turn it on with PUT /content/strategy/campaigns/{campaignId}.

PUT/content/strategy/campaigns/{campaignId}

Edit a campaign: name, enabled, planning mode, seed, interval, hit bar, tests, auto-approve, review windows, lead time.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { campaignId: UUID }; { name?, enabled?, planning_mode?: "PATTERN_LED" | "MIMIC_LED", seed?: { mode: "all" | "categories" | "patterns" | "mimic" | "ideas", categories?, patternIds?, playbookIds?, ideaIds? }, pattern_formats?: string[] (legacy form of a categories seed), interval_unit?: "WEEKLY" | "EVERY_N_WEEKS" | "EVERY_N_DAYS" | "MONTHLY", interval_count?, hit_bar_views?, tests_per_avatar_week?, auto_approve_brief?, review_windows?: [{ day, local_time }], lead_time_hours?, clear_overrides?: ("hit_bar_views" | "tests_per_avatar_week" | "auto_approve_brief")[] }.

Response

200 { ok: true }. 400 on a malformed id; 402 { message } when enabled is true and the workspace has no active subscription.

Notes

planning_mode is written on every call: omit it and the campaign inherits the workspace planning mode again. A campaign not in the workspace is a silent 200. interval_count and lead_time_hours are floored at 1; an interval_unit outside the four is weekly. hit_bar_views, tests_per_avatar_week and auto_approve_brief override the workspace values; clear_overrides puts them back to inherit. An EVERY_N_DAYS campaign fires on its interval and ignores review_windows. Enabling commits to recurring credit spend: the campaign plans, writes and generates on its schedule until disabled. Every call re-derives next_fire_at.

POST/content/strategy/campaigns/{campaignId}/run

Run the campaign now: plan the gap before its next scheduled window.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { campaignId: UUID }; no body.

Response

200 { batch_id }, null when the campaign is not in the workspace or every day in the gap is already planned. 400 on a malformed id; 402 { message } without an active subscription.

Notes

Plans tomorrow through the day before the next scheduled window (at most 92 days), only for avatar-days with nothing planned yet; when the scheduled window starts today or tomorrow it plans that window instead. The batch lands in stage brief_pending for approval (POST /content/batches/{batchId}/plan/approve); the scheduled fire still runs at its own time. Synchronous: the allocator runs in the request with no model call. Works on a disabled campaign too.

POST/content/strategy/campaigns/{campaignId}/delete

Delete a campaign. Its members go back to the Default campaign.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { campaignId: UUID }; no body.

Response

200 { ok: true }. 400 on a malformed id; 409 { ok: false } for the Default campaign or one not in the workspace.

Notes

A soft delete: the campaign is disabled and hidden from the list, and past batches keep pointing at it. Extra memberships and member seed overrides are removed.

PUT/content/strategy/campaigns/membership

Set an assignment's home campaign, or move it back to Default.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ assignment_id, campaign_id? }. Omit campaign_id (or send null) for the Default campaign.

Response

200 { ok: true }. 400 when assignment_id is missing or malformed.

Notes

A campaign or assignment outside the workspace, or an archived assignment, is a silent 200 with nothing written. Extra memberships from PUT /content/strategy/campaigns/memberships are left as they were.

PUT/content/strategy/campaigns/memberships

Set every campaign an assignment belongs to: the first is its home, the rest are extra memberships.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ assignment_id, campaign_ids: UUID[] }. An empty list means the Default campaign only.

Response

200 { ok: true }. 400 when assignment_id is missing or malformed.

Notes

Malformed campaign ids are dropped before the write. When any listed campaign, or the assignment, is not in the workspace, nothing is written and the call still returns 200. A member of several campaigns is planned by each of them.

PUT/content/strategy/campaigns/{campaignId}/avatar-cadence

Set how many scripts a day this campaign plans for one member.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { campaignId: UUID }; { assignment_id, scripts_per_day? (default 1, floored at 1) }.

Response

200 { ok: true }. 400 on a malformed campaignId or a missing assignment_id; 404 { ok: false } when the campaign or assignment is not in the workspace.

Notes

Overrides the assignment's cadence_per_day inside this campaign only.

PUT/content/strategy/campaigns/{campaignId}/avatar-video-model

Pin the video model this campaign generates with for one member, or clear the pin.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { campaignId: UUID }; { assignment_id, video_model?: "gemini-omni" | "seedance" | "grok-imagine-1.5" }. Omit video_model (or send null) to clear.

Response

200 { ok: true }. 400 on a malformed campaignId, a missing assignment_id, or a video_model outside the three; 404 { ok: false } when the campaign or assignment is not in the workspace.

Notes

The pin beats every routing rule for this member's slots in this campaign's batches. Cleared, the member follows GET /content/strategy/model-routing, then gemini-omni.

PUT/content/strategy/campaigns/{campaignId}/member-seed

Override what fills the batch for one member: a category list or a pattern list.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { campaignId: UUID }; { assignment_id, seed?: { mode: "categories" | "patterns" | "all", categories?: string[], patternIds?: UUID[] } }. Omit seed (or send null, or mode all) to clear the override.

Response

200 { ok: true }. 400 on a malformed campaignId, a missing assignment_id, a mode other than all, categories or patterns, or a list that is empty after dropping malformed ids; 404 { ok: false } when the campaign or assignment is not in the workspace.

Notes

mimic and ideas seeds are campaign-level only (PUT /content/strategy/campaigns/{campaignId}). Seed keys are camelCase (patternIds).

GET/content/strategy/campaigns/assignments

The assignment roster with each one's home campaign and whether its avatar is active.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { assignments: [{ id, avatar_id, avatar_project_id, avatar_name, tiktok_username, campaign_id, status: "ACTIVE" | "PAUSED", avatar_active }] }.

Notes

campaign_id null means the Default campaign. Extra memberships are on GET /content/strategy/avatar-options (campaign_ids). An assignment whose avatar_active is false is never planned.

Strategy Taxonomies and Avatar Settings

Content formats, avatar types, audiences, per-avatar strategy and per-account daily caps: the vocabulary the planner allocates within.

GET/content/strategy/formats

List content formats: the global set plus the workspace's own.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { items: [{ id, name, label, description, is_global }] }.

Notes

Global formats (yapper, carousel, hook_to_demo, sequence_of_images, fade_in, quote_text_overlay, podcast, yapper_overlay, lipsync, broll, other) are seeded on first read and read-only. A format name is what a pattern's category, a mix target and a categories seed refer to.

POST/content/strategy/formats

Create a workspace content format, or return the existing one with that name.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ name, label?, description? }.

Response

200 { id }. 400 when name is missing.

Notes

name is trimmed. A name that already exists (global or workspace) returns that id instead of a duplicate; when description is sent, a workspace format's label and description are overwritten and a global one is untouched.

PUT/content/strategy/formats/{id}

Set a workspace format's label and description.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; { label?, description? }. Both are written; an omitted field is cleared.

Response

200 { ok: true }. 400 on a malformed id; 403 for a global format or one not in the workspace.
DELETE/content/strategy/formats/{id}

Delete a workspace format.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { ok: true }. 400 on a malformed id; 403 for a global format; 404 when not in the workspace; 409 { error } while a pattern still has the format as its category.

Notes

Avatar type mappings to the format go with it.

GET/content/strategy/audiences

List the workspace's audiences.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { items: [{ id, name, label, description, is_global: false }] }.

Notes

Audiences are labels only. An avatar's target_audience is the fixed ALL, MALE or FEMALE, and nothing in planning reads these entries yet.

POST/content/strategy/audiences

Create an audience, or return the existing one with that name.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ name, label?, description? }.

Response

200 { id }. 400 when name is missing.

Notes

name is trimmed. An existing name returns its id; when description is sent, that entry's label and description are overwritten.

PUT/content/strategy/audiences/{id}

Set an audience's label and description.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; { label?, description? }. Both are written; an omitted field is cleared.

Response

200 { ok: true }. 400 on a malformed id; 403 when the audience is not in the workspace.
DELETE/content/strategy/audiences/{id}

Delete an audience.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200 { ok: true }. 400 on a malformed id; 404 when the audience is not in the workspace.
GET/content/strategy/avatar-types

List avatar types with the formats each can produce.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { avatar_types: [{ id, name, label, description, format_ids: UUID[], is_global }] }.

Notes

Global types Yapper (every format except podcast, plus the workspace's own formats) and Podcaster (podcast) are seeded on first read and read-only. Yapper is the default for a new avatar. A type's formats bound which patterns its avatars are eligible for.

POST/content/strategy/avatar-types

Create a workspace avatar type with its formats, or return the existing one with that name.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ name, label?, description?, format_ids?: UUID[] }.

Response

200 { id }. 400 when name is missing.

Notes

name is trimmed. An existing name returns that id and replaces its formats with format_ids; this applies to the global types too, so do not reuse Yapper or Podcaster as a name. Malformed format ids are dropped.

PUT/content/strategy/avatar-types/{typeId}

Set a workspace avatar type's label and description.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { typeId: UUID }; { label?, description? }. Both are written; an omitted field is cleared.

Response

200 { ok: true }. 400 on a malformed id; 403 for a global type or one not in the workspace.
PUT/content/strategy/avatar-types/{typeId}/formats

Replace the formats a workspace avatar type can produce.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { typeId: UUID }; { format_ids: UUID[] }.

Response

200 { ok: true }. 400 on a malformed id; 403 for a global type or one not in the workspace.

Notes

Malformed ids are dropped. An empty list leaves the type with no formats, so its avatars are eligible for nothing.

GET/content/strategy/avatar-strategy/{avatarId}

Read an avatar's type, target audience and pattern selection.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarId: UUID }; no body.

Response

200 { avatar_id, avatar_type_id, target_audience: "ALL" | "MALE" | "FEMALE", pattern_selection: "ALL_ELIGIBLE" | "CURATED", curated_pattern_ids: UUID[] }. 400 on a malformed id.

Notes

An avatar with nothing saved returns avatar_type_id null, ALL, ALL_ELIGIBLE and an empty list. An avatar outside the workspace returns the same defaults, not a 404.

PUT/content/strategy/avatar-strategy/{avatarId}

Set an avatar's type, target audience and pattern selection.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { avatarId: UUID }; { avatar_type_id?, target_audience?: "ALL" | "MALE" | "FEMALE" (default ALL), pattern_selection?: "ALL_ELIGIBLE" | "CURATED" (default ALL_ELIGIBLE), curated_pattern_ids?: UUID[] }.

Response

200 { ok: true }. 400 on a malformed id; 404 when the avatar is not in the workspace.

Notes

A full replace, not a merge: every omitted field goes back to its default, and an omitted or malformed avatar_type_id clears the type (no type means every format is eligible). curated_pattern_ids replaces the curated list. The audience steers the script writer; the type bounds pattern eligibility.

GET/content/strategy/account-caps

Per-account daily post caps the planner respects, and the default.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { default: 2, caps: { "<tiktok_username>": number } }.

Notes

An account absent from caps uses default.

PUT/content/strategy/account-caps

Set the most automated posts a day the planner may give one TikTok account.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ tiktok_username, max_posts_per_day? }. Omitted resets to the default of 2.

Response

200 { ok: true }. 400 when tiktok_username is missing.

Notes

Clamped to 1..20. The username is stored lowercased and is not checked against the workspace's accounts.

GET/content/strategy/avatar-options

Every avatar with each of its TikTok accounts and planning settings: the roster the members UI reads.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { options: [{ avatar_id, avatar_project_id, avatar_name, avatar_type_id, target_audience, pattern_selection, tiktok_username, assignment_id, campaign_id, campaign_ids: UUID[], status, cadence_per_day, lane, tier, is_add_row? }] }.

Notes

One row per (avatar, account), then one row per avatar with tiktok_username null for linking another account (is_add_row is true when the avatar already has one). avatar_type_id falls back to the global Yapper type. Sorted by avatar name.

Content Plans and Cycles

The Default cycle schedule and the plan batches it drafts: preview, create, edit, approve, export, delete.

GET/content/cycle-policy

Read the Default campaign's schedule: when plans are drafted and reviewed.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { cadence_mode: "MANUAL" | "CALENDAR", calendar_days: string[], lead_time_hours, review_windows: [{ day, local_time }], timezone, rolling_stock_min_days, autonomy: { <gate>: <level> }, enabled, next_fire_at }.

Notes

The same row as the Default campaign on GET /content/strategy/campaigns. Defaults when nothing is stored: MANUAL, 24 hours lead, no windows, America/Los_Angeles, disabled.

PUT/content/cycle-policy

Replace the Default campaign's schedule and turn it on or off.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ cadence_mode? (default MANUAL), calendar_days?, lead_time_hours? (default 24), review_windows?: [{ day: "SUNDAY" | "MONDAY" | ..., local_time: "HH:mm" }], timezone? (default America/Los_Angeles), rolling_stock_min_days?, autonomy?: { <gate>: <level> }, enabled? (default false) }.

Response

200 the stored policy, as GET /content/cycle-policy. 402 { message } when enabled goes from false to true without an active subscription.

Notes

A full replace: every omitted field takes its default, enabled included. next_fire_at is derived and ignored on write. The schedule runs in the workspace timezone, not the timezone field. Each review window is a review day and time: the plan is drafted lead_time_hours before it and covers the days up to the next window. With no review windows the campaign never fires on its own. cadence_mode, calendar_days and autonomy are stored and echoed back but do not drive firing; the last-call auto-approve reads the campaign's auto_approve_brief and the workspace autonomy dials, not this map. Turning it on commits to recurring credit spend.

POST/content/plans/preview

Dry-run the planner over the next Default window. Nothing is stored.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { batch_id: null, plan_status: "preview", window_start, window_end, slots: [{ slot_id, assignment_id, avatar_project_id, account, avatar_name, pattern_slug, slot_type: "PROVEN" | "EXPERIMENTAL", date, day_of_week, playbook_id, lane, note, video_model }], needs: [{ avatar, accounts, cadence, pipeline, target, needed }], policy_diff: [{ deviation, reason }], violations: [{ slot_id, rule, message, severity: "error" | "warning" }], rationale: { <avatar_name>: text }, discover_suggestions: [] }.

Notes

Uses the Default campaign's next window (or built-in Sunday and Thursday windows when none is set) over every active assignment; campaign scoping is not applied. Synchronous: the allocator reads the strategy, patterns and cross-tab and runs in the request, with no model call.

POST/content/batches/plan-now

Create a planned batch now for a date window and run allocation into it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ window_start?: "YYYY-MM-DD", window_end?: "YYYY-MM-DD", label?, assignment_ids?: UUID[] }. Omit both dates for the Default campaign's next window.

Response

201 { batch_id }. 400 when window_end is before window_start or the window is 92 days or longer. A date not in YYYY-MM-DD form is a 500.

Notes

The batch is stored in stage brief_pending with plan_status DRAFT_PENDING_REVIEW; read it with GET /content/batches/{batchId}/plan and approve it with POST /content/batches/{batchId}/plan/approve. assignment_ids limits planning to those assignments (malformed ids are dropped). Plans over all active assignments with the workspace planning mode and no campaign. Synchronous allocation, no model call. Sends the plan-ready email once per batch.

POST/content/batches/{batchId}/plan

Re-run allocation into a batch, replacing its slots.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 the plan, as GET /content/batches/{batchId}/plan, with plan_status DRAFT_PENDING_REVIEW. 400 on a malformed id; 404 when the batch is not in the workspace; 409 when the plan is already approved.

Notes

Uses the batch's stored window and campaign (members, seed and per-member overrides). Manual slot edits are discarded and slot ids change. Only stage plan_approved is refused: a batch already writing or later is re-planned as well and moved back to brief_pending, so check the stage first.

GET/content/batches/{batchId}/plan

Read a batch's stored plan.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { batch_id, plan_status: "DRAFT_PENDING_REVIEW" | "APPROVED", window_start, window_end, slots, needs: [], policy_diff, violations, rationale, discover_suggestions: [{ corpus_video_id, tiktok_url, title, author, views, virality_score, cover_url }] }, slots as on POST /content/plans/preview. 400 on a malformed id; 404 when the batch is not in the workspace.

Notes

needs is always empty on a stored plan; it is only on the response of the run that produced it. discover_suggestions are advisory reference videos for the reviewer. Slot ids survive edits but change when the plan is re-run.

DELETE/content/batches/{batchId}/plan

Delete a pending plan batch and everything hanging off it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { status: "deleted" }. 400 on a malformed id; 404 when the batch is not in the workspace; 409 { error } when the batch is past planning or brief_pending.

Notes

Removes the batch, its slots, scripts, review decisions, share pages, batch-scoped exceptions and usage rows. Video projects are never deleted: an item with a project is detached instead.

PUT/content/batches/{batchId}/plan/slots

Edit an unapproved plan's slots and re-validate it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; { edits: [{ op: "MOVE_SLOT" | "REMOVE_SLOT" | "SWAP_PATTERN" | "ADD_SLOT" | "PAUSE_AVATAR", slot_id?, assignment_id?, avatar_project_id?, account?, avatar_name?, pattern_slug?, idea_id?, date?: "YYYY-MM-DD", slot_type?, scope?: "batch_local" | "standing" }] }. MOVE_SLOT: slot_id plus date and/or a target assignment. REMOVE_SLOT: slot_id. SWAP_PATTERN: slot_id plus exactly one of pattern_slug or idea_id. ADD_SLOT: a target assignment (assignment_id, avatar_project_id or account, with avatar_name to disambiguate), optional pattern_slug or idea_id, date (default the window start), slot_type (default PROVEN). PAUSE_AVATAR: avatar_name; scope standing also pauses that avatar's assignments.

Response

200 the plan with the new violations. 400 on a malformed batchId; 404 { error } for a stale slot id, unknown pattern, unknown idea, unknown op or missing field; 409 { error } when the batch is past brief_pending or an assignment selector matches more than one binding; 422 { violations } when an edit trips a lock constraint (nothing is saved). A malformed slot_id, assignment id or date is a 500.

Notes

Edits apply in order and save together. A lock violation (severity error) refuses the whole set; balanced_daily_volume is a warning and saves. The per-avatar rationale is recomputed after the response, so it lags the slots by a few seconds.

POST/content/batches/{batchId}/plan/approve

Approve the plan (the Brief gate). Writing starts right away.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 the plan with plan_status APPROVED. 400 on a malformed id; 404 when the batch is not in the workspace; 409 { violations } when the plan has an error-severity violation, or 409 { error } on a conflict.

Notes

Moves the batch to stage plan_approved, snapshots the export, and queues the engine run that writes the scripts in the background (the batch then moves through writing to review). Approving again returns 200 but does not queue a second run while the first is active.

GET/content/batches/{batchId}/plan/export

The plan in the chat-pipeline contract shape: per-avatar ordered slots with counts.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { generated, planStatus, totalScripts, regularScripts, experimentalScripts, schedule: { pipeline_run_day, batch_label, start_date, end_date, posting_day_count, days: { "1": { date, day } } }, avatars: [{ name, rationale, accounts, regular: [{ pattern, count, note }], experimental: [{ pattern, count, note }], ordered: [{ pattern, type: "proven" | "experimental", note, date, day_of_week }] }] }. 400 on a malformed id; 404 when the batch is not in the workspace.

Notes

Top-level keys are camelCase. The same document is snapshotted on the batch at approval.

POST/content/strategy/plan/approve-all

Approve every plan-stage batch in the workspace at once.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { approved, total }.

Notes

total is the batches in stage planning or brief_pending; approved is how many passed the approve gate (a batch with an error-severity violation is skipped, not failed). Each approval queues an engine run, so this can start several writing runs.

Content Batches and Review

Script batches from the writer engine: seeding, reviewing, approving, pushing to generation, and the Studio views over them.

GET/content/batches

List the workspace's content batches with per-status script counts.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ id, external_id, label, stage, channel, counts: { <script status>: n }, imported_at, review_started_at, review_completed_at, push_started_at, push_completed_at, preflight_report, window_start, plan_status, created_at }].

Notes

stage is the batch's pipeline stage: planning, brief_pending, plan_approved, references, writing, checking, examples_review, review, approved, pushing, live, measured, budget_exceeded or needs_operator. counts is keyed by script status: pending, approved, needs_changes, cut, pushing, pushed, push_failed. plan_status is set only for batches the campaign planner produced.

POST/content/batches/seed

Create a review batch from ready-made scripts (the Studio Create-content flow).

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ label?, window_start?, window_end?, scripts: [{ avatar_name, tiktok_username, transcript, avatar_project_id? }] }. Dates are YYYY-MM-DD; window_start defaults to today and window_end to window_start. An unparseable date falls back the same way.

Response

201 { batch_id, script_count, review_url }. 400 { error } when scripts is empty; 409 { error, batch_id } when a batch with the same generated external id already exists.

Notes

Scripts land in review as manual-class scripts on the manual pattern, spread over the window one per day in order. Scheduling is deferred: the day is a suggestion until the script is approved. Approve with POST /content/scripts/{scriptId}/decision (which pushes each approval into a project) or POST /content/batches/{batchId}/approve-all then POST /content/batches/{batchId}/push.

Example

curl -X POST https://api.pamba.app/content/batches/seed \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Week 38 plumbing",
    "window_start": "2026-09-14",
    "window_end": "2026-09-18",
    "scripts": [{
      "avatar_name": "Maya",
      "tiktok_username": "plumber_pov",
      "transcript": "First call at 7am, a flooded basement. By noon I found a ring the customer lost years ago."
    }]
  }'
POST/content/batches/import

Import a batch of pre-written scripts from an external pipeline.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

{ batch: { external_id, label, source? (default claude_pipeline), window_start?, window_end?, defer_scheduling? }, scripts: [{ external_id, avatar_name, avatar_project_id?, tiktok_username, pattern_slug, pattern_name?, mode? (proven | experimental, default proven), automation_class (automated_bullet_list | automated_plain | manual), scheduled_at, transcript?, spokenHook?, textHook?, textDescription?, listItems?, searchTerms?, videoModel?, referenceVideoUrl?, splitability?, checker_reports?: [{ checker, status, report }], ... }] }. Identity fields are snake_case; the content fields (transcript, spokenHook, textHook, ...) are camelCase.

Response

201 { batch_id, script_count, review_url }. 400 { error } naming the first invalid script: missing or unknown automation_class, unknown mode, unparseable scheduled_at, unknown checker. 409 { error, batch_id } when batch.external_id was already imported into the workspace.

Notes

The operator pipeline's ingest. A checker_reports entry is stored as an imported check run: status pass is pass, anything else is fail. Customers seed batches with POST /content/batches/seed instead.

GET/content/batches/{batchId}

One batch with its script cards and any terminally failed engine runs.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { batch: <the summary GET /content/batches lists>, scripts: [{ id, external_id, pattern_id, pattern_slug, pattern_name, avatar_name, avatar_project_id, tiktok_username, mode, automation_class, status, scheduled_at, revision_number, revision_author, content: { transcript, spokenHook, textHook, textDescription, listItems, searchTerms, videoModel, startingFrameUrl, startingFrameId, splitability, ... }, format_kind, storyboard?, latest_decision: { verdict, note, review_seconds, decided_at }?, check_runs: [{ checker_type, status, source, report, created_at }], pushed_project_id, push_verification, content_item_id, is_example, writer_working, writer_error, writer_error_run_id }], failed_runs: [{ run_id, stage, group_key, state, user_message }] }. 404 when the batch is not in the workspace.

Notes

Archived scripts are excluded. content fields are camelCase. status is pending, approved, needs_changes, cut, pushing, pushed or push_failed. storyboard is present only for concepts with non-yapper blocks (lip-sync, scene clips); format_kind says which card to draw. writer_working is true while a writer-chat run is queued or running for the script; writer_error is the user-facing message of its last failed run, and writer_error_run_id is set only when that failure can be retried with POST /content/stage-runs/{runId}/retry. failed_runs carries user-facing messages only; the raw error is on GET /content/batches/{batchId}/stage-runs.

GET/content/batches/{batchId}/metrics

Review throughput metrics for a batch.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { scripts_total, decided_count, verdict_counts: { approved?, needs_changes?, cut? }, median_review_seconds_per_script, p90_review_seconds, total_review_seconds, wall_clock_review_seconds, inline_edit_count, principles_created_count }. 404 when the batch is not in the workspace.

Notes

Review seconds come from the review_seconds each decision was recorded with; the medians are null until at least one decision carried them.

POST/content/batches/{batchId}/approve-all

Approve every pending script in the batch in one call.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; optional query require_green=true to skip scripts that are not clean; no body.

Response

200 { approved_ids: UUID[], skipped: [{ id, reason }] }. 404 when the batch is not in the workspace.

Notes

Records the approvals only; unlike the single decision route it does not push. A script is skipped, with its reason, when its status is not pending, when a writer-chat revision is in flight for it, and with require_green when it has a prior needs_changes or cut decision, a failing check run, or a splitability score that is missing or below the green threshold. Push the approved scripts with POST /content/batches/{batchId}/push.

POST/content/batches/{batchId}/retry-failed-runs

Requeue the batch's terminally failed writer-engine runs.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { retried: n }. 404 { error } when the batch is not in the workspace; 409 { error } when the batch is not in an in-progress stage.

Notes

Retries FAILED and TIMED_OUT runs with a fresh attempt budget. Only batches in plan_approved, references, writing, checking, examples_review or needs_operator can retry; a needs_operator batch moves back to writing when anything was retried. Deterministic failures (a writer rejection loop) are skipped and stay terminal. Open to every member, unlike the batch-pipeline case of POST /content/stage-runs/{runId}/retry.

POST/content/batches/{batchId}/preflight

Check which approved scripts can be pushed and what it would cost, without pushing.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; optional JSON body { script_ids?: UUID[], scheduled_date?: YYYY-MM-DD } to scope the check. No body means the whole batch.

Response

200 { preflight_id, preflight_schema_version, scope: { script_ids, scheduled_date }, approved_count, pushable_count, blocked_count, candidate_script_ids, pushable_script_ids, date_queue: [{ date, count }], payload_classes: { <automation_class>: n }, blocked: [{ script_id, reasons }], warnings: string[], batch_cost_usd_micros, revision_fingerprint, resolved_avatar_project_ids: { <script_id>: <avatar_project_id> } }. 400 { error } on an invalid script_id or scheduled_date; 404 when the batch is not in the workspace.

Notes

Pass preflight_id to POST /content/batches/{batchId}/push. The report is bound to the scripts' current revisions and to its scope: an edit after the preflight, or a push with a different scope, is refused. The latest report is also stored on the batch as preflight_report.

POST/content/batches/{batchId}/push

Push approved scripts into fully automated video projects.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; JSON body { confirm_push_rules: true, preflight_id?, script_ids?: UUID[], scheduled_date?: YYYY-MM-DD }. With preflight_id the scope must match that preflight's; without it the call runs a preflight itself and pushes only when nothing is blocked.

Response

202 { pushed: [{ script_id, project_id }], failed: [{ id, reason }], preflight_report? }. 400 { error } on an invalid script_id or scheduled_date; 404 when the batch is not in the workspace; 409 { error } when refused: confirm_push_rules is not true, no preflight is on record, preflight_id is not the latest, the scope differs from the preflight, scripts changed since the preflight, nothing is pushable, or (without preflight_id) the preflight found blocked scripts.

Notes

Each pushed script becomes a fully automated project in the caller's workspace that generates and schedules its video, so this spends the workspace's video credits. Open to every workspace member. The projects exist when the 202 returns; generation continues in the background. Poll GET /content/batches/{batchId}/push-status for the per-script outcome. A script a concurrent push already claimed appears in neither pushed nor failed.

Example

curl -X POST https://api.pamba.app/content/batches/$BATCH_ID/push \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "confirm_push_rules": true, "preflight_id": "'$PREFLIGHT_ID'" }'
GET/content/batches/{batchId}/push-status

Per-script push outcome and post-push verification.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { scripts: [{ script_id, project_id, status, concept_ready, verification: { pushed_at, final, concept_ready, checks: { <check>: <result> }, error, repair_guidance }? }] }. 404 when the batch is not in the workspace.

Notes

status is the script's status (pushing, pushed or push_failed once pushed; its review status before). verification is null until the push was verified; final is true once the verification pass finished, and concept_ready says the pushed project's concept passed its checks. Includes archived scripts.

Script Review

Decide, edit and revise one script in a batch, and pick the frame its video opens on.

POST/content/scripts/{scriptId}/decision

Record a review verdict. An approval pushes the script into a project right away.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { scriptId: UUID }; JSON body { verdict: "approved" | "needs_changes" | "cut", note?, review_seconds? }.

Response

200 { script_id, status, project_id?, push_error? }. 400 { error } on an unknown verdict; 404 when the script is not in the workspace.

Notes

approved pushes the script on its own, like POST /content/batches/{batchId}/push for one script: the project lands in the caller's workspace on its credits, so status comes back pushed with project_id. When the push was blocked (a batch push already running, a duplicate the desk already sent, a preflight block) the approval stands and status is approved with push_error; when project creation failed, status is push_failed with push_error. A batch whose stage does not push on approve (examples review, and the writing stages) just records the approval. Re-approving a script that already has a project returns that project instead of creating another. needs_changes and cut set the matching status.

PATCH/content/scripts/{scriptId}

Edit a script's content inline: a new revision, re-checked immediately.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { scriptId: UUID }; JSON body, camelCase, any of { automationClass?, textHook?, textDescription?, transcript?, spokenHook?, listItems?: string[], searchTerms?: string[], videoModel? }. Send at least one field.

Response

200 { revision: { id, revision_number, author: "human_edit", trigger_type, trigger_ref, content, check_runs, created_at, is_current }, check_run: { checker_type, status: "pass" | "fail", source, report, created_at } }. 400 { error } on an unknown automationClass; 404 when the script is not in the workspace; 422 { error } when the edit changes spoken text that lives in the concept's scene clips (lip-sync and scene-clip concepts lock their spoken lines).

Notes

An approved script goes back to pending. automationClass accepts automated_bullet_list, automated_plain or manual and re-validates the content against the new class. A body with no editable field, or an unknown videoModel, is not caught by the handler and fails with 500.

GET/content/scripts/{scriptId}/revisions

The script's revision history with each revision's check runs.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { scriptId: UUID }; no body.

Response

200 [{ id, revision_number, author, trigger_type, trigger_ref, content, check_runs: [{ checker_type, status, source, report, created_at }], created_at, is_current }], oldest first. 404 when the script is not in the workspace.

Notes

author is importer, human_edit, principle_recheck or engine. Exactly one revision has is_current true; revision ids feed the restore route and the writer-chat decision route.

POST/content/scripts/{scriptId}/revisions/{revisionId}/restore

Make an earlier revision current again.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { scriptId: UUID, revisionId: UUID }; no body.

Response

200 { success: true }. 404 when the script or revision is not in the workspace.

Notes

The script goes back to pending for re-review.

GET/content/scripts/{scriptId}/starting-frames

The active starting frames the script's avatar can open on.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { scriptId: UUID }; no body.

Response

200 [{ id, avatar_id, creative_concept_id, parent_frame_id, image_url, is_active, bulk_generation_id, camera_style, tags, created_at, updated_at, is_profile_image, variation_count, content_labels, content_description }]. 404 when the script is not in the workspace.

Notes

Empty when the script's avatar cannot be resolved or has no active frames. The script's current pick is content.startingFrameId on its card.

POST/content/scripts/{scriptId}/starting-frame

Pin one of the avatar's active frames as the script's starting frame.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { scriptId: UUID }; JSON body { frame_id: UUID } from GET /content/scripts/{scriptId}/starting-frames.

Response

200 { frame_id, frame_url }. 400 { error } when frame_id is not a UUID; 404 when the script is not in the workspace or the frame is not one of its active frames.

Notes

Writes a human_edit revision (trigger frame_change) without resetting an approval.

POST/content/scripts/{scriptId}/starting-frame/reroll

Swap the script to a random other active frame.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { scriptId: UUID }; no body.

Response

200 { frame_id, frame_url }. 404 when the script is not in the workspace or its avatar has no active frames.

Notes

Picks at random among the active frames other than the current one (the current one only when it is the sole frame). Same revision behavior as pinning a frame.

Writer Engine

Run the AI writer on a batch, follow and control its stage runs, and talk to the writer about one script.

POST/content/batches/{batchId}/engine-run

Queue the writer engine on a planned batch.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { batchId: UUID }; no body.

Response

200 { run_ids: UUID[], gated_groups: string[] }. 409 { error } when the batch is not in the workspace (reported as 409, not 404), has no plan slots, or already has pending, claimed or running stage runs.

Notes

Queues one WRITE_GROUP run per script group; a group that needs an example first gets an EXAMPLES run instead and is listed in gated_groups (release it with POST /content/batches/{batchId}/examples-release once the examples are approved). The batch moves to references when any group is gated, else to writing. Runs are picked up by the pamba-runner machine and spend the workspace's LLM budget, billed to credits. Track with GET /content/batches/{batchId}/stage-runs.

POST/content/batches/{batchId}/examples-release

Release the example-gated groups to write their full set.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { batchId: UUID }; no body.

Response

200 { run_ids: UUID[] }. 409 { error } when the batch is not in the workspace, has no example gates, the example runs have not all succeeded, or the gates were already released.

Notes

Queues a WRITE_GROUP run per released group (plus a CLASSIFICATION run when any slot is playbook-sourced) and moves the batch to writing.

GET/content/batches/{batchId}/stage-runs

Every writer-engine stage run for the batch, with the runner fleet's last activity.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { runs: [{ id, workspace_id, batch_id, stage, group_key, state, attempt, max_attempts, payload, compiled_context_sha256, prompt_asset_versions, selected_references, error, created_at, started_at, finished_at }], runner_last_activity_at }. 404 when the batch is not in the workspace.

Notes

stage is REFERENCE_SELECTION, REFERENCE_MECHANISM, EXAMPLES, WRITE_GROUP, CLASSIFICATION or WRITER_CHAT (FINAL_READ on old rows). state is PENDING, CLAIMED, RUNNING, SUCCEEDED, FAILED, CANCELLED, TIMED_OUT or NEEDS_OPERATOR. Null fields are omitted. error is the raw run error; the batch detail route carries the user-facing message. runner_last_activity_at is a UTC instant across all workspaces (null when no runner has ever reported): use it to tell a stalled runner from a slow run.

GET/content/batches/{batchId}/scripts

A slim list of the batch's scripts: ids, status and transcript.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { scripts: [{ id, external_id, status, avatar_name, tiktok_username, transcript }] }. 404 when the batch is not in the workspace.

Notes

Includes archived scripts. The pamba-runner's writer reads this during a run to avoid duplicating scripts already written. GET /content/batches/{batchId} is the full card view.

PUT/content/batches/{batchId}/scripts

Upsert engine-written scripts into a batch by external id.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { batchId: UUID }; JSON body { scripts: [{ external_id (id is accepted as an alias), plan_slot_id?, pattern_id?, avatar_name, tiktok_username, mode? (default proven), automation_class, scheduled_at, is_example?, trigger_type?, trigger_ref?, content: { transcript, spokenHook?, textHook?, textDescription?, listItems?, searchTerms?, videoModel?, ... } }] }. scheduled_at is an ISO local date-time; a trailing Z is accepted.

Response

200 { results: [{ external_id, accepted, revision_id, violations: [{ ruleId, status, message }] }] }. 400 { error } on an unparseable scheduled_at; 404 when the batch is not in the workspace; 422 { error, required_fields } when the envelope does not deserialize.

Notes

The writer engine's submit path: the pamba-runner's writer calls it with the per-run workspace key. A script that fails the deterministic validators comes back with accepted false and the failing rules so the writer can fix and resubmit; an accepted script gets a new revision (revision_id). A script with a plan slot takes its schedule, video model and identity from the slot, whatever scheduled_at says.

POST/content/stage-runs/{runId}/retry

Requeue one terminal stage run with a fresh attempt budget.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { runId: UUID }; no body.

Response

200 { success: true }. 403 { error } when a customer key retries a batch-pipeline run; 404 when the run is not in the workspace; 409 { error } when the run is not in a terminal state or its failure is deterministic.

Notes

Partial operator gate (StageRunService.retrySingleRun): WRITER_CHAT runs are script-scoped and self-service for any workspace member; every other stage can re-fire stage transitions and paid work, so only internal Pamba operators can retry it and a customer key gets 403. A deterministic failure (the writer's rejection-loop abort, a read loop) is refused with 409: rephrase the request instead of re-running it.

POST/content/stage-runs/{runId}/cancel

Cancel a stage run that has not been claimed yet.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { runId: UUID }; no body.

Response

200 { success: true }. 404 when the run is not in the workspace; 409 { error } unless the run is PENDING.
GET/content/scripts/{scriptId}/writer-chat

The script's writer-chat thread: operator asks and the writer's proposed revisions.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { scriptId: UUID }; no body.

Response

200 { thread_id, script_id, agent_session_id, active_run_state, active_run_started_at, failed_run_id, failed_run_message, messages: [{ id, role: "OPERATOR" | "AGENT", content, proposed_revision_id, created_at }] }, oldest message first; 200 { messages: [] } when the script has no thread yet or is not in the workspace.

Notes

An AGENT message with proposed_revision_id carries a revision to accept or reject on POST /content/scripts/{scriptId}/writer-chat/revisions/{revisionId}/decision. active_run_state is the WRITER_CHAT run's state while one is queued or running; poll until it is null. failed_run_message is the last failure when no newer run supersedes it, and failed_run_id is present only when that failure can be retried with POST /content/stage-runs/{runId}/retry.

POST/content/scripts/{scriptId}/writer-chat

Ask the writer to revise a script. The reply arrives asynchronously.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { scriptId: UUID }; JSON body { message }.

Response

200 the thread as GET /content/scripts/{scriptId}/writer-chat returns it, with the new OPERATOR message. 400 { error } when message is blank; 404 when the script is not in the workspace.

Notes

Appends the message and queues a WRITER_CHAT stage run for the pamba-runner; the call returns at once. The writer answers through POST /content/writer-chat/agent-reply with a proposed revision, which you accept or reject on the decision route. On lip-sync and scene-clip concepts the spoken or lyric lines are locked: the writer may only revise the caption, text hook and metadata, and a proposal that changes them is rejected.

POST/content/scripts/{scriptId}/writer-chat/revisions/{revisionId}/decision

Accept or reject a revision the writer proposed.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { scriptId: UUID, revisionId: UUID }; JSON body { accept: boolean }.

Response

200 { success: true }. 404 when the script or proposed revision is not in the workspace.

Notes

accept makes the proposal the current revision and sets the script back to pending. reject returns the script to the revision the proposal was based on.

POST/content/writer-chat/agent-reply

The writer's reply into a thread, with an optional validated revision proposal.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

JSON body { thread_id, message, stage_run_id?, agent_session_id?, proposed_content?: { transcript, spokenHook?, textHook?, textDescription?, videoModel?, searchTerms?, listItems?, ... } } (content fields camelCase).

Response

200 { proposed_revision_id } (null when the reply carried no proposal). 400 { error } when thread_id is not a UUID; 404 when the thread is not in the caller's workspace; 422 { error: "revision_rejected", validation_errors: [{ ruleId, status, message }], instruction } when the proposal fails the validators or changes locked spoken text.

Notes

Called by the pamba-runner's WRITER_CHAT session with its per-run workspace key, not by operators by hand. A proposal that passes is stored as a revision and linked to the AGENT message, but does not become current until it is accepted on the decision route.

Principles and Patterns

The taste principles the checks enforce and the content patterns (formats) the writer works from.

GET/content/principles

List taste principles, optionally filtered.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query status (active | retired | candidate), scope (global | pattern | avatar | batch), pattern_id (UUID), all optional; an unknown value is ignored rather than rejected.

Response

200 [{ id, text, scope, status, pattern_id, avatar_project_id, batch_id, source_script_id, source_note, category, created_at }].
POST/content/principles

Create a taste principle, optionally re-checking a batch against it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ text, scope? (global | pattern | avatar | batch, default global), category?, pattern_id?, avatar_project_id?, batch_id?, source_script_id?, source_note?, recheck_batch_id? }. Every id must be a valid UUID.

Response

201 { principle_id, recheck_started }. 400 { error } when text is blank or scope is unknown.

Notes

recheck_batch_id starts a background re-check of that batch's undecided scripts against the new principle (one model judgement per script, recorded as a principle_recheck revision and check run where it applies); recheck_started reports whether it was launched. An id that is not a UUID is not caught by the handler and fails with 500.

PATCH/content/principles/{principleId}

Edit a principle's text, status or category.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { principleId: UUID }; JSON body any of { text?, status? (active | retired | candidate), category? }.

Response

200 the principle as GET /content/principles lists it. 400 { error } on an unknown status; 404 when the principle is not in the workspace.
GET/content/patterns

List the workspace's content patterns with their constraints.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { patterns: [{ id, slug, name, description, lifecycle_stage, category, format_kind, automation_class, description_format, description_guidance, bullet_count, hook_contract: [{ id, text, scope, checkType }], spec_rules: [PatternRule], imported_state_label, family, lore, created_at, updated_at, archived_at, linked_videos_count, linked_avatars, example_urls, duration_seconds_min, duration_seconds_max, word_count_min, word_count_max, weekly_cap, hook_casing, target_audience, speaker_role, hook_structures, retention_structures, payoff_types, term_list_id }] }.

Notes

lifecycle_stage is candidate, trialing, proven, cooling or retired. format_kind is derived from category (yapper unless the format maps to another block kind). example_urls are TikTok links found in the imported pattern notes, at most five. Archived patterns keep archived_at set.

POST/content/patterns

Create a pattern.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ name, description?, lifecycle_stage? (default candidate), category? }. category must name a format the workspace already has.

Response

201 the pattern as GET /content/patterns lists it, with linked_videos_count 0. 400 { error } when name is blank, lifecycle_stage is unknown, or category is not an existing format.
PATCH/content/patterns/{patternId}

Edit a pattern's fields, constraints and term list.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { patternId: UUID }; JSON body any of { name?, description?, lifecycle_stage?, category?, automation_class?, description_format? (standard | search), description_guidance?, bullet_count?, hook_contract?: PatternRule[], spec_rules?: PatternRule[], lore?, archived?: boolean, duration_seconds_min?, duration_seconds_max?, word_count_min?, word_count_max?, hook_casing?, weekly_cap?, term_list_id?, clear_constraints?: string[] }. A missing field is left unchanged; to unset one, name it in clear_constraints: duration_seconds_min, duration_seconds_max, word_count_min, word_count_max, bullet_count, weekly_cap, hook_casing, automation_class, description, category, term_list.

Response

200 the updated pattern (linked_videos_count is reported as 0 on this response). 400 { error } on an unknown clear_constraints name, lifecycle_stage, automation_class or description_format, a category that is not an existing format, or a term_list_id that is not a UUID or not in the workspace; 404 when the pattern is not in the workspace.

Notes

archived: true is the way to retire a pattern that is still referenced; see DELETE.

DELETE/content/patterns/{patternId}

Delete a pattern that nothing references.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { patternId: UUID }; no body.

Response

200 { success: "true" } (a string, not a boolean). 404 when the pattern is not in the workspace; 409 { error } naming what still uses it.

Notes

On 409, archive the pattern with PATCH /content/patterns/{patternId} { archived: true } instead.

Studio Views and Content Items

The Studio's read models: the roster grid, the Home summary, the review queue, and one content item resolved into its facets.

GET/content/studio/grid

The Studio roster: avatar and account rows by day, each cell a content item with its stage.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query days (default 11, clamped to 1..60), offset (days from the anchor, clamped to -180..180, default 0), source (content, the default, or plan).

Response

200 { days: [YYYY-MM-DD], todayIso, rows: [{ avatar: { id, name, imageUrl, active, source, generating }, account: { username, platform, instagramUsernames, viewsL7d, assignmentId, capPerDay }, cells: { <day>: [{ contentId, itemId, scriptId, projectId, batchId, format, patternName, stage, failureKind, title, scheduledAt, suggested, patternId, ideaId, sourceDescription }] }, unscheduled: [cell] }] }.

Notes

Field names are camelCase. Days are in the workspace's timezone; the content grid anchors on yesterday, the plan grid on today. source=plan shows the campaign planner's proposed slots for unapproved batches; the content grid shows made and scheduled items, date-less items on their suggested day (suggested true), and pending plan squares. stage is plan, concept, generating, review, approved, scheduled, posted or failed; failureKind (generation or post) says which failure when stage is failed. Days with no cells are omitted from cells.

GET/content/studio/home-summary

The Home page's summary: views, the current winner, what needs you, what Pamba is doing, and setup state.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { views: { total, deltaPct }, winner: { contentId, title, views, thumbUrl, projectId, videoUrl, platform }?, needsYou: [{ id, kind, avatar, label, deepLink, avatarName, platform, detail, error, postId, projectId, failedAt, attentionId }], onIt: [{ id, kind, label, detail, since }], recentlyDone: [{ id, kind, label, detail, at }], tally: { postsPublished, browseSessions, scriptsWritten }, setup: { hasAvatar, hasAccount, automationOn, hasBrand, hasDraft, hasRender, hasLivePost, hasPostableAccount, selfPostMode, accountProvisioning, readinessKnown, firstDraftProjectId, firstDraftTitle, firstDraftCoverUrl, firstDraftAvatarName, firstDraftAvatarImageUrl, firstDraftDurationSeconds, firstDraftSceneCount, needsBootstrapHeal }, hasHistory }.

Notes

Field names are camelCase. A needsYou item with an attentionId can be dismissed with POST /content/studio/attention/{itemId}/acknowledge. setup.readinessKnown false means the posting-readiness read failed and the posting flags are fallbacks, not facts.

POST/content/studio/attention/{itemId}/acknowledge

Mark a Home needs-you item as seen so it stops surfacing.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { itemId: UUID }, the attentionId from GET /content/studio/home-summary; no body.

Response

200 with an empty body. 400 (plain text) when itemId is not a UUID; 404 (plain text) when the item is not in the workspace.
GET/content/studio/review-queue

Content items by lifecycle bucket, in three read forms.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query, one form. fields=ids: the identity slice. statuses=<comma list of queue, failed, approved, scheduled, posted, archived, drafts>: the windowed read, with optional limit (default 60, max 500), search (title substring), avatar (repeatable, exact display name), date=YYYY-MM-DD plus tz=<IANA zone>, hideAuto=1, and sort=newest | oldest | posttime. No parameters: the legacy full read.

Response

200 { items: [{ itemId, projectId, batchId, stage, failureKind, avatarName, avatarImageUrl, tiktokUsername, tiktokUsernames, instagramUsernames, instagramUsernamesWithFacebook, title, format, hook, bullets, script, description, frameUrl, videoUrl, editedVideoUrl, stitchedVideoUrl, clipVideoUrls, generatedAt, scheduledAt, hasPendingPost, awaitingPurchase, avatarProjectId, suggestedScheduledAt, isFullyAutomated, renderState, building, generating, progressMessage, creationStage, promotional, archived, videoModel, replicationOrigin, referenceVideo?, storyboard?, draftBuild?, sourceProject? }], total?, counts?, avatarOptions?, avatarCounts?, dateOptions?, awaitingPurchase? } for the full and windowed reads (the optional extras only on the windowed read); 200 { items: [{ itemId, stage, building, generating }] } for fields=ids. 400 { error } when statuses is empty or names an unknown bucket.

Notes

Field names are camelCase. queue is generating plus review; approved and scheduled split on hasPendingPost; posted and archived grow without bound and are only reachable through the windowed read; drafts is the Drafts tab's population. The legacy full read excludes posted and archived. sort orders the whole population before the window cut, so do not re-sort a page client-side. An unknown sort, date or tz degrades silently (newest, no date filter, UTC). counts are per-bucket totals with the search, avatar, date and hideAuto filters applied, across all buckets. Item ids feed GET /content/items/{id}.

GET/content/items/{id}

Resolve a content item id, or a legacy project id, into its item, concept, video and post facets.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }: a content item id, or the id of a project that has no item; no body.

Response

200 { item: { id, workspaceId, status, format, avatarProjectId, tiktokUsername, patternId, scheduledAt, batchId, planSlotId, conceptScriptId, videoProjectId, source }, concept: { scriptId, status, automationClass, currentRevisionId, content, storyboard }?, video: { projectId, generationStatus }?, post: { scheduledPostId, status, scheduledAt }? }. 400 { error } when id is not a UUID; 404 when neither an item nor a project with that id is in the workspace.

Notes

Field names are camelCase. item.status is the derived stage (concept, generating, review, approved, scheduled, posted, failed), not a stored column. A legacy project resolves with item.id equal to the project id, source legacy_project and no concept. Resolving also repairs an item whose project link was lost from the script's pushed project.

GET/content/items/resolve/{id}

Alias of GET /content/items/{id}: the same handler and payload.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { id: UUID }; no body.

Response

200, 400 and 404 exactly as GET /content/items/{id}.

Pipeline Profile, Knowledge and LLM Usage

The workspace's pipeline profile, the knowledge exports the writer is briefed with, and the content engine's LLM spend.

GET/content/profile

The workspace's content pipeline profile.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { workspace_id, customer_slug, enabled_hooks: string[], config: { classification_timing: "AT_WRITE" | "POST_WRITE" | null }, is_default }.

Notes

enabled_hooks names the optional pipeline hooks an operator turned on for the workspace. is_default is true when the workspace has no profile row and this is the synthesized default. Profiles are written by operators through the admin API; there is no customer write route.

GET/content/knowledge-exports

List the rendered knowledge exports the writer engine is briefed with.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ file_key, content_hash, rendered_at, source_mutated_at }].

Notes

file_key values: produce-scripts/review-feedback-principles.md, push-scripts/automated-patterns.yaml, content-strategy/mix-targets.yaml, content-strategy/avatars-roster.yaml, content-strategy/current-account-mimic-map.yaml, video-model-routing/model-routing.yaml (and _sync-test.md, a probe). Exports re-render when their sources (patterns, principles) change; source_mutated_at is when the source last changed. Read by the pamba-runner during a run as well as by operators.

GET/content/knowledge-exports/{fileKey...}

Read one knowledge export's content.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { fileKey: the file key with its slashes, as listed by GET /content/knowledge-exports }; no body.

Response

200 { file_key, content, content_hash, rendered_at }. 400 { error } when the key is empty; 404 { error } when the workspace has no export under that key.

Example

curl https://api.pamba.app/content/knowledge-exports/push-scripts/automated-patterns.yaml \
  -H "X-API-Key: $PAMBA_API_KEY"
POST/content/llm-usage

Record LLM usage rows against the workspace's content-engine spend.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

{ rows: [{ agent_role, provider, model, input_tokens, output_tokens, cached_input_tokens? (default 0), cost_usd_micros, status? (default OK), batch_id?, stage_run_id? }] }.

Response

200 { inserted }. 400 { error } when a batch_id or stage_run_id is not a UUID.

Notes

The engine's own bookkeeping path: the agent tool log_llm_usage and operator scripts call it; the pamba-runner reports its usage through the stage-run complete call instead. Inserting charges the rows' summed cost_usd_micros to the workspace owner's credits with the LLM margin. Idempotent on the report's contents: the same workspace and rows again inserts nothing, returns inserted 0 and charges nothing, so a retry is safe.

GET/content/llm-usage/summary

The workspace's content-engine LLM spend, by agent role and model.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query batch_id (UUID) and since (ISO-8601 local date-time, for example 2026-09-01T00:00:00), both optional.

Response

200 { total_cost_usd_micros, total_input_tokens, total_output_tokens, by_agent_role: { <role>: { cost_usd_micros, input_tokens, output_tokens } }, by_model: { <model>: { cost_usd_micros, input_tokens, output_tokens } } }. 400 { error } on an invalid batch_id or since.

Content Knowledge

The vocabulary the writer engine draws from: a playbook per reference account and tiered term lists.

GET/content/playbooks

List the workspace's playbooks, optionally by status.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query status? (for example active or research_only). No body.

Response

200 [{ id, account_handle, tracked_account_id, lane, writer_tldr, what_to_copy: string[], what_to_avoid: string[], audience_notes, performance_snapshot, status, source_markdown, created_at, updated_at }].

Notes

A playbook is the writer's brief for one reference account: what to copy from it and what to avoid. status is free text: active by default, research_only for playbooks made from a tracked account.

POST/content/playbooks

Create an empty playbook for a TikTok handle.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ account_handle, lane?, writer_tldr? }. A leading @ is stripped and the handle is lowercased.

Response

201 Playbook. 400 when account_handle is blank; 409 when the workspace already has a playbook for that handle.

Notes

what_to_copy and what_to_avoid start empty; fill them with PUT /content/playbooks/{playbookId}. tracked_account_id is set when the handle is already a tracked account.

POST/content/playbooks/from-account

Create a research_only playbook for an account the workspace tracks.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ username } (a leading @ is stripped, nothing else is normalized).

Response

201 Playbook with status research_only. 404 when the username is not tracked in this workspace.

Notes

Upserts by handle: called again for a handle that already has a playbook, it clears that playbook's writer_tldr, what_to_copy, what_to_avoid and audience_notes (lane and status are kept). Use POST /content/playbooks when the account is not tracked.

GET/content/playbooks/{playbookId}

Get one playbook.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { playbookId: UUID }; no body.

Response

200 Playbook. 404 when the playbook is not in this workspace.
PUT/content/playbooks/{playbookId}

Edit a playbook's lane, brief, copy and avoid lists, audience notes or status.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { playbookId: UUID }; { lane?, writer_tldr?, what_to_copy?: string[], what_to_avoid?: string[], audience_notes?, status? }. Only the fields sent change; a list replaces the whole list.

Response

200 updated Playbook. 404 when the playbook is not in this workspace.
DELETE/content/playbooks/{playbookId}

Delete a playbook nothing in the strategy uses.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { playbookId: UUID }; no body.

Response

200 { success: "true" }. 404 when the playbook is not in this workspace; 409 { error } while it is a mimic on an account assignment or backs a reference pack.

Notes

Hard delete, no undo. Video classifications that point at the playbook keep their mimic text and lose the link.

GET/content/term-lists

List term lists with their term counts.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ id, slug, name, kind, usage_hint, total_terms, eligible_terms, candidate_terms, held_terms, linked_patterns }].

Notes

A term list is a tiered vocabulary a pattern's writer draws from instead of inventing terms (search terms, hashtags, product names, ...). eligible_terms counts terms with status active, not risk-flagged, in a writer tier (proven, testing, exploratory). candidate_terms is the discovery inbox, which the writer never uses. held_terms have status held.

POST/content/term-lists

Create a term list and optionally point patterns at it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ slug, name, kind?, usage_hint?, link_pattern_slugs?: string[] }. kind is free text and defaults to search_terms when blank.

Response

201 TermList with its counts. 409 when the slug already exists in the workspace.

Notes

Each entry in link_pattern_slugs is one of the workspace's pattern slugs; an unknown slug is ignored. A pattern has one term list, so linking it here moves it off any list it had.

GET/content/term-lists/{termListId}

Get a term list with every term in it.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { termListId: UUID }; no body.

Response

200 { term_list: TermList, terms: [{ id, value, tier, status, category, notes, best_views, source_url, source_account, risk_flagged, last_deployed_at }] }. 404 when the list is not in this workspace.

Notes

tier: proven | testing | exploratory | candidate. status: active | held | retired. last_deployed_at is the last time a written script used the term.

PATCH/content/term-lists/{termListId}

Rename a term list, change its kind or usage hint, or link more patterns.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { termListId: UUID }; { name?, kind?, usage_hint?, link_pattern_slugs?: string[] }. Only the fields sent change.

Response

200 { status: "updated" }. 404 when the list is not in this workspace.

Notes

link_pattern_slugs only adds links, it never removes one; unknown slugs are ignored.

DELETE/content/term-lists/{termListId}

Delete a term list and all of its terms.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { termListId: UUID }; no body.

Response

200 { status: "deleted" }. 404 when the list is not in this workspace.

Notes

Patterns linked to the list are unlinked, not deleted. No undo.

POST/content/term-lists/{termListId}/terms

Add terms to a list, or update the ones already in it, by value.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { termListId: UUID }; { terms: [{ value, tier?, status?, category?, notes?, best_views?, source_url?, source_account?, risk_flagged? }] }. tier: proven | testing | exploratory | candidate. status: active | held | retired.

Response

200 { status: "ok", terms_written } (terms_written is a string). 400 { error } on an unknown tier or status; 404 when the list is not in this workspace.

Notes

Upsert keyed on the trimmed value. A new term defaults to tier candidate and status active. An existing term keeps every field you omit, its best_views only ever rises, and risk_flagged can be set here but not cleared (clear it with PATCH /content/term-lists/terms/{termId}). Blank values are dropped and not counted.

Example

curl -X POST https://api.pamba.app/content/term-lists/$TERM_LIST_ID/terms \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "terms": [
      { "value": "best budget espresso machine", "tier": "testing", "best_views": 410000, "source_account": "coffeewithcal" },
      { "value": "espresso machine under 200" }
    ]
  }'
PATCH/content/term-lists/terms/{termId}

Edit one term.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { termId: UUID }; { value?, tier?, status?, category?, notes?, best_views?, risk_flagged? }. Only the fields sent change.

Response

200 { status: "updated" }. 400 { error } on an unknown tier or status, or when the new value already exists in the list; 404 when the term is not in this workspace.

Notes

A term is writer-eligible only with status active, a writer tier (proven, testing, exploratory) and risk_flagged false. Set status held or retired to take it out without deleting it.

DELETE/content/term-lists/terms/{termId}

Delete one term.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { termId: UUID }; no body.

Response

200 { status: "deleted" }. 404 when the term is not in this workspace.

Patterns and Classification

Link reference videos to patterns, classify posted videos, and read what each pattern earned.

POST/content/capture

File a TikTok video as an idea and queue its analysis at high priority.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

{ tiktok_video_url, notes? }. Short share links are resolved and share query strings stripped before the video is keyed.

Response

201 { idea_id, analyzed_video_id, already_existed: "true" | "false" }. analyzed_video_id is null until the analysis has landed.

Notes

Returns once the idea is filed: the analysis and the video download run in the background, so poll GET /ideas until the idea carries its analyzed video. A URL captured before returns its existing idea with already_existed "true" and re-drives a stalled analysis instead of paying for a second one. A URL that is not a TikTok video URL fails the request with 500, not 400. Resolving a short link is a network round trip inside the request.

GET/content/pattern-links

Pattern links for given videos, or the workspace's open AI proposals.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query analyzed_video_ids? (comma-separated UUIDs). No body.

Response

200 { links: [{ id, analyzed_video_id, pattern_id, pattern_name, proposed_pattern_name, status: "proposed" | "confirmed" | "rejected" | "skipped", source: "ai" | "human", ai_reason, video_url, video_username }] }.

Notes

With ids: every link on those videos, whatever its status. Without ids: the 50 newest proposed AI links that name a pattern, the inbox PUT /content/pattern-links/{linkId} decides. skipped is the AI matcher's record that it judged the video and matched nothing.

PUT/content/pattern-links/{linkId}

Confirm or reject a proposed pattern link.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { linkId: UUID }; { status: "confirmed" | "rejected", pattern_id? }. pattern_id replaces the proposed pattern.

Response

200 { success: "true" }. 400 on any other status; 404 when the link is not in this workspace.

Notes

Marks the link as a human decision (source human). Confirming with a pattern records a video_linked event on it, and the video joins the pattern's references.

DELETE/content/pattern-links/{linkId}

Remove a video-to-pattern link.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { linkId: UUID }; no body.

Response

200 { success: "true" }. 404 when the link is not in this workspace.
GET/content/patterns/{patternId}/links

Every video linked to a pattern, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { patternId: UUID }; no body.

Response

200 { links: PatternLink[] }, any status. An unknown pattern returns an empty list, not 404.
POST/content/patterns/{patternId}/links

Attach a TikTok video to a pattern as a confirmed human reference.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { patternId: UUID }; { tiktok_video_url }.

Response

201 { link: PatternLink } when the video is already analyzed. 202 { status: "analyzing" } when it still needs analysis: no link exists yet, call again once the analysis has landed. 400 { error } when the URL is blank or not a TikTok video URL.

Notes

Rides POST /content/capture, so the video is also filed as an idea. A video already linked to the pattern has its link promoted to confirmed and source human. Records a video_linked event.

GET/content/patterns/{patternId}/references

The references a write run claiming the pattern right now would receive.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { patternId: UUID }; no body.

Response

200 { window_days, references: [{ rank, username, url, tiktok_video_id, views, posted_at, age_days, transcript, caption }] }.

Notes

Up to 5 of the pattern's proposed or confirmed videos: transcribed ones first, by views, inside a window that widens through 14, 30 and 90 days to all time (window_days null) until 5 are found; videos without a transcript only fill what remains. This is the same selection the writer gets, so it is the review surface into its inputs.

GET/content/patterns/{patternId}/events

A pattern's event log, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { patternId: UUID }; no body.

Response

200 { events: [{ event_type, payload, created_at }] }. 404 when the pattern is not in this workspace.

Notes

event_type values include created, imported, created_from_import, lifecycle_moved, automation_class_updated, video_linked and reclassify; payload is that event's detail object.

GET/content/classifications

Classification rows for the given projects.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query project_ids (comma-separated UUIDs); no body. Without project_ids the list is empty.

Response

200 { classifications: [{ project_id, pattern_id, pattern_name, mimic, lane, hook_structure, retention_structure, payoff_type, product_integration, reference_mechanic, production_format, target_audience, pattern_status, source, confidence }] }.

Notes

At most one row per project. source says how the row was written: push (the batch push), manual (the PUT below), auto, lineage or import_notes. Projects the workspace does not own are left out.

PUT/content/classifications/{projectId}

Set a project's classification by hand.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { projectId: UUID }; { pattern_id?, pattern_name?, playbook_id?, mimic?, lane?, hook_structure?, retention_structure?, payoff_type?, product_integration?, reference_mechanic?, production_format?, target_audience?, pattern_status?, mode?, content_script_id?, content_batch_id? }. pattern_name is a pattern slug and resolves the pattern when pattern_id is absent.

Response

200 { success: "true" }. 404 when the project is not in this workspace.

Notes

Replaces the whole row as source manual, confidence HIGH: every field you omit becomes null, so send the full classification, not a delta. Records a reclassify event on the resolved pattern.

GET/content/outcomes

Post outcomes attributed to patterns, with the attribution funnel.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query days?, pattern_id?, username?; no body.

Response

200 { outcomes: [{ id, tiktok_username, pattern_id, posted_at, baseline_median_views, baseline_video_count, baseline_notes, funnel_state: "VERIFIED" | "MATURE", views_at_maturity, lift_vs_baseline }], funnel: { verified, mature, unverified_posts } }. Every value is a string or null, the counts included.

Notes

An outcome opens as VERIFIED when a post is verified on the account, with the account's median views over the 30 days before it as the baseline, and turns MATURE 7 days after posting, when views_at_maturity and lift_vs_baseline are filled. unverified_posts counts posts whose verification is not VERIFIED. days bounds posted_at (and the unverified count's completion time); without it, all time. Newest first.

GET/content/audits

Recent audit runs, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query limit? (default 10); no body.

Response

200 [{ id, window_days, as_of, status: "running" | "completed" | "failed", video_count }].
POST/content/audits/run

Start an audit of the fleet's posted videos over a window.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query days? (default 30); no body.

Response

202 { run_id }.

Notes

Returns at once; the report builds in the background from stored data (no spend) and a restart resumes it. Poll GET /content/audits until the run's status is completed, then read GET /content/audits/{runId}. The nightly scheduler runs the same audit for every workspace with classification rows.

GET/content/audits/{runId}

The full report of one audit run.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { runId: UUID }; no body.

Response

200 { generated_at, window: { days_lookback, as_of }, source: { fleet_accounts, classification_rows, id_join_matches, inherited }, summary, account_summaries, dimension_summaries, cross_tab_pattern_account_median_views, videos, outliers, metadata_gaps, recommendations, attrition, periods: { all, 14, 7 } }. 404 when the run is not in this workspace, and also while it is still running or after it failed, because the report is written only on completion.

Notes

summary, account_summaries, dimension_summaries, outliers and recommendations are the whole window's readouts; periods repeats them for the last 14 and 7 days.

Learning Loop

What the engine proposes from results: recommendations, discovery findings, autonomy dials, batch generation health and customer share pages.

GET/content/digest

What needs a decision since a date: pending recommendations, findings, health events.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query since? (ISO-8601 date-time without an offset, a trailing Z is accepted; default 7 days ago, also used when since does not parse). No body.

Response

200 { recommendations_pending, accepted_recommendations: Recommendation[], findings_proposed: Finding[], auto_adopted_findings: [{ finding_id, pattern_id, undo_available }], health_events_opened: [{ kind, tiktok_account_id, opened_at }], estimated_minutes }.

Notes

Recommendation and Finding are the shapes of GET /content/recommendations and GET /content/discovery/findings. estimated_minutes is a rough review time: one per pending recommendation, two per proposed finding, one per auto-adopted finding and per health event.

GET/content/recommendations

The recommendation inbox, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query status? (PENDING, ACCEPTED, REJECTED; case-insensitive). No body.

Response

200 { recommendations: [{ id, kind, title, rationale, proposed_diff, evidence, status, created_at }] }.

Notes

kind: PATTERN_LIFECYCLE, CRAFT_PRINCIPLE, PAUSE_ACCOUNT, MIX_TARGET_CHANGE, LANE_ASSIGNMENT or AUTONOMY_DIAL. proposed_diff is the change with its before and after values; accept checks the before values against the current state.

POST/content/recommendations/{recommendationId}/accept

Apply a pending recommendation.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { recommendationId: UUID }; no body.

Response

200 { applied_event_id } (null unless the change produced an event). 400 { error } when the recommendation is no longer pending or its kind cannot be applied here; 404 when not found; 409 { conflict } when the state the diff expected has moved on since it was written.

Notes

PATTERN_LIFECYCLE moves the pattern's lifecycle stage, CRAFT_PRINCIPLE activates the principle, PAUSE_ACCOUNT pauses the account assignment. MIX_TARGET_CHANGE, LANE_ASSIGNMENT and AUTONOMY_DIAL are read here but applied from their own routes; accepting one is 400.

POST/content/recommendations/{recommendationId}/reject

Reject a pending recommendation.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { recommendationId: UUID }; { reason? }.

Response

200 { success: true }. 404 when not found or not pending.
GET/content/discovery/findings

Discovery findings: proposed new patterns and new references for existing ones, newest first.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Query status? (PROPOSED, APPROVED, REJECTED, SNOOZED, AUTO_ADOPTED), source? (BREAKOUT_SCAN, OWN_FLEET_OUTLIER, CAPTURE_INBOX, FORMAT_TREND); both case-insensitive. No body.

Response

200 { findings: [{ id, source, finding_type: "NEW_PATTERN" | "REFERENCE_FOR_EXISTING_PATTERN", source_account_username, existing_pattern_id, proposed_card, evidence_videos, cooling_flag, status, reject_reason, approved_pattern_id, created_at }] }.

Notes

proposed_card is the pattern the finding would create; evidence_videos are the videos behind it. AUTO_ADOPTED findings were minted into trialing patterns without sign-off by the DISCOVERY_ADOPT dial and stay undoable.

POST/content/discovery/findings/{findingId}/approve

Turn a finding into a pattern.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { findingId: UUID }; { initial_lifecycle?: "candidate" | "trialing" } (default candidate).

Response

200 { pattern_id }. 400 { error } when the finding is not PROPOSED or SNOOZED, or initial_lifecycle is something else; 404 when not found.

Notes

The planner only allocates trialing and proven patterns, so a candidate never reaches production until it is promoted (through the recommendation inbox); trialing starts testing at once.

POST/content/discovery/findings/{findingId}/reject

Reject a finding.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { findingId: UUID }; { reason? }.

Response

200 { success: true }. 404 when not found.

Notes

Works from any status and does not touch a pattern the finding already created; use undo for that.

POST/content/discovery/findings/{findingId}/snooze

Hide a finding until a date.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { findingId: UUID }; { until } (ISO-8601 date-time without an offset, a trailing Z is accepted).

Response

200 { success: true }. 400 when until does not parse; 404 when not found.

Notes

The finding goes back to PROPOSED when a scan sees the same finding again after the date.

POST/content/discovery/findings/{findingId}/undo

Undo an approval or auto-adoption: retire the pattern and reopen the finding.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { findingId: UUID }; no body.

Response

200 { success: true }. 409 { error } when the finding is not APPROVED or AUTO_ADOPTED (an unknown finding is also 409).

Notes

The pattern the finding created moves to retired and the finding returns to PROPOSED with its undone_at set.

GET/content/autonomy

The autonomy level of each gate, with override evidence for REVIEW and PUSH.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 { dials: [{ gate, level, weekly_cap, evidence?: { batches, green_scripts, override_rate, suggestion } }] }.

Notes

Gates and their levels: BRIEF FULL_GATE | AUTO_WITH_UNDO; REVIEW and PUSH FULL_GATE | SAMPLE_20; DISCOVERY_ADOPT APPROVAL_REQUIRED | AUTO_TRIALING_CAPPED (weekly_cap defaults to 2). Every gate defaults to its first level. evidence covers the last 4 reviewed batches: the share of green-checked scripts a human changed; suggestion appears after 4 batches and 80 green scripts with under 5% overridden.

PUT/content/autonomy/{gate}

Set a gate's autonomy level and weekly cap.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { gate } (case-insensitive); { level, weekly_cap? }.

Response

200 { gate, level, weekly_cap }. 400 { error } on an unknown gate or a level the gate does not support.

Notes

weekly_cap is stored as sent: omit it and the cap is cleared, including DISCOVERY_ADOPT's default of 2.

GET/content/batches/{batchId}/generation-status

Per-project generation and posting status for a pushed batch.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { batchId: UUID }; no body.

Response

200 { summary: { running, completed, failed, stuck }, projects: [{ project_id, script_id, script_external_id, automation_state, creation_stage, activity_state, failure_message, last_activity_at, scheduled_post_status, scheduled_at, verification_status, stuck }] }. 404 when the batch is not in this workspace.

Notes

Only scripts already pushed to a project appear. stuck means automation_state running with no activity for 30 minutes; stuck and failed projects sort first.

POST/content/batches/{batchId}/share-page

Publish a public share page for a live batch.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { batchId: UUID }; no body.

Response

201 { token, url } where url is the app path /share/{token}. 404 when the batch is not in this workspace; 409 { error } unless the batch stage is pushing, live or measured.

Notes

The page is rendered once, at creation, from the batch's scripts. Every call mints another token, and earlier tokens stay valid until revoked. The token is a bearer secret: anyone holding it can read the page and leave reactions.

DELETE/content/batches/{batchId}/share-page

Revoke every active share page of a batch.

Authentication

Internal Pamba operators only. A customer workspace key receives 403 Forbidden.

Request

Path { batchId: UUID }; no body.

Response

200 { success: true }. 404 when the batch has no active share page.

Notes

Immediate: GET /content/share/{token} answers 404 from then on.

GET/content/share/{token}

Read a batch's share page by its token.

Authentication

Public. No authentication required.

Request

Path { token }; no body.

Response

200 { title, cover: { scripts, accounts, days, proven, testing }, avatars: [{ name, accounts, patternMix: [{ pattern, count }] }], scripts: [{ scriptId, avatar, account, pattern, mode, scheduledFor, textHook?, caption?, referenceUrl?, referenceViews?, whatWeAreReplicating?, transcript }] }. 404 for an unknown or revoked token.

Notes

Field names are camelCase on this route. The page never carries workspace ids.

Engine Prompts

Read and override the prompts the writer engine runs on.

GET/content/prompts

Every prompt kind with the version in effect for the workspace.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

No body.

Response

200 [{ kind, version, scope: "workspace" | "global", has_workspace_override, body_length, updated_at }].

Notes

Kinds: WRITER, SPOKEN_HOOK, TEXT_HOOK, LIST_ITEMS, CHECKER_SPEC, CHECKER_REFERENCE_FIDELITY, CHECKER_SPOKEN, REFERENCE_MECHANISM, CLASSIFICATION, ORCHESTRATOR_REFERENCES, ORCHESTRATOR_WRITE_GROUP. A workspace override wins over the global default; a kind with no active prompt is left out.

GET/content/prompts/{kind}

The prompt body in effect for one kind.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { kind } (upper case, exact); no body.

Response

200 { kind, version, scope, body, declared_slots: string[], has_workspace_override }. 400 on an unknown kind; 404 when no prompt is active for it.

Notes

declared_slots are the {UPPER_CASE} placeholders in the body that the engine fills at run time.

PUT/content/prompts/{kind}

Write a new prompt version: a workspace override, or the global default.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { kind }; { body, scope?: "workspace" | "global" } (default workspace).

Response

200 PromptDetail of the new version, or 200 { status: "unchanged (identical to current version)" } when body matches the active version in that scope. 400 { error } on an unknown kind, a blank body or another scope; 403 for scope global unless the caller is an internal operator.

Notes

A workspace override applies to this workspace's runs only and is dropped with reset. scope global changes the default every workspace without an override runs on. Versions are never deleted: the previous one is retired and the new one becomes active. Keep to the slots the current body declares (GET /content/prompts/{kind}): a body naming a slot the engine cannot fill is refused, and the request fails with 500 rather than 400.

POST/content/prompts/{kind}/reset

Drop the workspace override, or re-seed the global default from the bundled file.

Authentication

X-API-Key, or JWT + X-Workspace-Id for workspace-scoped routes.

Request

Path { kind }; { scope?: "workspace" | "global" } (default workspace).

Response

scope workspace: 200 { status: "reverted to global default" | "no workspace override to reset" }. scope global: 200 PromptDetail of the re-seeded version. 400 { error } on an unknown kind or another scope, and for scope global when the kind has no bundled default or the bundled default is already the active global version (reported as no bundled default); 403 for scope global unless the caller is an internal operator.

Notes

A workspace reset retires the override so resolution falls back to the global default; nothing is deleted. A global reset writes the bundled file as a new global version and does not touch workspace overrides.

Billing

Stripe checkout/portal sessions and the public credit price list.

POST/billing/create-stripe-checkout-session

Create a Stripe checkout session.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

JSON body { "price_id": string, "return_path"?: string }.

Response

200 { "session_url": string }. 400 when the workspace has no Stripe customer; 403 when the caller is not the workspace owner or an admin; 409 when the change is a yearly-plan downgrade, which goes through support.
POST/billing/create-customer-portal-session

Create a Stripe customer portal session.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

No body.

Response

200 { "portal_url": string }. 400 when the workspace has no Stripe customer yet; 403 when the caller is not the workspace owner or an admin.
GET/billing/plans

The credit plan catalog and the top-up offer.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

Query include_yearly (true to include yearly plans; default monthly only).

Response

200 { plans: [{ key, tier, credits, price_id, interval: "month" | "year", price_usd, monthly_price_usd }], topup: { credits_per_unit, max_units, unit_price_usd, available } | null }. 503 while the catalog cannot be read from Stripe.

Notes

price_id is what POST /billing/create-stripe-checkout-session takes. A yearly plan's monthly_price_usd is its price spread over twelve months. topup is null when top-ups are not configured.

POST/billing/create-topup-checkout-session

Create a Stripe checkout for a one-off credit top-up on the active plan.

Authentication

X-API-Key or JWT. JWT callers do not need X-Workspace-Id on this bootstrap route.

Request

{ units (1-20, each 1000 credits), return_path (the app path Stripe returns to) }.

Response

200 { session_url }. 400 when units is out of range or the workspace has no Stripe customer; 402 when the workspace has no active subscription; 403 when the caller is not a workspace admin; 503 when top-ups are not configured.

Notes

The user opens session_url and pays in the browser; the credits land on the workspace when Stripe's webhook settles the payment. Top-ups extend a plan, so a workspace without one picks a plan first.

GET/usage/pricing

Public credit price list for all metered operations.

Authentication

Public. No authentication required.

Request

No body.

Response

200 { "video_generation_credits_per_second_by_model": { "xai/grok-imagine-video-1.5": 10.4, "bytedance/seedance-2.0/image-to-video": 48.1, "gemini-omni-1.1-flash-preview": 16.9, "gemini-omni-flash-preview": 16.9 }, "video_generation_credits_per_second": 60, "photo_generation_credits_per_image": 15, "scene_generation_credits_per_image": 15, "basic_voice_generation_credits": 100, "premium_voice_generation_credits": 1500, "hook_video_purchase_credits": 200 }.

Notes

Values are the live backend constants; clients should read prices from here instead of hardcoding. basic_voice_generation_credits is legacy: basic voices are now free curated roster voices, so no live flow charges it.

Fast Start

Create a project batch with an avatar and let automation handle generation, render, and scheduling.

curl -X POST https://api.pamba.app/projects \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "projects": [{
      "selected_avatar_project_id": "$AVATAR_PROJECT_ID",
      "project_name": "Plumber POV #1",
      "script": "Day in the life of a plumber. First call at 7am, a flooded basement. By noon I found a ring the customer lost years ago.",
      "is_fully_automated": true,
      "video_description": "Day in the life #plumbing",
      "tiktok_username": "plumber_pov",
      "scheduled_at": "2026-05-18"
    }]
  }'

Upload your own video

Post a video you produced outside Pamba to TikTok and/or Instagram, the same way the Upload button on the Videos tab does: a project holds the file, and scheduling posts it. Nothing is generated, rendered or credit-metered. Create the project without an avatar to post from any of the workspace's accounts, or with one to attribute the post's analytics to that avatar (then only that avatar's linked accounts are valid destinations).

# 1. Create an empty project (no avatar). Returns 201 { project: { id, ... } }.
curl -X POST https://api.pamba.app/projects/create \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

# 2. Upload the file as the project's final video.
curl -X POST https://api.pamba.app/projects/$PROJECT_ID/edited-video \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "video=@./my-video.mp4;type=video/mp4"

# 3. Schedule it. Returns 201 ScheduledPost; cross_posts carries the Instagram leg.
curl -X POST https://api.pamba.app/projects/$PROJECT_ID/schedule \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tiktok_username": "plumber_pov",
    "instagram": { "username": "plumber_pov" },
    "caption": "Behind the scenes on a flooded basement job #plumbing",
    "scheduled_at": "2026-09-14T14:00:00-07:00"
  }'

# One-call variant: upload and schedule together (multipart).
curl -X POST https://api.pamba.app/projects/$PROJECT_ID/schedule \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "video=@./my-video.mp4;type=video/mp4" \
  -F "tiktok_username=plumber_pov" \
  -F "instagram_username=plumber_pov" \
  -F "caption=Behind the scenes on a flooded basement job #plumbing" \
  -F "scheduled_at=2026-09-14T14:00:00-07:00"

# Project not decided yet: stage the file first, then attach it by URL.
curl -X POST https://api.pamba.app/uploads/videos \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -F "video=@./my-video.mp4;type=video/mp4"
# -> { "staged_video_url": "..." }
curl -X POST https://api.pamba.app/projects/$PROJECT_ID/edited-video \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"source_url\": \"$STAGED_VIDEO_URL\" }"

Destinations are accounts on the workspace's Pamba phones; list them with GET /devices/accounts/inventory. Upload limits and the re-encode rule are on the POST /projects/{projectId}/edited-video card; caption, timing, one-post-per-project and refusal codes on the POST /projects/{projectId}/schedule card.

Buy managed accounts and post to them

No social accounts of your own, or you want more: Pamba creates the TikTok or Instagram account on one of its phones, hosts it, warms it, and posts to it. Many workspaces run exactly this way, buying accounts and scheduling videos they produced elsewhere. Nothing here spends credits; seats bill monthly through Stripe. The order matters: buy, name, wait for creation, then post.

# 1. Options: price, cap, and purchases still being set up. Check the inventory first:
#    a purchase never adopts a spare account already on the phones.
curl https://api.pamba.app/devices/accounts/seat-options \
  -H "X-API-Key: $PAMBA_API_KEY"

# 2. Buy one TikTok and one Instagram account for an avatar. Returns { "checkout_url": "..." }
#    that the user opens and pays in the browser.
curl -X POST https://api.pamba.app/devices/accounts/seat-checkout \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tiktok_seats": 1, "instagram_seats": 1, "return_path": "/accounts", "link_avatar_project_id": "'$AVATAR_PROJECT_ID'" }'

# 3. After payment each seat waits for its name: get a checked free handle per seat, then save
#    the picks. Saving starts creation.
curl -X POST https://api.pamba.app/devices/accounts/username-suggestions \
  -H "X-API-Key: $PAMBA_API_KEY" -H "Content-Type: application/json" -d '{}'
curl -X PUT https://api.pamba.app/devices/accounts/seat-usernames \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "names": [{ "seat_request_id": "'$SEAT_REQUEST_ID'", "username": "plumber.pov" }] }'

# 4. Creation runs on a phone, minutes to longer. Poll the options (pending_requests[].jobs) until
#    the account is on the inventory with status "posting"; then it is a valid destination.
curl https://api.pamba.app/devices/accounts/inventory \
  -H "X-API-Key: $PAMBA_API_KEY"

# 5. Post your uploaded video to it (see "Upload your own video" above). To queue the post before
#    the account exists, make the project with the same avatar and schedule with the pending flag.
curl -X POST https://api.pamba.app/projects/$PROJECT_ID/schedule \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tiktok_pending_account": true, "caption": "First one from the new account", "scheduled_at": "2026-09-21T14:00:00-07:00" }'

Pricing, the bundle rule, cancellation and every response shape are on the /devices/accounts/* cards under Managed Accounts and Seats. Seats are TikTok and Instagram only; Facebook rides an Instagram account's cross-posting.