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.

Claude Code, Codex, Cursor and other MCP clients connect to https://api.pamba.app/mcp with the same API key and get every tool, with Pamba's product knowledge built in. The reference below is for direct HTTP calls.

Start with a guide: A posting API for TikTok and Instagram · An AI avatar video API that ends in a posted video · Pamba's MCP server for short-form video

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.

Media URLs

Every media field takes a file on Pamba storage, never a link to another host: a post's video_url, an editor item's src, an image_url and a clip's starting_frame_url. Upload the file first and pass the Pamba URL the upload returns: POST /uploads/videos for a video to post, POST /projects/{projectId}/upload-editor-asset for timeline images, video and audio, and POST /projects/{projectId}/upload-starting-frame for an image to use as a starting frame. A refused URL answers 400 with the upload to use. Fields named tiktok_url take a TikTok video link instead, instagram_url an Instagram reel link, and reference_video_url either.

Credit Usage

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

AI clip generation

POST /projects/{projectId}/generate-clip-videos, plus the generate_video path on bulk project creation and the agent's generate_video tool.

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.

Voice

Every 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 voices never charge.

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 projects that generate clips and render, then schedule each finished video.

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

Connected apps

Manage your own OAuth connections in the current workspace. Connector bearer tokens are accepted only at /mcp; use your usual workspace JWT or API key here.

List your active connected apps in this workspace.

Authentication

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

Request

No body.

Response

200 [{ id, client_name, workspace_id, workspace_count, created_at, last_used_at, expires_at }]. No credentials are returned.

Disconnect one of your apps across all its approved workspaces.

Authentication

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

Request

Path { connectionId: UUID }; no body.

Response

204 on revocation; 404 if absent, already revoked, or owned by another user, or not approved for the current workspace. Existing API keys are unchanged.

Account

Read caller profile, subscription state, and credit totals.

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[], paid_seats, 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. paid_seats is the managed-account seats the workspace holds (bought, or granted by Pamba), the same count as GET /devices/accounts/seat-billing's paid_seats; they are apart from the credits plan, so a workspace can hold seats with subscribed_product_name "No Subscription".

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.

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.

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, TikTok account-health notices and Instagram account restrictions; weekly_digest the Monday recap and the niche discover digest; account_and_credits purchased accounts ready, low balance, credit-wall follow-ups, failed profile edits, removed warming search terms and a change to account pricing; tips_and_onboarding the onboarding nudges and the first-post-live milestone. Yearly renewal notices always send and have no toggle.

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.

Announcements

What the banner at the top of the Pamba app shows: warnings about the workspace (such as accounts the workspace connected that were signed out of their phones, or accounts a platform banned), and new features and known outages or slowdowns, written by the Pamba team.

The announcements live for the caller now.

Authentication

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

Request

No body.

Response

200 { announcements: [{ id, kind: "warning" | "feature" | "outage", label, title, body, cta_label, cta_url, starts_at, ends_at, updated_at, viewed }] }. Warnings come first, then outages, then the newest start. label is the warning's badge (such as Banned) and null on a feature or outage. body, cta_label, cta_url and ends_at may be null; cta_label and cta_url are both set or both null. Times are ISO-8601 UTC.

Notes

Per user and workspace. A warning is about the workspace the request names and comes from its state: it shows while its cause lasts (a signed-out account until it is reconnected, a banned account until Pamba replaces it), and its id changes when its cause does. An announcement is live while it is switched on and inside its window, and one meant for some workspaces only (those with an Instagram account that can take a Facebook add-on now) is listed only to them. A feature the caller clicked or dismissed is left out; an outage stays until the Pamba team ends it. cta_url is an app path such as /content/create (prefix it with /w/{workspaceId}) or an https:// URL. viewed is true once the caller recorded a view, and always true on a warning.

Record what the caller did with an announcement.

Authentication

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

Request

Path { announcementId: UUID }. Body { action: "viewed" | "clicked" | "dismissed" }.

Response

200 { recorded: true }. 400 { error } on a bad id or action. 404 { error } when no announcement has this id, which includes every warning: nothing is recorded on one.

Notes

Each action counts once per user; repeating it is a no-op. Clicking or dismissing a feature announcement removes it from GET /announcements for this user.

Workspaces and Members

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

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, goal: "accounts" | "videos" | "both" | null, warming_follows, account_warming: { searches_enabled, follows_enabled }, is_default, role: admin | member, member_count, created_at, motion_overlays, pronunciations: [{ written, spoken }] }], active_workspace_id }, the caller's default workspace first. pronunciations is the list PUT /workspaces/{workspaceId}/pronunciations sets, empty for none. motion_overlays says whether the workspace's replications draw motion graphics (POST /projects/replications) and whether its editor states may carry caption looks and motion items (caption_look, type motion); it is on for a few workspaces while the feature is tried.

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.

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?: "light" | "complete", website_url?, niche_description?, what_they_do?, goal?: "accounts" | "videos" | "both" }. name is 1 to 255 characters and required unless setup is "light", or "complete" with a website_url. timezone is an IANA zone id; an invalid one is dropped, not refused. website_url only applies with a setup. setup "complete" needs website_url or niche_description; niche_description and what_they_do apply only to it. goal applies only with a setup (400 otherwise, and 400 on an unknown goal); without it the new workspace takes the goal of the workspace the request was made in.

Response

201 { id, name, niche_description, website_url, company_profile: { company_name, what_they_do, products_and_services, target_audience, brand_tone, logo_url } | null, owner_user_id, timezone, goal, warming_follows: true, account_warming: { searches_enabled: true, follows_enabled: true }, is_default: false, role: "admin", member_count: 1, created_at, motion_overlays, pronunciations: [] }, plus with setup "complete" setup_status: "completed" | "needs_brand" and, with needs_brand, setup_message. 400 when name is missing or over 255 characters, when website_url is not a valid address, when setup "complete" has neither website_url nor niche_description, when niche_description or what_they_do is over 2000 characters or is sent without setup "complete", or when setup is anything other than "light" or "complete".

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, and an API-key caller makes a key for it with POST /api-keys and its workspace_id. 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. setup "complete" runs that setup in the same call, as the wizard would: the site is read into the company brief and names the workspace after the company, niche_description and what_they_do confirm the brand instead of or on top of it, and the niche feed, three niche-matched avatars and, with a site, its media library capture start in the background. The call waits on the site read and, when what_they_do is sent, on the brand reconcile PUT /onboarding/brand describes: seconds usually, so set a client timeout of several minutes. It is not idempotent: after a timeout, look for the workspace in GET /workspaces before sending it again, or a second one is created. needs_brand means the workspace exists but its brand is unconfirmed (the site gave no usable brief, or there was no website_url and no what_they_do): finish it in the new workspace with PUT /onboarding/brand, then POST /onboarding/complete, rather than creating another. Without setup the workspace is bare and has no setup to run.

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?, own_niches?: "keep" | "replace" }, at least one of name and niche_description.

Response

200 { success: true, own_niches_replaced }. 400 when neither name nor niche_description is sent, or own_niches is neither keep nor replace; 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), and every account warms on it unless it or its avatar has a warming niche of its own (GET /devices/accounts/warming-niches): own_niches keep (the default) leaves those on theirs, replace clears the ones GET /devices/accounts/warming-niches lists so they warm on the new niche too (an archived account is never changed), and own_niches_replaced counts the accounts and avatars it cleared (0 otherwise).

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.

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.

Set what the workspace is for: accounts, videos or both.

Authentication

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

Request

Path { workspaceId: UUID }; { goal }, one of accounts (accounts to post content made off Pamba), videos (AI videos downloaded and posted elsewhere) or both (AI videos posted on accounts Pamba creates).

Response

200 { goal }. 400 { message } on an unknown goal, a malformed workspaceId or a deleted workspace ("Workspace not found"); 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action").

Notes

Admin only. The goal decides the steps of the app's first-week checklist on Home; changing it deletes and starts nothing. While the signup wizard that asks it is open, PUT /onboarding/goal is its first step, which any member may answer and which also starts the video steps' preparation; set here, the goal still changes that wizard's step list and completion rule but starts nothing. GET /workspaces reports it as goal, null when it was never answered. A workspace created with setup "light" or "complete" takes the goal sent with it, else that of the workspace the request was made in.

Set the workspace's account warming defaults, for new accounts only or for every existing account too.

Authentication

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

Request

Path { workspaceId: UUID }; { searches_enabled?: boolean, follows_enabled?: boolean, apply_to_existing_accounts: boolean }, with at least one of the first two.

Response

200 { account_warming: { searches_enabled, follows_enabled }, accounts_updated }. 400 on a malformed workspaceId or body, or when neither default is sent; 403 { message } unless the caller is an admin (a member gets 403 "Only admins can perform this action"); 404 { message } when the workspace no longer exists.

Notes

Admin only. The defaults are what a new avatar, and a new TikTok or Instagram account with no avatar, starts with (an account added to an avatar takes the avatar's setting): whether its warming sessions search, and whether they follow creators of in-niche videos they watch. Both are on until changed. apply_to_existing_accounts true also writes them onto every existing account and avatar of the workspace, from each account's next session; accounts_updated counts the accounts it reached. false changes the defaults for new accounts only. Each account keeps its own switches after that (PUT /devices/accounts/warming, or an avatar's through PUT /avatars/projects/{projectId}/selected-avatar). Warming itself keeps running, and follow lists (POST /devices/accounts/follows) are separate. GET /workspaces reports the defaults as account_warming.

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.

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.

Replace the workspace's pronunciation list: words its videos say differently from how they are written.

Authentication

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

Request

Path { workspaceId: UUID }; { pronunciations: [{ written, spoken }] }, the whole list, at most 50 entries. Each written and spoken is 1 to 60 characters with at least one letter or digit and no quote marks, tabs or line breaks; a written word appears once (case-insensitive), and its spoken form differs from it and is not itself a written word of the list. An empty array clears the list.

Response

200 { pronunciations: [{ written, spoken }] }, the stored list, each value trimmed. 400 { message } when an entry breaks a rule above, 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. The voice says spoken wherever a take's dialogue has the written word (whole words, any case), written "quinoa" said "keen-wah"; scripts, prompts and the editor keep the written word, and captions built from the take's audio show it as written in clips whose script uses it. It applies to takes generated after the change; a take already generated keeps how it was said until it is redone, and a video edit's instruction is sent as written. GET /workspaces returns the list as each workspace's pronunciations.

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.

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.

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.

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.

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.

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.

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.

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.

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.

Create an API key for the active workspace, or for a workspace the calling key created.

Authentication

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

Request

{ name, workspace_id? }. workspace_id: UUID; absent or blank means the active workspace.

Response

201 { id, key, key_prefix, name, workspace_id, created_at }. 400 when workspace_id is not a UUID. 403 { message } when workspace_id names another workspace and the caller is not an API key that created it, or when that workspace has since been deleted or the key's creator is no longer a member of it.

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 its workspace 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. The workspace is the active one (X-Workspace-Id for a JWT caller, the calling key's workspace for an API-key caller) unless workspace_id names another: an API-key caller may name a workspace that same key created with POST /workspaces (or the create_workspace tool), which is how an integration holding one key works in a workspace it just made. A key never makes keys for the person's other workspaces, and a JWT caller makes a key elsewhere by sending that workspace's X-Workspace-Id. Revoking the calling key leaves the keys it made working, and only the key that created a workspace can make keys for it: a replacement key does not inherit the workspaces its predecessor created, so keep a key made inside each one.

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; an API-key caller revokes any key of the user behind it, in any workspace. Revocation takes effect immediately. It removes nothing the key made: the keys it made with POST /api-keys, the workspaces it created, and the members, invitations and share links it added stay. After a leak, check GET /api-keys (by created_at and last_used_at, since a key's name is its maker's choice), the members and invitations of every workspace the user administers, the key's workspace's GET /share-links, and GET /workspaces.

Help and Support

Read the public help center and changelog, and write to and read your private support conversation with the Pamba team.

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}.

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.

List the public changelog's entries, newest first.

Authentication

Public. No authentication required.

Request

Optional query: audience=product | api (api: REST API and MCP changes), area (videos, slideshows, posting, accounts, comments, warming, analytics, workspaces, agent), since (YYYY-MM-DD: entries made or amended on or after that day), before (YYYY-MM-DD: entries dated before that day), breaking=true (breaking changes only). Values are case-insensitive; a blank one is no filter.

Response

200 { entries: [{ id, date, updated, title, summary, area, area_title, type, audiences, breaking, highlight, link, url, body }], areas: { <area>: title }, has_older }. type is new, improved, changed, deprecated or removed; audiences lists product and/or api; updated is the day a later change amended the entry, or null; breaking marks an api change a working caller can fail on, and its body says what to change; body is markdown naming the routes, tools and fields that changed (empty for a product-only entry); link is a pamba.app path or null; url opens the entry on its pamba.app page; has_older is true when an entry matching the same filters is dated before since. 400 { message } for an unknown audience or area, a malformed date or a breaking other than true or false.

Notes

The same entries as https://pamba.app/changelog (product) and https://pamba.app/changelog/api (REST API and MCP), whose Atom feeds are /changelog/feed.xml and /changelog/api/feed.xml, and as the get_recent_changes tool. Poll with since a few days before your last read and keep each entry's id with its updated day: an entry is dated the day it went live, which can be a day or two before it reaches this list, and a later amendment keeps its id and sets updated, so treat a new updated as news. Bug fixes are not listed. id is the entry's page anchor.

Example

curl "https://api.pamba.app/changelog?audience=api&since=2026-10-01"

Record a captured frontend failure for the active workspace.

Authentication

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

Request

{ eventId: UUID, kind: render | unhandled_error | unhandled_rejection, fingerprint: SHA-256 hex, routeTemplate, requestId?: UUID }. Maximum body 2048 bytes. routeTemplate must be a known app route template (for example /w/:workspaceId/content/create) or <unknown>. Raw URLs, messages, stack traces and identity fields are rejected.

Response

202 after durable acceptance (including duplicate event ids); 400 for an invalid event, 413 for an oversized body, 429 with Retry-After: 60 when the workspace exceeds 20 events or the server exceeds 1000 events per minute, 503 when persistence is unavailable.

Notes

The authenticated workspace and user own the event. Reuse eventId on retries; optionally correlate with an API response's server-issued X-Request-Id. This endpoint does not send email. Use POST /support/messages to ask for help.

Send a message to the Pamba team in your private support conversation.

Authentication

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

Request

{ message, contact_email?, attachment_ids?, page_path?, idempotency_key? }. message is up to 5000 characters after trimming and may be empty only when attachment_ids is not. attachment_ids holds up to 4 ids from POST /support/attachments that you uploaded and have not sent. contact_email is a plain ASCII address (no display name) the team's replies are emailed to; it stays for later replies until another message gives a new one, and without one the account email is used. page_path is the app page the message was written from (a path starting with /), shown to the team. idempotency_key is yours, 1 to 100 letters, digits, - or _, scoped to the workspace, the user and the credential (a signed-in session and an API key are separate scopes) and never expiring: a request retried with the same key in the same scope returns the message the first one stored instead of sending it again, and the same key in another scope sends a new message.

Response

201 { message, request_id, message_id, contact_email, support_message } where request_id and message_id are both the stored message's id, contact_email is the address replies are emailed to (the one given, else the one kept from an earlier message, else the account email), and support_message is the stored message: { id, author: customer, author_name: null, body, sent_via, agent_chat_shared, attachments: [{ id, url, content_type, width, height }], created_at }. 400 { message } for an empty or over-long message, more than 4 attachments, an attachment that is not yours or was already sent, an invalid contact_email, an invalid idempotency_key, or no contact_email when the account email cannot receive mail and the conversation keeps no other address; 429 { message } with a Retry-After header in seconds after 10 messages in a minute or 100 in a day from the caller (emails to [email protected] or to a team member count too), or 2000 a day platform-wide.

Notes

Each user has one conversation per workspace, visible only to them and the Pamba team. The team's replies arrive in the conversation (GET /support/messages), in the app's Support tab, and by email when unread for 10 minutes; replying to that email adds to the same conversation. Sending a message does not mark the team's replies read, so their email still goes unless you read them (POST /support/messages/read). sent_via is app for a signed-in session and api for an API key, which acts as the user who created it.

Read your support conversation with the Pamba team.

Authentication

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

Request

Query { limit?, before?, after? }. limit defaults to 30; a value outside 1 to 100 is clamped into it, and one that is not a number is a 400. Without a cursor: the newest limit messages. before is a message id: the limit messages just older than it. after is a message id, for polling: at most limit of the messages newer than it, the oldest of them first.

Response

200 { messages, has_more, unread_count }. messages are oldest first, each { id, author: customer | staff, author_name (a team member's first name, null for yours), body, sent_via (app | agent | mcp | api | email for yours, null for the team's), agent_chat_shared, attachments: [{ id, url, content_type, width, height }], created_at }. has_more says whether older messages remain, for a read without a cursor or with before; an after read always answers false. unread_count counts the team's messages you have not marked read. 400 { message } for a before or after id that is not in your conversation.

Notes

To catch up with after, read again from the newest message you got until a read returns fewer than limit messages. Attachment urls are signed links valid for 60 minutes and change on every read; keep the first one you got per attachment id. A caller with no conversation yet gets an empty list.

Mark your support conversation read up to a message.

Authentication

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

Request

{ through_message_id }: a message id in your conversation, usually the newest on screen.

Response

200 { unread_count }. 400 { message } when the id is not in your conversation.

Notes

The marker never moves backwards. Reading everything cancels the reply email that would otherwise go out 10 minutes after the team's reply.

Count the Pamba team's support replies you have not read.

Authentication

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

Request

No body.

Response

200 { unread_count }.

Upload a screenshot to attach to a support message.

Authentication

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

Request

multipart/form-data with one part named file: a PNG, JPEG or WebP image of at most 10 MB.

Response

201 { id, content_type, width, height, url }. 400 { message } for a missing part, a file that is not a PNG, JPEG or WebP image, a file over 10 MB, or an image over 60 million pixels; 429 { message } with a Retry-After header in seconds after 50 uploads in a day.

Notes

The image is re-encoded before it is stored, which drops its metadata, and stays private: only you and the Pamba team can open it. Pass the id in POST /support/messages attachment_ids; an upload never sent is deleted after 24 hours.

Send a message to the Pamba team (the original contact route).

Authentication

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

Request

Same body as POST /support/messages: { message, contact_email?, attachment_ids?, page_path?, idempotency_key? }, where idempotency_key is yours per workspace, user and credential and never expires, and a contact_email stays for later replies until another replaces it.

Response

Same responses as POST /support/messages; the response's request_id is the stored message's id.

Notes

An alias of POST /support/messages, kept for callers that predate the support conversation. New callers use POST /support/messages.

Onboarding

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

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, claimed_device_count, account_seats, device_step_skipped, plan_price_id, plan_credits, plan_step_skipped, referral_source, referral_source_detail, dismissed_at, completed_at, created_at, step_list_version: 2 | null, goal: "accounts" | "videos" | "both" | null, door_tiktok_seats, door_instagram_seats }. 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; claimed_device_count (phones the legacy device step claimed) resolves it the same way. Reading the state may re-arm the one-time seeding of the content hub from the stored references.

Goal step: record what the workspace is for, which decides the wizard's steps.

Authentication

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

Request

JSON body { "goal": "accounts" | "videos" | "both", "preselected_goal"?: string, "tiktok_seats"?: integer, "instagram_seats"?: integer }. preselected_goal is the goal the signup page suggested, recorded for analytics only. The seat counts are the accounts the signup page asked for, clamped to 0 to 500, and pre-fill the accounts purchase after the wizard.

Response

200 OnboardingState with goal set (and door_tiktok_seats, door_instagram_seats when sent); current_step does not move. 400 on an unknown goal, or when this workspace's wizard does not ask it: onboarding completed, a row from before the question (step_list_version null), or the light setup of an added workspace (change the goal with PUT /workspaces/{workspaceId}/goal then, admins only). A dismissed wizard is resumable and still answers. 404 when onboarding was never started.

Notes

A wizard whose step_list_version is 2 opens on this question. accounts skips the avatar, research, first-drafts and plan steps: website, brand, then PUT /onboarding/referral and POST /onboarding/complete, with the accounts bought afterwards (POST /devices/accounts/seat-checkout); its website and brand steps do not start the first-video research or the presenter match. videos and both keep every step. Switching away from accounts after the brand step starts that research and match. Rows with step_list_version null predate the question and keep the seven steps they started on, whatever goal their workspace is later given.

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

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.

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).

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.

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.

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 free roster voice. The credit check answers 400 here, not 402. The instant alternative is POST /onboarding/avatar/select with one of the presenter options.

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.

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 its plan is still waiting on its first bank payment, 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). Its page takes a Stripe promotion code. Nothing is recorded until payment: the paid checkout's webhook (at once for a card, or once a bank transfer settles, up to about 4 business days later) 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/..." }

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.

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.

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.

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.

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), except on the accounts track (step_list_version 2 with goal accounts; a row from before the goal question keeps the gate whatever its goal); 400 in the light flow, or on the accounts track, 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.

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.

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.

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. A niche request runs up to 5 TikTok searches in parallel, and searches the niche sentence again when every angle came back empty; an explicit topic runs one search plus a lookup of Pamba's video corpus. Each search request times out at 60s, with one retry after a rate limit of up to 10s; 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": "" }'

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.

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.

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 (default 20), sort_by (date | views | views_per_follower | shares_per_view | likes_per_view | bookmarks_per_view | comments_per_view | engagement_rate), sort_direction (asc | desc, default desc), include_archived (default false), min_views, max_views, min_views_per_follower, max_views_per_follower, min_engagement_rate, max_engagement_rate, days_lookback, phone_number, search, media (video | photo: photo lists photo-post ideas — slideshow sources with slides instead of a transcript; absent = both)

Response

200 { ideas: Idea[], page, page_size }; Idea includes id, title, notes, status, analyzed_video, scripts, avatars, video_projects, archived_at, created_at. analyzed_video carries media_kind (video | photo); a photo post's analyzed_video has gcs_video_url null and photo_slide_urls (its slides as Pamba keeps them, swipe order, at most 12), photo_slide_count, photo_verdict (pass | no_slot | children | refuse) and photo_verdict_why, and is replicated with POST /slideshows/replications { idea_id }.

Bulk-create ideas from titles, notes, and TikTok URLs (a /photo/ link lands as a photo idea with its slides).

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[] }.

Notes

Creates are not deduplicated: a retried request creates the ideas again, so record the returned idea_ids, and after a timeout list GET /ideas before sending it again.

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" }] }'

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 }.

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 }.

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 }.

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 }.

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 }.

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 }.

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 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 }. 404 when the script is not in this workspace, or was already deleted.

Notes

The script's avatars are detached with it. A video project made from the script stays on its idea, and its created_from_script_id in the idea's video_projects reads null from then on.

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 }.

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 }.

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.

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 plan or account seats, unless the workspace holds credits it was given or bought; 422 when the model call failed or returned nothing usable.

Notes

Blocks on one model call, typically 4 to 9 seconds. The script runs 30 to 45 seconds spoken unless the idea asks for another length, and is written in the idea's language. 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.

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), media (video, default; photo; all).

Response

200 { videos: DiscoverVideo[], page, page_size, niche_status: ok | pending | no_niche }. 400 on an unknown media.

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.

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.

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).

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.

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.

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.

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.

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.

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.

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), media (video, default; photo for the Slideshow card's grid of viral TikTok photo posts).

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, media_kind (video | photo) }], page, page_size, brand_supplied_kinds, niche_status, locked_count, locked_covers, niche_downloads_in_flight, niche_feed_stale_since }. 400 on an unknown source, sort, feed or media.

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. With media=photo every source reads the corpus's photo posts (for_you = the workspace's in-niche posts first, then the trending ones around them, same-category first; discover follows feed; search hunts the corpus): rows with no video_url and no feasibility, but a photo_verdict (refuse | no_slot | pass) and photo_slide_count from the photo-post judge; exclude_low_feasibility (default true) then means judge-passed only, false adds unjudged and no_slot rows, and a refused post never appears. Each row's corpus_video_id feeds POST /slideshows/replications. 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. A free plan's niche is searched once, at signup or a niche edit; once that search is 5 days old, page 1 of for_you and of discover with feed=niche carries niche_feed_stale_since (UTC, ISO) for a free workspace, when its niche videos have aged out of the 7-day feed (a paid plan refreshes the niche daily). poster_thumbnail_urls are direct bucket URLs that may 404 until first rendered; fall back to poster_url.

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.

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[] }. 409 { error_code: "account_archived", message } when one of the usernames is one of the workspace's own accounts that is archived: tracking it again would bring it back, which only Pamba support does.

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"] }'

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. Read status for whether an account is active: deactivation_reason avatar_unlinked also rides an active account with no avatar, the mark an unlink or an avatar delete leaves so a later assignment reuses the row.

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; 409 { error_code: "own_account", message } if it is one of the workspace's own accounts (signed in on one of its phones, or named by one of its seats), which would be archived for good: pause one of those instead.

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 (the GET /ideas values), sort_direction (asc | desc), 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, media_kind (video | photo: a TikTok photo post, pictures with a soundtrack, duration 0 and no transcript), permalink (/video/ or /photo/ on TikTok) and views_source, always public (TikTok publishes every post's views; see the Instagram listings for the other values).

Notes

By default the videos of the workspace's own accounts, the Dashboard's set: those linked to an avatar, signed in on the workspace's phones, or with a post the workspace scheduled. include_research_accounts=true adds the inspiration accounts it tracks.

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 }] }.

Notes

Days are calendar days in timezone (default: the workspace's). A day holds the growth Pamba read that day across the account's videos plus, on the day a video was posted, everything it earned before Pamba first read it (when Pamba first read it within 90 days of posting; a video tracked later adds only its later growth), so a recent day can still grow after it ends. When two readings of a video are two or more days apart (typically an older video), the views between them are spread evenly over those days, and that stretch's likes, comments, shares and bookmarks are not counted.

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.

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[] }] }.

Accounts available in analytics.

Authentication

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

Request

No body.

Response

200 { accounts: [{ username, avatar_id, avatar_name, avatars: [{ avatar_id, avatar_name }], archived }], unassigned_accounts: [{ username, archived }] }.

Notes

accounts are the ones linked to one of the workspace's avatars, one entry per account: avatars lists every avatar a shared account posts as, and avatar_id names the first. unassigned_accounts are the workspace's accounts no avatar claims: signed in on one of its phones, or with a post the workspace scheduled, and an archived account whose avatar was deleted. archived is true for an account the workspace ran and archived (removed it from analytics, or it was replaced with a recreated one): its history keeps counting in analytics with nothing new read for it. An inactive avatar's accounts are archived the same way until it is active again; an account still live under another avatar is listed under its live avatars only. An account tracked for analytics alone leaves the analytics, history included, once removed.

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 the workspace's own accounts. accounts carries the avatars' accounts, one entry per account with avatars naming every avatar it posts as (avatar_id is the first, and totals count a shared account once), and unassigned_accounts the accounts no avatar claims (signed in on one of its phones, or with a post the workspace scheduled); every account carries archived, true for an account the workspace ran and archived (removed it from analytics, or it was replaced with a recreated one) and for one whose avatars are all inactive, whose history keeps counting with nothing new read for it (an account tracked for analytics alone leaves, history included, once removed); the video lists count every one of them. Every video carries views_source, always public on TikTok.

Notes

Daily figures follow the views-per-day rule, always on the workspace's calendar: timezone only sets which day is today (the end of the window), not which day a reading lands on. Every account also carries refreshed_at (when Pamba last read its counts; null before the first read), next_refresh_at (when its next read is due; in the past while that read runs) and refresh_every_hours (how many hours apart it is read: 1 for most accounts, more for some), the last two null for an archived account; times are UTC as yyyy-MM-ddTHH:mm:ssZ. Totals and the daily series catch up within 30 minutes of a read.

Instagram and Facebook Analytics

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

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, avatars: [{ avatar_id, avatar_name }], archived }], unassigned_accounts: [{ platform: "instagram", username, archived }] }.

Notes

accounts are the ones linked to one of the workspace's avatars, one entry per account: avatars lists every avatar a shared account posts as, and avatar_id names the first; avatar_id is the avatar's own id, not its project id. unassigned_accounts are the workspace's accounts no avatar claims: signed in on one of its phones, or posted to by the workspace before. archived is true for an account the workspace ran and archived (removed it from analytics, or it was replaced with a recreated one), and for an account whose avatars are all inactive until one is active again: its history keeps counting in analytics with nothing new read for it. An account still live under another avatar is listed under its live avatars only. An account tracked for analytics alone leaves the analytics, history included, once removed.

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 { accounts: [{ platform, username, avatar_id, avatar_name, avatars: [{ 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 }], archived (every avatar it is listed under is inactive: its history keeps counting, nothing new is read for it), refreshed_at, next_refresh_at }], unassigned_accounts: Account[] without avatar_id and avatar_name, with archived (an account the workspace archived, whose history keeps counting), 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, media_kind, create_time, view_count, like_count, comment_count, share_count, bookmark_count, follower_count, views_per_follower, engagement_rate, description, permalink, metrics_updated_at, views_read_at, views_source }.

Notes

unassigned_accounts are the workspace's accounts no avatar claims (signed in on one of its phones, or posted to by the workspace before); scope_overview and the video lists count them with the avatars' accounts. The window is the last days calendar days in the given zone. top_videos is the window's posts 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. media_kind is video for a reel and photo for a slideshow (carousel) or picture post. Instagram shows a photo post's views only to its owner, so its view_count is what the account's phone read off the post's own insights (views_read_at says when: about three days after posting, then a final read about four weeks after posting; those views count in the totals and series from the read, the first read's on the posting day), and 0, with views_per_follower and engagement_rate 0, before the first read. views_source says where view_count comes from: public (the count the platform shows publicly, read by Pamba's hourly scrape: a reel, every Facebook post) or insights (read off the post's own insights by the account's phone: every photo post). An insights post with views_read_at null has not been read yet: its 0 means the views are not known yet, never no views. A photo post's duration is 0. A day in the series holds the growth Pamba read that day; when two readings of a post are two or more days apart (an account not read while its avatar was inactive, or a photo post's final read), the growth between them, likes and comments included, is spread evenly over those days. refreshed_at is when Pamba last read the account's counts and next_refresh_at when its next read is due (every hour, on the quarter hour; in the past while that read runs; null for an archived account), both UTC as yyyy-MM-ddTHH:mm:ssZ. Totals and the daily series catch up within 30 minutes of a read.

Every tracked post across the workspace's Instagram accounts (reels, slideshows and pictures), most-played 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.

Notes

A slideshow (a carousel) or picture has media_kind photo. Instagram publishes its likes and comments but not its views, so its view_count is what the account's Pamba phone read off the post's own insights, at views_read_at: about three days after posting, then a final read about four weeks after posting, after which it no longer changes. Its views_source is insights. Before the first read view_count is 0 and views_read_at is null: the views are not known yet, not zero (the Analytics page shows a ?). A reel's views are public: views_source public and views_read_at null, a slideshow posted as a reel (as_reel on POST /slideshows/{slideshowId}/schedule) included.

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, avatars: [{ avatar_id, avatar_name }], archived }], unassigned_accounts: [{ platform: "facebook", username, archived }] }.

Notes

A Facebook account rides the Instagram account it is linked to, so analytics follow that Instagram account in the workspace. accounts, one entry per account: a Facebook account posts for its own avatar and every avatar its Instagram account is linked to, and avatars lists the active ones, avatar_id naming the first. An account whose avatars are all inactive is listed under them with archived true until one is active again: its history keeps counting with nothing new read for it. unassigned_accounts are the workspace's Facebook accounts signed in on one of its phones that no avatar claims, directly or through their Instagram account. archived is true for one the workspace archived (or whose Instagram account it archived), whose history keeps counting.

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"; unassigned_accounts are the accounts no avatar claims (signed in on one of the workspace's phones), and scope_overview and the video lists count them with the avatars' accounts; an account whose avatars are all inactive carries archived true.

Notes

Same windows, limits, daily rule and refreshed_at / next_refresh_at as the Instagram dashboard: growth read two or more days after the reading before it is spread evenly over those days, the newest posts are pulled every hour and every post every 12 hours. Share and bookmark counts are 0. A post's views are public: views_source is always public.

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.

Post Analytics and Audience

One post's metrics and audience, the retention and engagement of every post with a read at a glance, an account's viewers and followers, and the workspace's audience across its accounts, read off TikTok's and Instagram's own analytics by the accounts' phones.

The workspace's audience across all its accounts on Pamba phones.

Authentication

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

Request

No parameters or body.

Response

200 { computed_at: string | null, rollups: [{ segment: "viewers" | "followers", platform: "all" | "tiktok" | "instagram", total, gender: [{ label, percent }], age: [{ label, percent }], locations: [{ label, percent }], new_viewers_percent, returning_viewers_percent, follower_percent, non_follower_percent, viewer_types_base, follower_split_base, accounts: [{ platform, username, source: "studio_viewers" | "reels" | "followers", weight, reads, read_at }], earliest_read_at, latest_read_at, sample: { size, unit: "viewers" | "followers", low_confidence, confident_from } | null }], next_visit_at: { all?, tiktok?, instagram? }, last_checked_at: { all?, tiktok?, instagram? } }, one rollup per segment and platform scope; rollups empty until the phones have an answer to an audience read inside the windows below.

Notes

Each account counts by its size: its viewers for viewers, its followers for followers. viewers folds TikTok Studio's Viewers tab (the last 28 days) and, since Instagram has no account-wide viewers breakdown, each Instagram account's reels read in the last 28 days; followers folds each app's followers breakdown (both apps show it only from 100 followers). Each account counts its newest read of the last 60 days; the phones read every account on its daily visit. Ages use shared buckets (18-24, 25-34, 35-44, 45-54, 55+; Instagram's 55-64 and 65+ count as 55+), and none under 18 is ever served. Locations are of what each read lists (the apps show the top five), so they do not sum to 100. Not every read shows new vs returning viewers or followers vs not: viewer_types_base and follower_split_base are the part of total those shares are of. The rollups are materialized: rebuilt when a read is analyzed and at least every six hours, and computed_at says when. next_visit_at, keyed like a rollup's platform (UTC as yyyy-MM-ddTHH:mm:ssZ, while the read times above are UTC without a zone), is the soonest daily phone visit planned for the workspace's accounts on its phones, read yet or not (so it is there before the first read): each visit reads whatever is due (a post at 200 views, then 1k, 5k, 10k and every doubling; the account's audience once a day). last_checked_at, keyed and formatted the same way, is the newest read the app answered of the kinds the rollups fold (an account's viewers or followers, an Instagram reel's viewers; TikTok post reads do not count), of either segment and of the accounts the rollups count, inside the same windows (an account's last 60 days, a reel's last 28), also when the app had too little data to show an audience (so a rollup can be empty after it). sample is total as a sample: low_confidence when the rollup folds fewer than confident_from (10,000) viewers or followers, whose shares can still move a lot; null while total is 0. The viewers are those the reads' in-app analytics printed; a followers read that printed no follower count folds the account's public follower count. An Instagram account's viewers are its reels' viewers summed (a reel that printed none counts by its views), so a person who watched two reels counts twice.

Example

curl https://api.pamba.app/analytics/audience \
  -H "X-API-Key: $PAMBA_API_KEY"

The workspace's posts with an audience read, each read at a glance.

Authentication

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

Request

Query platform (all (default), tiktok or instagram), days (posts posted in the last N days, 1-365, default 30), sort (newest (default): newest read first; retention_5s and retention_end: most still watching first; skip_rate: lowest first; like_rate, comment_rate, share_rate and save_rate: highest first; a post with no value for the sort comes last, and equal values keep newest read first).

Response

200 { posts: [{ platform, video_id, username, caption, cover_url, posted_at, media_kind, duration_seconds, views, likes, comments, shares, saves, read: { id, captured_at, milestone, views_at_read, views_read, retention_at_5s, retention_at_10s, retention_at_midpoint, midpoint_seconds, retention_at_end, end_seconds, skip_rate_percent, skip_rate_from_curve, average_watched_percent, watched_full_percent, average_watch_seconds, drop_off_seconds, retention: [{ second, percent }], likes, comments, shares, saves, reposts, like_rate_percent, comment_rate_percent, share_rate_percent, save_rate_percent, repost_rate_percent, rates_from_counts, peak_like_seconds, sample: { size, unit: "viewers" | "views", low_confidence, confident_from } | null } }], total }. 400 for an unknown platform or sort, or days outside 1-365.

Notes

Each post appears once, with its newest read whose status is read, and only posts of the workspace's own accounts (under their handle now, even when the read was made before a rename) read by its own phones. The answer holds at most 1,000 posts; total counts every match. The retention_at_* values are the percent of viewers still watching, read off the post's retention curve; retention_at_end is at the last measured point before the curve's final one, end_seconds saying where (TikTok's chart falls to about zero in its last second), and a moment past the end of a short post is null. skip_rate_percent is the app's own skip rate where it prints one (Instagram); otherwise it is the share of viewers gone in the first 3 seconds of the curve and skip_rate_from_curve is true (always so on TikTok, which prints none). views_at_read is the views the read was planned at, as on the post's page; views_read is the views the phone read off the app (null when the page printed none). retention holds at most 21 points of the curve, its end moment and its last point kept; GET /analytics/posts/{platform}/{videoId} has the full curve (a point a second up to 20 seconds, 21 points over a longer post). A TikTok photo post has no curve, so its retention values are null. The post's likes, comments, shares and saves are the listing's own (Instagram's listing has no shares or saves, null); the read's are what the app counted at the read, reposts being Instagram's. The *_rate_percent values are the share of viewers who liked, commented, shared, saved or reposted: Instagram's own rates where the read printed them; TikTok prints none, so there each is the read's count over views_read and rates_from_counts is true. peak_like_seconds is TikTok's most liked moment. sample is how many people the read's numbers come from: size, unit (the viewers the app's analytics printed on the read, else the views they printed; never the public listing's count), low_confidence and confident_from (10,000): below it a retention percent or a rate can still move a lot as more people watch. Analytics shows no Low sample size mark on a single post (it marks only an account's or the workspace's audience). sample is null when the read's analytics printed no count (a count of 0 is none). caption is cut to 200 characters.

Example

curl "https://api.pamba.app/analytics/posts?days=7&sort=retention_5s" \
  -H "X-API-Key: $PAMBA_API_KEY"

One post's metrics, their daily history, and its audience reads.

Authentication

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

Request

Path { platform: "tiktok" | "instagram", videoId: the TikTok video id or the Instagram shortcode }; no body.

Response

200 { platform, video_id, username, url, caption, cover_url, posted_at, media_kind, duration_seconds, views, views_source, views_read_at, likes, comments, shares, bookmarks, metrics_updated_at, history: [{ date, views, likes, comments, shares, bookmarks }], audience: AudienceRead | null, audience_reads: AudienceRead[], next_read: { milestone, description } | null }. AudienceRead: { id, scope: "post" | "account", segment: "viewers" | "followers", status, captured_at, analyzed_at, milestone, views_at_read, total_viewers, new_viewers_percent, returning_viewers_percent, follower_percent, non_follower_percent, gender: [{ label: "female" | "male" | "other", percent }], age: [{ label, percent }], locations: [{ label, percent }], metrics: { views, viewers, followers, new_followers, average_watch_seconds, watched_full_percent, total_play_seconds, average_watched_percent, drop_off_seconds, skip_rate_percent, average_photos_viewed, photo_count }, retention: [{ second, percent }] (the percent still watching at each second, measured off the app's chart; empty when the read shows none), photo_retention: [{ photo, percent }] (a TikTok photo post's curve instead: the percent of viewers who reached each photo, 1 the first; its metrics carry average_photos_viewed out of photo_count and no watch time), retention_moments: { retention_at_5s, retention_at_10s, retention_at_midpoint, midpoint_seconds, retention_at_end, end_seconds, skip_rate_percent, skip_rate_from_curve } | null, engagement: { likes, comments, shares, saves, reposts, like_rate_percent, comment_rate_percent, share_rate_percent, save_rate_percent, repost_rate_percent, rates_from_counts, top_comment_words: [{ word, count }], peak_like_seconds, likes_by_second: [{ second, percent }] } | null, period, detail, frames: [{ label, kind, url }], sample: { size, unit: "viewers" | "views" | "followers", low_confidence, confident_from } | null }. 400 for an unknown platform; 404 when the post is not on one of the workspace's own accounts.

Notes

The account's phone reads a post on its daily visit once the post has 200 views, at any age (or once it is three days old with 100), and again as its views pass 1,000, 5,000, 10,000 and every doubling after, at most ten posts a visit and once a day per post; milestone is the step a read answers. An Instagram slideshow posted as a carousel, or a picture (media_kind photo), whose views only its owner sees, is read by age instead, whatever its views: three days after posting (milestone 100), then a final time once it is four weeks old (milestone 0), after which next_read is null. No post is read once it is more than 60 days old, and next_read is null too for a post of an account on no Pamba phone. views_source says where views come from: public (the platform's public count: every TikTok post, an Instagram reel, a slideshow posted as a reel (as_reel on POST /slideshows/{slideshowId}/schedule) included) or insights (an Instagram carousel's or picture's views, read off its insights by the account's phone, last at views_read_at). Before an insights post's first read views and views_read_at are null, which means not read yet, never no views; views_read_at is null for public views. status is read, insufficient_data (the platform has too little data yet), reading (screenshots in, numbers being read) or not_found (the platform says the post is unavailable); a read the phone or the analysis could not make is not listed, and next_read keeps saying when the post is due. detail is the read's own reason on read and insufficient_data, null otherwise. audience is the newest read whose status is read. The numbers are the platforms' own estimates, transcribed from the screenshots in frames. sample is how many people the numbers come from: size, unit (the viewers the app's analytics printed on the read, else the views they printed; followers on a followers read; never the public listing's count), low_confidence and confident_from (10,000): below it a share, a retention percent or a rate can still move a lot as more people watch or follow. Analytics shows no Low sample size mark on a single post (it marks only an account's or the workspace's audience). sample is null when the read's analytics printed no count (a count of 0 is none). Instagram has no shares or bookmarks (null) in the post's own counts; its read's engagement carries them. engagement is what the post's Engagement tab and its counts show: likes, comments, shares and saves as the app counts them (reposts are Instagram's; top_comment_words and peak_like_seconds, the moment most viewers liked it, are TikTok's), the five rates (Instagram's own where the read printed them; TikTok prints none, so there each is the read's count over its views and rates_from_counts is true), and likes_by_second, when in the video viewers liked it, measured off the app's chart on its own scale: TikTok's runs to 100% at its most liked moment, Instagram's gives each moment's share of the likes. It is null on an account's read and on a post read analyzed before reads took in engagement; a post read whose Engagement tab did not open still carries the counts and the rates (Instagram's printed on the post's Overview, TikTok's from those counts), with an empty likes_by_second and top_comment_words and a null peak_like_seconds. engagement.saves is the count the post's own analytics call bookmarks. Age never includes viewers under 18: Instagram's 13-17 is left out (TikTok shows none), and the other ages keep the percent the app printed, so they can sum under 100.

Example

curl https://api.pamba.app/analytics/posts/tiktok/7690750011893697806 \
  -H "X-API-Key: $PAMBA_API_KEY"

An account's viewers and followers breakdowns, and its daily health checks' screenshots.

Authentication

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

Request

Path { platform: "tiktok" | "instagram", username }; no body.

Response

200 { platform, username, viewers: AudienceRead | null, followers: AudienceRead | null, reads: AudienceRead[], health_checks: [{ id, status, created_at, analyzed_at, account_warning, account_warning_detail, issue_count, frames: [{ label, kind, video_index, url }] }] }, with the AudienceRead of GET /analytics/posts/{platform}/{videoId}. 400 for an unknown platform; 404 when the account is not one of the workspace's own.

Notes

TikTok's viewers come from TikTok Studio's Viewers tab over 28 days and its followers from the Followers tab; Instagram's followers from the account's Insights. Each is read on the account's daily visit, TikTok's viewers only for an account with a post from the last 60 days (one with none has no viewers to show) and a followers breakdown only for an account with 100 followers, since both apps fill it only from there. health_checks are the account's latest daily TikTok health checks, newest first (empty for Instagram). Age never includes viewers under 18: Instagram's 13-17 is left out (TikTok shows none), and the other ages keep the percent the app printed, so they can sum under 100.

Avatars and Starting Frames

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

List avatars by status or tag.

Authentication

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

Request

Query avatar_status (active | inactive, lowercase; any other value is a 400), tag.

Response

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

Notes

The accounts' ai_creator_label_state and ai_comment_block_state are null on this list; GET /avatars/projects/{projectId} fills them.

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 }.

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. A video_model_based_voice_creation field is accepted and ignored.

Response

201 { avatar_id, project_id, name, gender, image_url, voice_id, voice_sample_url }.

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.

Notes

selected_avatar.instagram_accounts[].ai_creator_label_state is where each account's AI creator label stands: "on", "turning_on" or "turning_off" while the phone is queued or running to change it, "off", "failed" when its newest attempt to turn it on failed, or "turn_off_failed" when its newest attempt to turn it off failed (the label is still on); null for an account on none of the workspace's phones. selected_avatar.instagram_accounts[].instagram_account_type ("personal", "creator" or "business"), instagram_category and instagram_show_category are the account's type, profile category and whether the profile shows it, as GET /devices/accounts/inventory reports them (null when unknown), and selected_avatar.instagram_accounts[].instagram_audience_countries is which countries its audience comes from, as the inventory's instagram_audience_countries ("all", "tier1_tier2", "tier1", "us_only" or "custom"; null when never read), and instagram_audience_countries_status how its latest change stands, as the inventory's. selected_avatar.tiktok_accounts[].ai_comment_block_state is the same for the AI-comment filter: "applied", "queued", "scheduled", "failed" or "off". linked_to_other_avatars, on a TikTok or Instagram account, is true when other avatars are linked to it too: it stays theirs when this avatar unlinks it. renaming_to, on a TikTok account Pamba just created for a purchased seat, is the username picked for it while the rename to that pick is still being applied (usually a few minutes after the signup; null otherwise): until then the account carries the handle TikTok assigned and GET /devices/accounts/inventory leaves it out. selected_avatar.tiktok_accounts and selected_avatar.instagram_accounts list each platform's accounts oldest seat first, accounts with no seat after them (GET /devices/accounts/inventory's slot_rank), so an account a recreation lands takes the place of the one it replaced; tiktok_usernames keeps the order the accounts were linked in.

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

Notes

Its TikTok and Instagram accounts on the workspace's phones stay as unassigned rows of GET /devices/accounts/inventory, which POST /devices/accounts/reassign assigns to another avatar; a TikTok account tracked only for analytics stays in GET /tiktok/tracked-accounts with no 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"?, "use_niche_terms"?, "generate_warming_search_terms"?, "warming_config"?, "warming_searches_enabled"?, "warming_follows_enabled"?, "facebook_cross_post_enabled"?, "warming_niche"?, "use_workspace_niche"?, "accent"? }. avatar_status here is uppercase (ACTIVE | INACTIVE), unlike the GET /avatars filter; a lowercase value is ignored (sent alone, the request is a 400). warming_config is { "search_terms"?: { "terms" } }; one without search_terms changes nothing (sent alone, the request is a 400). Warming is always on; warming_search_terms sets only the browse search terms (an empty array reverts to the workspace's auto niche terms; with use_niche_terms false an empty array is kept, so the avatar's accounts search nothing; use_niche_terms true with an empty array reverts to the niche terms), warming_config.search_terms.terms sets the same terms, warming_searches_enabled false sends the warming sessions out without a search (the terms are kept), warming_follows_enabled false stops them following creators, and warming_niche gives the avatar's accounts a warming niche of their own, one sentence of what they are about, in place of the workspace's niche_description (use_workspace_niche true goes back to the workspace's; not with warming_niche). The niche leaves the search terms as they are; generate_warming_search_terms true replaces them with terms written for the avatar's own niche (the warming_niche sent with it, else the one it already has), which takes a few seconds and counts as one AI assist. All apply to every account the avatar links, accounts other avatars share included; the two switches and the niche also apply to an account linked to the avatar later that no other avatar links. facebook_cross_post_enabled is the avatar's Instagram accounts' Cross-post reels to Facebook switch, written onto each of them and taken by one linked later the same way: false keeps every reel of theirs off their linked Facebook accounts, the ones already scheduled included. Pamba paces warming itself; the pace is not configurable. tiktok_usernames replaces the avatar's TikTok accounts, except that an account Pamba just created whose picked username is still being applied (renaming_to on GET /avatars/projects/{projectId}) stays linked while the rename runs and for an hour after it lands, and the set may name it by its pick; POST /devices/accounts/unlink removes it. accent (up to 60 characters, e.g. "British") is the accent the avatar speaks with on camera: the video model is asked to speak in it whatever the voice, and changing the voice never changes it; videos generated afterwards use it.

Response

200 OK. 400 when accent is longer than 60 characters, when a search term carries a word TikTok and Instagram refuse to search ("porn"; the message names the term), when terms are sent with use_niche_terms true, when warming_niche is sent with use_workspace_niche true, when the niche has no letter or digit or is longer than 1,000 characters, when generate_warming_search_terms is sent with warming_search_terms, use_niche_terms or warming_config search terms, or when it finds no niche of the avatar's own. 429 when the workspace has used its AI assists for the day; 503 when the terms could not be generated, in which case nothing in the request was applied. A warming_config carrying total_minutes_daily_anchor, session_count_daily_anchor or search_terms.search_probability is a plain-text 400 naming the fields to drop and pointing to warming_search_terms, and nothing in the request is applied. 409 { error_code: "account_archived", message } when tiktok_usernames names an archived account: Pamba no longer runs or changes anything on it.

Authentication

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

Request

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

Response

200 OK. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

Notes

The status belongs to the account, not to this avatar's link: it applies for every avatar sharing the account, and pausing cancels each one's pending posts and warming sessions.

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.

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.

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.

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

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.

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.

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.

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.

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 full budget is reserved before acceptance; completed images are charged against that hold and unused credits are released on completion, failure or restart recovery. 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.

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 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, avatar_accent, 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 (a free roster voice), or PREMIUM on a wizard that cloned a premium voice before premium voices were retired (still selectable). latest_job, scenes_job (newest WIZARD_APPEARANCE job) and voices_job (newest legacy WIZARD_VOICES job) are GenerationJob objects (see POST /avatars/generations); read the lane you are waiting on. avatar_accent is the accent the avatar will speak with, read from the appearance prompt and changed with accent on PUT /avatars/projects/{projectId}/selected-avatar (project_id is that projectId); null until the first scene pass has created the avatar. 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.

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. No undo. Credits already spent are not returned.

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). Reserves the full budget before acceptance. A retry of a failed pass with the same prompt reuses its checkpointed profile and kept scenes, reserving only missing scenes at 15 credits each. A changed prompt or a new pass after success generates a fresh profile and replaces the scenes. Allowed at the VOICE step too.

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.

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.

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. Voice options appear on the next GET.

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.

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. A legacy PREMIUM option is kept. Read the new picks with GET /avatars/wizards/{wizardId}.

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 any unselected legacy premium voice, 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, the job tray, and the ready-made pool.

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 }. user_input at most 10,000 characters. A video_model_based_voice_creation field is accepted and ignored.

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); the voice is a free roster voice. The caller's first avatar is free. 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.

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

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.

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.

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 jobs, and legacy PREMIUM_VOICE and BASIC_VOICE jobs: every running one plus unacknowledged finished ones from the last 7 days. Clear one with POST /avatars/generations/{jobId}/acknowledge.

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.

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.

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; 402 { error, code: "connected_account_limit", limit: "5" } when a free workspace already has 5 connected accounts; 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. A free workspace (on its signup credits alone: no plan, account seats or credit purchase) can connect up to 5 accounts; relinking one it already tracks never counts against the limit, and the refusal comes before any TikTok lookup. 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.

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. An account several avatars share moves for all of them: each linked avatar's concepts, items, plan slots and binding, whichever avatar the path names. Safe to call again after a partial earlier run (the new handle may already be on the device row).

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 profile change plus unacknowledged finished ones from the last 7 days, across the avatar's accounts on every platform: profile edits, profile links and Instagram account type changes (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, profile_link, account_type, category, show_category. operations lists only the fields the change asked for, so a failed entry is a real failure. Changes are started from the accounts routes (POST /devices/accounts/profile-edits).

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.

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.

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.

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.

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 image URL, https://storage.googleapis.com/pamba_images/..., for example the result of POST /image/edit).

Response

200 AvatarFrame (the new variation). 400 when image_url is not a Pamba image 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.

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 image URL, https://storage.googleapis.com/pamba_images/...).

Response

200 OK. 400 when image_url is not a Pamba image 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.

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.

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.1" | "nano-banana-2-lite" }. image_url must be a Pamba image URL (https://storage.googleapis.com/pamba_images/...); model defaults to nano-banana-2.1, and the retired nano-banana-2 also runs on nano-banana-2.1.

Response

200 { image_url, model, latency_ms, text_response }. 400 { error } when edit_prompt is blank, image_url is not a Pamba image 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.

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}.

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

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.

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.

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 projects as reviewable drafts or straight to a finished video, control each generation step manually, or skip generation entirely and post a video you made yourself.

Bulk-create projects, as reviewable drafts or straight to a finished video.

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"?, "idea_variation"?, "idea_variation_count"?, "promotional"? (true), "instructions"?, "scene_url"?, "generate_video"? (false), "video_model"?: "gemini-omni" | "seedance" | "grok-imagine-1.5", "keep_raw_voice"? (false), "project_name"?, "notes"?, "reference_video_url"?, "pattern_slug"?, "product_ids"?: UUID[] }] }. "reference_video_url" is a TikTok video link (https://www.tiktok.com/@user/video/id) or an Instagram reel link (https://www.instagram.com/reel/shortcode/); anything else is refused with a 400. A "script" or "idea_description" builds a draft in the background (script, scenes and on-screen text), which the Drafts tab shows as building; with neither the project is created empty. The "script" is carried into the concept word for word. "idea_description" is the alternative: the server writes a distinct script from it per project, numbering the variations when one request carries the same idea several times ("idea_variation"/"idea_variation_count", both or neither, set that numbering yourself when one idea fans out across separate requests). "promotional": false writes the idea's script with no workspace business context (ideas only). "instructions" is free-form guidance for the build (requested on-screen text, tone, structure). "scene_url" pins an avatar library scene image the video keeps as-is (starting-frame generation is skipped); omitted, the build picks the avatar's default scene. "generate_video": true generates and renders the video as soon as the draft is built (needs a script or idea). "video_model" omitted uses the avatar's default model, then currently grok-imagine-1.5 for a script or idea build; a project with neither gets gemini-omni for an API key's request (the Pamba app's own requests draw gemini-omni or grok-imagine-1.5 in a default-model experiment). "pattern_slug" records the content pattern the project belongs to. "product_ids" (up to 3, from GET /products, each with a photo) puts those products in the avatar's hands: the build repaints the chosen scene with them, painted from their photos, and the script names them; it needs a script or idea_description and cannot be combined with "promotional": false (400 either way). The legacy keys is_fully_automated, camera_style, video_text_hook, bullet_list, scheduled_at, video_description, tiktok_username, keep_tiktok_song and only_create_draft are accepted and ignored for one release.

Response

201 { project_ids: UUID[] }. 400 (a plain-text reason) for an empty list, "is_fully_automated": true (removed: create with generate_video, then POST /projects/{projectId}/schedule with automate), idea_variation without idea_variation_count or out of range, an unknown pattern_slug, a reference_video_url that is neither a TikTok video link nor an Instagram reel link, or generate_video with neither script nor idea_description. 402 { error, remaining_credits, required_credits: 0, code: "video_generation_locked" } when any project has generate_video and the workspace is on its free signup credits alone: video generation needs a paid plan, account seats or an admin credit grant, and nothing is created; a draft-only create stays open to the grant.

Notes

The call returns once the project rows exist; the builds run in the background. generate_video spends video credits as its clips generate. Schedule a finished video with POST /projects/{projectId}/schedule. Creates are not deduplicated: a retried request creates the projects again (with generate_video, spending video credits again), so record the returned project_ids, and after a timeout list GET /projects, matching a project_name or notes value you set, before sending it again.

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.",
      "instructions": "Open on a text hook: the ring I found at work",
      "generate_video": true
    }]
  }'

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 }.

List projects.

Authentication

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

Request

Query, all optional: creation_stage and activity_state (each repeatable), user_visibility (visible | archived), with_avatar_status (active | inactive). An unknown value is a 400 naming it.

Response

200 VideoProject[].

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.has_published_post is true once Pamba published a post for the project (completed, not a draft, not deleted), whether or not its live link is matched yet. 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. project.posted_tiktok_video_url and project.posted_instagram_video_url are the live posts once verification matched them, with project.posted_instagram_username the account the reel is on (a reel's link names no account; a reel deleted through Pamba before its check found it keeps the deleted reel's link, which is no longer live); POST /devices/accounts/post-deletions takes either link as a post id. project.posting_accounts is { scope: "avatar" | "workspace", tiktok_usernames, instagram_usernames, instagram_usernames_with_facebook, instagram_usernames_cross_posting_to_facebook, instagram_usernames_below_trial_floor, instagram_usernames_personal }: the accounts a schedule may name, the choices the app's schedule dialog offers. scope avatar lists the avatar's post-enabled accounts (an Instagram account only while it is on its phone); scope workspace, for a project or slideshow with no avatar, lists every postable account on the workspace's own phones, whichever avatar it is linked to (a TikTok account the workspace paused or archived is left out). A listed TikTok account that is not signed in on one of the workspace's phones, or one with an open suspension, is still refused when scheduled. instagram_usernames_with_facebook names the Instagram accounts whose linked Facebook account can post too (share_to_facebook), and instagram_usernames_cross_posting_to_facebook those of them whose Cross-post reels to Facebook switch is on: their reels go to Facebook unless a post sends share_to_facebook false, and only they take share_to_facebook true; instagram_usernames_below_trial_floor names those whose last follower count Pamba read is under the 200 Instagram asks of a trial reel (trial_reel is refused on them; an account Pamba has not read yet is not named), and instagram_usernames_personal those Pamba knows are personal accounts with no switch to creator queued (trial_reel is refused on them too; POST /devices/accounts/profile-edits with instagram_account_type creator queues the switch). Handles carry no @. 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 | motion, 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? (style.font_size sets the size), caption_look?: CaptionLook (present: the motion engine draws the caption in that look instead of the subtitle burn; captions of one voice share one: a track gives every caption the same look unless its voices are styled apart; only in a workspace whose motion_overlays is true on GET /workspaces: elsewhere a save or render carrying a caption_look or a motion item answers 400)
CaptionLook { name?, mode? (phrase | word: one word at a time), font? (a MotionStyle font), weight?, italic?, uppercase?, letter_spacing?, line_height?, color?, stroke_color?, stroke_width? (fraction of the font size), shadow? (none | soft | hard | glow), shadow_color?, fill? (behind the whole phrase), radius?, padding?, active_color? (the spoken word), active_fill? (a box behind it), active_scale? (1..1.4), spoken_color? (words already said), upcoming_opacity? (0..1; 0 reveals words as spoken), enter? (none | fade | pop | slide_up | zoom), enter_duration? }
  adjust:   adjust: { temperature?, tint?, saturation?, brightness?, contrast? } each -100..100 with 0 neutral; lut? ("pamba_v1" is the default grade)
  image, video: src (a Pamba image or video URL; upload any other file first with POST /projects/{projectId}/upload-editor-asset and use the url it returns; a signed media-library URL expires and is refused), 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 (a Pamba audio URL or a bundled /audio/music/... or /audio/sfx/... track; upload any other file first with POST /projects/{projectId}/upload-editor-asset), volume?, fade_in?, fade_out?, media_in?, speed?, voice_effect?, voice_strength? (as on MainTrackEntry)
  motion:   an animated graphic (a scoreboard, a list, a card, rain): x, y, w, aspect (its box: center and width as frame fractions, aspect = box width / height in pixels), label?, opacity?, motion: MotionSpec (required). start and duration follow the beats' anchors; an editor or API change that moves a beat re-derives them
MotionSpec {
  v: 1, name (required, up to 60 chars), full_frame? (covers the frame; the box is ignored), speaker? ({x, y, scale}: a green-screen backdrop, full_frame only, which hides the footage and draws the speaker cut out of it in front, the footage frame scaled by scale (0.25-1.4) and centred at x, y; the render cuts the speaker out of each clip the backdrop spans, once per clip video), until_end? (lasts to the end of the video), cut? (every anchored beat was trimmed away: kept, not drawn)
  beats: [{ key (required, unique), label, at (seconds after the overlay's start), anchor?: { clip_id, t (seconds in that clip's own time), word_index?, word? } }]   up to 48; key "out" is where the overlay leaves
  elements: [MotionElement]   up to 24
}
MotionElement {
  id (required, unique), kind (required): text | shape | image | particles | media, name, x, y, w, h (fractions of the overlay's box, top-left origin), rotation?
  text?, steps?: [{ beat, text }] (the text changes at that beat)   text: up to 400 chars, shrinks to fit its box
  shape?: rect | pill | circle | line | arrow | triangle | star | heart | check | cross | cursor | burst
  src? (a Pamba storage image; for media, a Pamba storage video or image), fit?: cover | contain, prompt? (what the picture shows), asset_id?
  media: src (required), media_in? (the second of the video it starts from); plays in a card its style dresses (fill, radius, padding, border, shadow); show and hide take only none | fade | slide_up | slide_down | slide_left | slide_right | zoom, and no emphasis or loop; elements after it draw over it
  particles?: { preset: rain | snow | confetti | sparkles | bubbles | hearts, density 1-400, speed, angle (degrees off vertical), color?, size? }
  style?: { font?: TikTok Sans | Inter | Montserrat | Oswald | Poppins | Archivo | Bebas Neue | Anton | Instrument Serif | Playfair Display | DM Serif Display | Noto Serif | Caveat | Permanent Marker | Bangers | Roboto Mono, weight? 100-900, italic?, size? (font size as a fraction of the box width), color?, align?: left | center | right, valign?: top | middle | bottom, line_height?, letter_spacing? (em), uppercase?, stroke_color?, stroke_width? (fraction of the font size), shadow?: none | soft | hard | glow, shadow_color?, fill?, radius? (0-0.5), border_color?, border_width?, opacity?, padding? }   colors are #rrggbb or rgba()
  show?: { beat, effect: none | fade | pop | slide_up | slide_down | slide_left | slide_right | zoom | drop | wipe_right | wipe_down | typewriter, duration? }
  hide?: { beat, effect: none | fade | pop | slide_up | slide_down | slide_left | slide_right | zoom | wipe_left, duration? }
  emphasis?: [{ beat, effect: pop | shake | flash | bounce, duration? }], loop?: { effect: pulse | float | wobble | blink | spin | sway, period?, amount? }
}
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?: line (a box per line, what the box presets use) | block (one pill; the read when unset), 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. A text item's line breaks are the \n in its text: the save keeps its wrap_lines in step with them (a text_wrap box keeps the lines the Studio measured), so read the lines from text. Reserved: origin values auto_color and media_scene, asset_id, bullet_index.

Update metadata, states, notes, or reference URL.

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"? }. reference_video_url is a TikTok video link or an Instagram reel link (https://www.instagram.com/reel/shortcode/); anything else is refused with a 400. The legacy keys bullet_list and full_automation_configuration are accepted and ignored for one release.

Response

200 when saved. 400 when reference_video_url is neither a TikTok video link nor an Instagram reel link. 404 when the project is not in the workspace.

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 }.

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 }.

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.

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.

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.

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.

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

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 }. 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when the workspace is past its daily upload limit (see Usage limits under Billing).

Notes

Stages a file before the project or avatar is decided; pass the URL as source_url to POST /projects/{projectId}/edited-video, or as source_video_url to POST /projects/replications to replicate your own 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/..." }

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; 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when the workspace is past its daily upload limit (a source_url attach of an already staged file is not a second upload). A file part with no Content-Type is read as video/mp4; one with any 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 over 1080 pixels on its short side or 1920 on its long side is scaled down to fit, a rotated phone capture is re-encoded upright, anything above 60fps is resampled to 60fps, 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\" }"

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 }] }. starting_frame_url must be one of the project's registered scenes (GET /projects/{projectId}/library-frames lists them); omit it to keep the clip's current frame. To use your own image, upload it first with POST /projects/{projectId}/upload-starting-frame and pass the image_url it returns.

Response

200 generation result; 400 when a starting_frame_url is not a registered scene (nothing is saved); 402 { error, remaining_credits, required_credits }; 409 { error } on the same refusals as POST /projects/{projectId}/generate-clip-videos-async; 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 }] }'

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 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[] }.

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.

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. 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when the workspace is past its daily render limit or has too many renders running at once.

Change one motion overlay by asking for it in words.

Authentication

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

Request

Path { projectId: UUID, itemId: the motion item's id }; JSON body { "request": string (up to 2000 chars, e.g. "make the score red"), "element_id"?: the inner element the change is about, "item"?: OverlayItem (the overlay as you hold it; omitted reads the saved one), "apply"?: boolean (true), "at_seconds"?: number (the timeline second the viewer is paused at, so a request about "here" or "this one" finds its moment; a request about the whole graphic still changes all of it) }.

Response

200 { item: OverlayItem (the rewritten motion item, same id), credits_charged: integer, editor_state_version: integer | null }. With apply the item is saved over the one with its id and editor_state_version is the new version; with apply false it is only returned. 400 when the request is empty or too long or the item is not a drawable motion item. 404 when the project or item is not found. 402 when the workspace has too few credits.

Notes

A model rewrites the overlay against the project's spoken words and draws it on two preview frames first (three with at_seconds, the first at that moment). A picture of the workspace's own logo or media uses the real file (the company profile's logo, the media library), and a real company's logo is fetched from its website; the call takes 10-40 seconds and bills its model cost at the LLM rate (typically 3-8 credits). Render afterwards to see it in the video.

Review and fix a project's motion overlays.

Authentication

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

Request

Path { projectId: UUID }; JSON body { "feedback"?: string (up to 2000 chars), "items"?: [OverlayItem] (the motion items as you hold them; omitted reviews the saved ones), "apply"?: boolean (true) }.

Response

200 { items: [OverlayItem] (every reviewed motion item), notes: [string] (what changed and why; empty when nothing needed fixing), credits_charged: integer, editor_state_version: integer | null }. 400 when the project has no motion overlays. 404 when the project is not found. 402 when the workspace has too few credits.

Notes

A model looks at up to six frames of the video with the overlays drawn and compares them with the reference video the project replicated: unreadable or clipped text, a graphic over the speaker's face, a change on the wrong word, a look unlike the reference. 20-60 seconds; bills its model cost at the LLM rate (typically 10-25 credits).

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 naming the field and its path (an unknown key also lists the fields accepted there).

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 | motion, 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? (style.font_size sets the size), caption_look?: CaptionLook (present: the motion engine draws the caption in that look instead of the subtitle burn; captions of one voice share one: a track gives every caption the same look unless its voices are styled apart; only in a workspace whose motion_overlays is true on GET /workspaces: elsewhere a save or render carrying a caption_look or a motion item answers 400)
CaptionLook { name?, mode? (phrase | word: one word at a time), font? (a MotionStyle font), weight?, italic?, uppercase?, letter_spacing?, line_height?, color?, stroke_color?, stroke_width? (fraction of the font size), shadow? (none | soft | hard | glow), shadow_color?, fill? (behind the whole phrase), radius?, padding?, active_color? (the spoken word), active_fill? (a box behind it), active_scale? (1..1.4), spoken_color? (words already said), upcoming_opacity? (0..1; 0 reveals words as spoken), enter? (none | fade | pop | slide_up | zoom), enter_duration? }
  adjust:   adjust: { temperature?, tint?, saturation?, brightness?, contrast? } each -100..100 with 0 neutral; lut? ("pamba_v1" is the default grade)
  image, video: src (a Pamba image or video URL; upload any other file first with POST /projects/{projectId}/upload-editor-asset and use the url it returns; a signed media-library URL expires and is refused), 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 (a Pamba audio URL or a bundled /audio/music/... or /audio/sfx/... track; upload any other file first with POST /projects/{projectId}/upload-editor-asset), volume?, fade_in?, fade_out?, media_in?, speed?, voice_effect?, voice_strength? (as on MainTrackEntry)
  motion:   an animated graphic (a scoreboard, a list, a card, rain): x, y, w, aspect (its box: center and width as frame fractions, aspect = box width / height in pixels), label?, opacity?, motion: MotionSpec (required). start and duration follow the beats' anchors; an editor or API change that moves a beat re-derives them
MotionSpec {
  v: 1, name (required, up to 60 chars), full_frame? (covers the frame; the box is ignored), speaker? ({x, y, scale}: a green-screen backdrop, full_frame only, which hides the footage and draws the speaker cut out of it in front, the footage frame scaled by scale (0.25-1.4) and centred at x, y; the render cuts the speaker out of each clip the backdrop spans, once per clip video), until_end? (lasts to the end of the video), cut? (every anchored beat was trimmed away: kept, not drawn)
  beats: [{ key (required, unique), label, at (seconds after the overlay's start), anchor?: { clip_id, t (seconds in that clip's own time), word_index?, word? } }]   up to 48; key "out" is where the overlay leaves
  elements: [MotionElement]   up to 24
}
MotionElement {
  id (required, unique), kind (required): text | shape | image | particles | media, name, x, y, w, h (fractions of the overlay's box, top-left origin), rotation?
  text?, steps?: [{ beat, text }] (the text changes at that beat)   text: up to 400 chars, shrinks to fit its box
  shape?: rect | pill | circle | line | arrow | triangle | star | heart | check | cross | cursor | burst
  src? (a Pamba storage image; for media, a Pamba storage video or image), fit?: cover | contain, prompt? (what the picture shows), asset_id?
  media: src (required), media_in? (the second of the video it starts from); plays in a card its style dresses (fill, radius, padding, border, shadow); show and hide take only none | fade | slide_up | slide_down | slide_left | slide_right | zoom, and no emphasis or loop; elements after it draw over it
  particles?: { preset: rain | snow | confetti | sparkles | bubbles | hearts, density 1-400, speed, angle (degrees off vertical), color?, size? }
  style?: { font?: TikTok Sans | Inter | Montserrat | Oswald | Poppins | Archivo | Bebas Neue | Anton | Instrument Serif | Playfair Display | DM Serif Display | Noto Serif | Caveat | Permanent Marker | Bangers | Roboto Mono, weight? 100-900, italic?, size? (font size as a fraction of the box width), color?, align?: left | center | right, valign?: top | middle | bottom, line_height?, letter_spacing? (em), uppercase?, stroke_color?, stroke_width? (fraction of the font size), shadow?: none | soft | hard | glow, shadow_color?, fill?, radius? (0-0.5), border_color?, border_width?, opacity?, padding? }   colors are #rrggbb or rgba()
  show?: { beat, effect: none | fade | pop | slide_up | slide_down | slide_left | slide_right | zoom | drop | wipe_right | wipe_down | typewriter, duration? }
  hide?: { beat, effect: none | fade | pop | slide_up | slide_down | slide_left | slide_right | zoom | wipe_left, duration? }
  emphasis?: [{ beat, effect: pop | shake | flash | bounce, duration? }], loop?: { effect: pulse | float | wobble | blink | spin | sway, period?, amount? }
}
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?: line (a box per line, what the box presets use) | block (one pill; the read when unset), 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. A text item's line breaks are the \n in its text: the save keeps its wrap_lines in step with them (a text_wrap box keeps the lines the Studio measured), so read the lines from text. 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 on an unknown key or a wrong type, naming the field and its path (an unknown key also lists the fields accepted there). 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 }

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 | motion, 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? (style.font_size sets the size), caption_look?: CaptionLook (present: the motion engine draws the caption in that look instead of the subtitle burn; captions of one voice share one: a track gives every caption the same look unless its voices are styled apart; only in a workspace whose motion_overlays is true on GET /workspaces: elsewhere a save or render carrying a caption_look or a motion item answers 400)
CaptionLook { name?, mode? (phrase | word: one word at a time), font? (a MotionStyle font), weight?, italic?, uppercase?, letter_spacing?, line_height?, color?, stroke_color?, stroke_width? (fraction of the font size), shadow? (none | soft | hard | glow), shadow_color?, fill? (behind the whole phrase), radius?, padding?, active_color? (the spoken word), active_fill? (a box behind it), active_scale? (1..1.4), spoken_color? (words already said), upcoming_opacity? (0..1; 0 reveals words as spoken), enter? (none | fade | pop | slide_up | zoom), enter_duration? }
  adjust:   adjust: { temperature?, tint?, saturation?, brightness?, contrast? } each -100..100 with 0 neutral; lut? ("pamba_v1" is the default grade)
  image, video: src (a Pamba image or video URL; upload any other file first with POST /projects/{projectId}/upload-editor-asset and use the url it returns; a signed media-library URL expires and is refused), 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 (a Pamba audio URL or a bundled /audio/music/... or /audio/sfx/... track; upload any other file first with POST /projects/{projectId}/upload-editor-asset), volume?, fade_in?, fade_out?, media_in?, speed?, voice_effect?, voice_strength? (as on MainTrackEntry)
  motion:   an animated graphic (a scoreboard, a list, a card, rain): x, y, w, aspect (its box: center and width as frame fractions, aspect = box width / height in pixels), label?, opacity?, motion: MotionSpec (required). start and duration follow the beats' anchors; an editor or API change that moves a beat re-derives them
MotionSpec {
  v: 1, name (required, up to 60 chars), full_frame? (covers the frame; the box is ignored), speaker? ({x, y, scale}: a green-screen backdrop, full_frame only, which hides the footage and draws the speaker cut out of it in front, the footage frame scaled by scale (0.25-1.4) and centred at x, y; the render cuts the speaker out of each clip the backdrop spans, once per clip video), until_end? (lasts to the end of the video), cut? (every anchored beat was trimmed away: kept, not drawn)
  beats: [{ key (required, unique), label, at (seconds after the overlay's start), anchor?: { clip_id, t (seconds in that clip's own time), word_index?, word? } }]   up to 48; key "out" is where the overlay leaves
  elements: [MotionElement]   up to 24
}
MotionElement {
  id (required, unique), kind (required): text | shape | image | particles | media, name, x, y, w, h (fractions of the overlay's box, top-left origin), rotation?
  text?, steps?: [{ beat, text }] (the text changes at that beat)   text: up to 400 chars, shrinks to fit its box
  shape?: rect | pill | circle | line | arrow | triangle | star | heart | check | cross | cursor | burst
  src? (a Pamba storage image; for media, a Pamba storage video or image), fit?: cover | contain, prompt? (what the picture shows), asset_id?
  media: src (required), media_in? (the second of the video it starts from); plays in a card its style dresses (fill, radius, padding, border, shadow); show and hide take only none | fade | slide_up | slide_down | slide_left | slide_right | zoom, and no emphasis or loop; elements after it draw over it
  particles?: { preset: rain | snow | confetti | sparkles | bubbles | hearts, density 1-400, speed, angle (degrees off vertical), color?, size? }
  style?: { font?: TikTok Sans | Inter | Montserrat | Oswald | Poppins | Archivo | Bebas Neue | Anton | Instrument Serif | Playfair Display | DM Serif Display | Noto Serif | Caveat | Permanent Marker | Bangers | Roboto Mono, weight? 100-900, italic?, size? (font size as a fraction of the box width), color?, align?: left | center | right, valign?: top | middle | bottom, line_height?, letter_spacing? (em), uppercase?, stroke_color?, stroke_width? (fraction of the font size), shadow?: none | soft | hard | glow, shadow_color?, fill?, radius? (0-0.5), border_color?, border_width?, opacity?, padding? }   colors are #rrggbb or rgba()
  show?: { beat, effect: none | fade | pop | slide_up | slide_down | slide_left | slide_right | zoom | drop | wipe_right | wipe_down | typewriter, duration? }
  hide?: { beat, effect: none | fade | pop | slide_up | slide_down | slide_left | slide_right | zoom | wipe_left, duration? }
  emphasis?: [{ beat, effect: pop | shake | flash | bounce, duration? }], loop?: { effect: pulse | float | wobble | blink | spin | sway, period?, amount? }
}
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?: line (a box per line, what the box presets use) | block (one pill; the read when unset), 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. A text item's line breaks are the \n in its text: the save keeps its wrap_lines in step with them (a text_wrap box keeps the lines the Studio measured), so read the lines from text. 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 on an unknown key or a wrong type, naming the field and its path (an unknown key also lists the fields accepted there); 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. 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when the workspace is past its daily render limit or has too many renders running at once; the state save has already landed, so retry at retry_at without resending it (see Usage limits under Billing).

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.

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.

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, "instagram_caption"?: string, "scheduled_at": string, "tiktok_username"?: string, "instagram"?: { "username"?: string, "share_to_facebook"?: boolean, "trial_reel"?: "release_to_followers_never" | "release_to_followers_once_it_performs_well" | null }, "keep_tiktok_song"?, "only_create_draft"?, "ai_label"?: boolean, "first_frame_cover"?: boolean, "video_url"? (a Pamba video URL, such as a staged_video_url from POST /uploads/videos; a link to another host is refused with a 400), "automate"?, "tiktok_pending_account"?, "instagram_pending_account"?, "tiktok_product_id"?, "tiktok_product_link_name"? }. At least one destination is required: tiktok_username, instagram, or a pending-account flag. The legacy keys video_text_hook and bullet_list are accepted and ignored for one release. Multipart variant: the same fields as form items (instagram as instagram_username + share_to_facebook + instagram_trial_reel, where an instagram_trial_reel without instagram_username is a 400 with no error_code; instagram_caption as a form item; ai_label and first_frame_cover as "true" or "false"; instagram_pending_account is JSON-only) plus an optional file part "video" that becomes the posted video.

Response

201 ScheduledPost { id, platform, status, scheduled_at, moved_from?, move_reasons[], cross_posts[] (the other platform's leg), ... }. moved_from (ISO 8601 UTC) is the exact time asked for when a leg was booked at another time (in a later minute), and move_reasons says why, in the order they applied: too_soon (the time had passed or was under a minute away, named first even when another reason made the wait longer), video_to_make (automate), video_finishing (a clip upscaling or a render running), accounts_setting_up (a pending account's creation holds the phone first), phone_busy (the phone is busy until the booked time with another post, a task or maintenance, or Pamba's posting capacity at that time is full; an Instagram leg sharing the phone with its TikTok sibling reads this). A kept time has a null moved_from and an empty move_reasons. Only this response fills them: every other response carrying a ScheduledPost returns them empty. Each leg also carries posting_warning: posting_warning is a warning, never a refusal: the post makes its account's 2nd post that day (the workspace's day, counting its pending, posting and posted posts) while Pamba created the account less than 10 days before the post, or any account's 4th, and is booked all the same. It is { code: "new_account_posts_per_day" | "posts_per_day", message, day (yyyy-MM-dd), posts_that_day (this post included), posts_per_day }, and null (or absent) otherwise. Only the schedule and post-edit responses fill it: every other response carrying a scheduled post returns it null. 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, invalid_trial_reel, instagram_trial_reel_with_facebook, instagram_draft_not_shareable (400); video_generation_locked (402, when automate is true, a clip still needs generating and the workspace is on its free signup credits alone: video generation needs a paid plan, account seats or an admin credit grant); account_not_found (404); tiktok_account_paused, tiktok_account_archived, account_suspended, account_banned (TikTok or Instagram banned the account), account_needs_reconnect (an account the workspace connected itself was signed out of its phone: reconnect it with POST /devices/accounts/imports/{importId}/retry first), instagram_account_paused, instagram_account_off_device, no_linked_facebook_account, facebook_account_paused, facebook_cross_post_off (share_to_facebook true on an account whose Cross-post reels to Facebook switch is off), instagram_trial_reel_ineligible, duplicate_pending_post, duplicate_content (another account of the workspace has this video scheduled, posting or posted on that platform: the same project, or a file with the same bytes; TikTok and Instagram flag the same video on a second account; a draft-only post never counts), draft_limit_reached (an only_create_draft leg's account already has its 10 drafts), schedule_conflict (409). Also 409 while video work is still in flight on the project. Each leg carries ai_label (true when it goes out with the platform's AI label) and first_frame_cover (true when its cover is set to the video's first frame), and each Instagram leg carries instagram_trial_reel (release_to_followers_never, release_to_followers_once_it_performs_well, or null).

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. Every post is public: privacy may be left out or sent as "public", and anything else is 400. 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, for the same account or another platform: a second account on the same platform is refused as 409 duplicate_content. instagram.username may be omitted when exactly one Instagram account qualifies. Omitting share_to_facebook follows the Instagram account's Cross-post reels to Facebook switch (facebook_cross_post_enabled on GET /devices/accounts/warming): the Reel goes to the linked Facebook account while the switch is on and that account is posting, decided again when it posts, so a switch turned off or a Facebook account paused after scheduling stops it. share_to_facebook false keeps this Reel off Facebook; true asks for the share and is refused when it cannot happen (409 facebook_cross_post_off while the switch is off, no_linked_facebook_account, facebook_account_paused). Each Instagram leg carries share_to_facebook (what it will do as of now) and share_to_facebook_choice (what it asked: null follows the switch). Captions: caption goes out on every leg. To give Instagram its own (other wording, other hashtags), send instagram_caption with it: the Instagram Reel posts instagram_caption, its Facebook share carries the same text, and the TikTok post keeps caption. Each is checked against its platform before anything is scheduled (400, a plain-text reason): TikTok at most 4,000 characters, 5 hashtags and 5 @mentions; Instagram at most 2,200 characters and 5 hashtags. instagram_caption without an Instagram destination (instagram or instagram_pending_account) is a 400. Each scheduled leg returns its own caption. AI label: ai_label true labels the video as AI-generated on every leg the request schedules, pending legs included: the phone turns on TikTok's "AI-generated content" switch (More options) and Instagram's "Add AI label" switch on the Reel's share screen (shown as "AI info"). False or absent posts without a label. The phone fails the post before its Post or Share tap rather than publish it without the label, and the post is retried on its own (error_code ai_label_not_set on TikTok, ig_ai_label_not_set on Instagram). Pamba cannot take a label off a post once it is live. A slideshow has no AI label. First-frame cover: first_frame_cover true makes the video's first frame (where its burned-in hook is) the cover on every leg the request schedules, pending legs included, instead of the frame TikTok or Instagram picks a few seconds in: the phone opens the app's Edit cover before its Post or Share tap, moves the cover to the first frame and saves it. False or absent leaves the cover to the platform. The phone fails the post before its Post or Share tap rather than publish it with another cover, and the post is retried on its own (error_code cover_not_set on TikTok, ig_cover_not_set on Instagram). A slideshow has no first-frame cover. Trial reels: trial_reel "release_to_followers_never" or "release_to_followers_once_it_performs_well" shares the Instagram Reel as a trial, which Instagram shows to non-followers first and keeps off the account's profile; release_to_followers_never keeps it a trial that is never shown to the account's followers, release_to_followers_once_it_performs_well lets Instagram share it to everyone if it performs well in its first 72 hours (the Graph API's MANUAL and SS_PERFORMANCE). Absent or null is a regular reel. A trial never goes to Facebook: share_to_facebook true with it is 400 instagram_trial_reel_with_facebook, and a reel that follows the switch skips Facebook while it is a trial. Instagram offers trials to professional (creator or business) accounts with at least 200 followers: an account under 200 is 409 instagram_trial_reel_ineligible, judged by its last follower count Pamba read or, for an account Pamba has not read yet, by its profile read during the request (a read that fails lets the trial through), and so is an account Pamba knows is personal with no switch to creator queued (POST /devices/accounts/profile-edits with instagram_account_type creator queues one, and the trial can be scheduled once it is queued); a personal account Pamba has not read yet fails before Share with nothing posted, as does a trial that reaches the phone before its switch lands. posting_accounts names the accounts known to be under 200 in instagram_usernames_below_trial_floor and the personal ones in instagram_usernames_personal. A slideshow's Instagram leg (a carousel, or a reel built from its slides) has no trial. Pamba confirms a trial from the phone, since Instagram keeps it off the profile its post check reads; a trial whose outcome was lost is failed for a manual check and never sent again on its own: it reads being_fixed from that failure, a retry of the project's other leg leaves it failed, and POST /devices/tiktok/posts/{postId}/retry on the trial itself is refused with 409 post_being_fixed, since it may be live, while Pamba's engineers check it on the phone and settle it. Drafts: only_create_draft true saves the video as a draft in the TikTok or Instagram app on the account's phone instead of publishing it, on every leg the request schedules: the phone prepares the post as usual (caption, sound, AI label, cover) and taps the app's Drafts (TikTok) or Save draft (Instagram) in place of Post or Share, then confirms the draft saved. Nothing goes live: the post completes with only_create_draft true and no post URL, the project's posted_state reads draft_created (posted when another of its posts already went live), and no draft counts as a published post (analytics, the post check, duplicate_content). Pamba never publishes a draft later; it stays in the app on that phone. An Instagram draft is never shared: share_to_facebook true or a trial_reel with it is 400 instagram_draft_not_shareable, and the automatic Facebook cross-post skips it. Each draft takes room on the account's phone, so one account gets at most 10 drafts in its life, whichever workspace booked them: every draft saved, being saved or waiting to be counts, and so does an Instagram one whose outcome was lost; any other cancelled or failed draft does not. A draft past that is 409 draft_limit_reached and nothing is scheduled; schedule a post instead. Drafts waiting for an account still being made (tiktok_pending_account, instagram_pending_account) count against that account. A slideshow has no draft mode. TikTok captions: at most 5 hashtags and 5 @mentions, an @ only to start an @handle (no email addresses or links, no handle run into other letters such as @my-brand), never the posting account's own handle (400). One caption goes to every platform of the schedule, so a handle is posted as written on Instagram too. An @handle posts as a linked mention on TikTok and Instagram: the TikTok phone picks each account from TikTok's mention list as it types the caption, and a handle the list does not offer fails the post before anything is posted (error_code mention_not_offered, never retried on its own: fix the caption with PUT /devices/tiktok/posts/{postId}, then retry). A TikTok caption with #ad, #ads, #sponsored, #partnership or #paidpartnership is posted with TikTok's commercial content disclosure on, as Branded content: TikTok labels the post "Paid partnership", and Pamba cannot take the label off once the post is live. TikTok publishes no post with such a hashtag without it, so the phone fails the post before its Post tap rather than skip it, and the post is retried on its own (error_code commercial_disclosure_not_set). Instagram captions: at most 2,200 characters and 5 hashtags (400; Instagram keeps no more than 5 hashtags on a post). scheduled_at: yyyy-MM-dd lets Pamba pick a free slot that day; ISO 8601 with a timezone offset posts at that time (from a minute out; with automate, or while the video is still being finished (a clip upscaling or a render running), no sooner than 30 minutes after the request) and is 409 schedule_conflict (naming the next free slot) when the phone is busy, and GET /devices/accounts/schedule lists the times it is free; 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 normalized the same way as an /edited-video upload. A multipart file part with no Content-Type is read as video/mp4; one whose Content-Type is anything other than video/mp4, video/quicktime, video/webm or video/x-msvideo (curl sends application/octet-stream unless told ;type=video/mp4) is ignored with no error and the schedule goes ahead without it, so check the post's video after a one-call upload. A pending post follows the project's latest render, including one started by the schedule itself; the project's own video URLs (its edited or stitched video, an earlier render, a file previously uploaded to it) are never accepted as a replacement, so a client that re-sends the edited_video_url it loaded changes nothing and the post is created with video_url null. A later /edited-video upload does not change a pending post; swap it with the new file's video_url on PUT /devices/tiktok/posts/{postId}. TikTok Shop: tiktok_product_id links a product of the TikTok account's shop (an id from GET /devices/tiktok/accounts/{username}/shop) to the TikTok leg, under tiktok_product_link_name (at most 30 letters, numbers, spaces, apostrophes or hyphens; the product's default_link_name when omitted). The phone adds the link before the Post tap and fails the post rather than publish it without the link. A post links from the account's showcase only, so a product not yet in it is queued for adding the moment the post is scheduled, and the post goes out only after the phone can have added it: an exact scheduled_at before that is 400 { error_code: tiktok_product_not_in_showcase_yet, error, earliest_at } (earliest_at is the earliest time the post can take, ISO 8601 UTC), and a date-only scheduled_at gets a slot after it. A product link the post cannot carry is 400 { error_code: invalid_product_id | tiktok_shop_product_not_found | tiktok_shop_not_linked | invalid_product_link_name | tiktok_product_requires_tiktok, error }. tiktok_pending_account and instagram_pending_account queue the post for an account still being created (a seat bought with link_avatar_project_id): the project needs an avatar that has no account on that platform yet (otherwise 400 pending_account_not_allowed, naming the account to schedule to instead), the flag cannot be combined with that platform's destination (tiktok_username, or instagram) or with automate (400), and the post goes out on its own once the created account links to the avatar. automate needs tiktok_username (400 without it). automate generates the project's video from its creative concept before posting, so it is 400 on a project with no concept with clips to generate from (a concept still being built counts): a project whose video was uploaded or supplied has nothing to generate, and scheduling it without automate posts that file as is. A post publishes only if its project is approved (user_review_state approved) at its slot. Scheduling without automate approves the project. Scheduling with automate does not: the video does not exist yet, the pipeline never approves it, and the post fails at its slot with "Project not approved at scheduled time" unless someone approves the rendered video first (PATCH the project's user_review_state, refused while the video is still generating); after such a failure, approve and retry the post. 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"
  }'

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.

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.

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.

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 gemini-omni. A script build with no video_model (POST /projects) currently gets grok-imagine-1.5, so pass it to preview that split.

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.

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.

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.

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 must be one of the project's registered scenes (GET /projects/{projectId}/library-frames lists them; POST /projects/{projectId}/upload-starting-frame adds your own image) and is saved on the clip before generation; one that is not is refused with a 400 and nothing is saved. 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, and the same body with code: "video_generation_locked" while the workspace is on its free signup credits alone: video generation needs a paid plan, account seats or an admin credit grant (analysis, drafts, photos and voices stay open to the grant). 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, while a replication draft has work in flight (the work-in-flight refusal), or when a script or idea draft's latest scene change or line edit could not be applied before the takes (the error says whether to try again); every 409 body carries 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. A script or idea draft's scene change or line edit leaves its place and its clips' video prompts to be rewritten in the background. A generate that arrives before they land applies them first, which adds about 15 to 40 seconds to the call, so no take is shot from words that describe the old scene or line.

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 }, ...] }

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 }, with code: "video_generation_locked" while the workspace is on its free signup credits alone (video generation needs a paid plan, account seats or an admin credit grant); 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.

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. 400 on a malformed project id; 404 when the project is not in the workspace, has no selected concept, or no clip has a video yet; 500 with the fixed body "500: Internal server error" when transcription fails (the cause is logged, never returned).

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.

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.

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.

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.

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, when an image file is empty (0 bytes), 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.

File one of the project's own media (an entry in its editor media pool) into the workspace media library, so any video or slideshow can use it.

Authentication

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

Request

Path { projectId: UUID }; body { url, label? } — url is a src in the project's editor_state media_pool; label is its name in the library (the entry's label, else its file name, when absent).

Response

200 the asset, shaped as in GET /assets, with source_url set to url. 400 for a url not in the project's media pool or a file type the library does not take. 404 when the project is not in the workspace. 409 when the media library is full.

Notes

Filing a file that is already in the library returns the asset it became the first time rather than a copy; an asset's source_url is how to tell. Counts toward the library's cap like an upload.

The songs the video posts with: a TikTok sound and an Instagram song, one per platform.

Authentication

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

Request

Path { projectId: UUID }; no body.

Response

200 { tiktok_sound: { tiktok_music_id, title, author, src, duration_seconds, cover_url, matched_title, matched_author, commerce_right, volume } | null, instagram_sound: { instagram_audio_id, title, artist, src, duration_seconds, cover_url, highlight_start_seconds, explicit, volume } | null } — null for a platform whose post carries the video's own audio only. 400 on a malformed project id; 404 when the project is not in the workspace.

Notes

src is the song's file on Pamba storage (what an editor previews; an Instagram song previews from highlight_start_seconds, where Instagram starts it); volume is the song's level under the video's own audio, 0-1. Neither song is rendered into the video: the phone adds each when the video posts to that platform.

Pick the TikTok sound the video posts with on TikTok, or change its volume.

Authentication

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

Request

Path { projectId: UUID }; { tiktok_music_id, volume? } — a music_id from GET /sounds/hot or /sounds/search; volume 0-1, omitted keeps the current level (a new pick starts at 0.3).

Response

200 { tiktok_sound, instagram_sound } as GET returns them. 400 when tiktok_music_id is not TikTok's numeric id, the sound is no longer available on TikTok, or volume is outside 0-1; 404 when the project is not in the workspace; 503 { message } when TikTok's sound page is not answering.

Notes

The sound is never rendered into the video. When the video posts to TikTok, the phone opens the sound's page, uses it, and sets TikTok's volume panel so the video's own audio stays at 100% and the sound plays under it at volume, once, from the start; a post that cannot attach it fails before publishing and is retried rather than going out without it. A new pick copies the sound's clip and cover onto Pamba storage first, so the call can take a few seconds; picking the same sound again only changes its volume. Scheduled posts pick up the change.

Remove the video's TikTok sound.

Authentication

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

Request

Path { projectId: UUID }; no body.

Response

200 { tiktok_sound: null, instagram_sound }. 400 on a malformed project id; 404 when the project is not in the workspace.

Notes

A TikTok post of the video then carries its own audio only.

Pick the Instagram song the video posts with as a reel, or change its volume.

Authentication

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

Request

Path { projectId: UUID }; { instagram_audio_id, volume? } — an audio_id from GET /sounds/instagram/top or /sounds/instagram/search; volume 0-1, omitted keeps the current level (a new pick starts at 0.3).

Response

200 { tiktok_sound, instagram_sound } as GET returns them. 400 when instagram_audio_id is not Instagram's numeric id, the track is no longer available, or volume is outside 0-1; 404 when the project is not in the workspace; 429 with retry_at when a track the chart or the day's searches did not list needs a lookup past the workspace's search rate; 503 { message } when Instagram's audio page is not answering.

Notes

The song is never rendered into the video. When the reel posts, the phone finds it in Instagram's own music picker, adds it from where Instagram starts it (highlight_start_seconds), and keeps the video's own audio with the song under it. A song Instagram in the US does not have goes out as the same song by the same artist at another length, else as a song Instagram picks (the post's audio_fallback says which, see GET /devices/tiktok/posts/scheduled); a post that cannot attach a song fails before sharing rather than going out with none. A new pick copies the song and its cover onto Pamba storage first; picking the same song again only changes its volume. Scheduled posts pick up the change.

Remove the video's Instagram song.

Authentication

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

Request

Path { projectId: UUID }; no body.

Response

200 { tiktok_sound, instagram_sound: null }. 400 on a malformed project id; 404 when the project is not in the workspace.

Notes

An Instagram reel of the video then carries its own audio only.

Re-time each on-screen text item to the moment its words are 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, duration, item_id }] }: one entry per timed item in play order, start and duration in timeline seconds: a project's bullet items (origin bullet) when it has any, its hook staying at the start, else its non-blank hook items (origin text_hook). duration keeps the item's window shape: an item that was on screen together with the next one (a held row of a stacked list) keeps its end, one that handed over to the next ends where the next now starts, the last keeps its end, an item whose new start passes the end it would keep keeps its length, and none is shorter than 0.5. An empty list when there is nothing to time (no bullets and fewer than two hook items); 404 when the project is not in the workspace; 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when the workspace is past its daily AI-assist limit (the timing detection is an AI assist; see Usage limits under Billing).

Notes

Timing is semantic, so an item may paraphrase the speech. index is the item's place in play order and item_id names the item each entry is for. Nothing is written: apply start and duration 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 entry keeps its item's start and carries no duration. Blocks for the transcription and one model call.

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. 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when the workspace is past its daily AI-assist limit (see Usage limits under Billing).

Notes

One model call, a few seconds. The result is at most 300 characters with exactly 5 hashtags and no @mention (the accounts a post mentions are yours to add), and passes the same checks as a schedule caption. It is returned, not saved: send it as caption on POST /projects/{projectId}/schedule.

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. 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when a render would start but the workspace is past its daily render limit or has too many renders running at once (nothing is rendered; the platform's own commit-point renders are never refused). 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.

Start replicating a reference video into a new project, starring an avatar or a cast designed for it. Returns the build job.

Authentication

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

Request

{ selected_avatar_project_id?, tiktok_url?, instagram_url?, source_video_url?, source_project_id?, video_model?, max_duration_seconds?, variation_instructions?, promotional? (default true), draft_only? (default false), product_ids?: UUID[] }. A source is required: tiktok_url (share links resolve), instagram_url (an Instagram reel link in any pasted form: /reel/, /reels/, /p/, with the account in the path or a share query; at most one of tiktok_url and instagram_url), source_video_url (a Pamba storage URL: a content hub or workspace video, a video in the workspace's media library, or your own file staged with POST /uploads/videos) or source_project_id (one of the workspace's projects with a finished video). With a storage source, a tiktok_url or instagram_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. product_ids (up to 3, from GET /products, each with a photo) are the workspace products the replica features: where the reference holds or shows the product it pitches, the replica shows these, painted from their photos; a replica with products is promotional, so promotional: false with product_ids answers 400. In a workspace whose replications draw motion graphics (motion_overlays on GET /workspaces), the graphics follow how the reference draws and animates its own; ask for simpler or more impressive ones in variation_instructions. similarity, acknowledge_low_feasibility and enhanced_motion 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, and with code: "video_generation_locked" and required_credits 0 when draft_only is false and the workspace is on its free signup credits alone (video generation needs a paid plan, account seats or an admin credit grant; a draft_only build stays open to the grant); 404 when the avatar is not in the workspace.

Notes

Without selected_avatar_project_id every person of the replica is designed for it in the reference's look, the lead included; with it the avatar plays the lead and every other person who speaks or recurs is designed. Creates the project (with the avatar when one is chosen, the video_model, and the TikTok link or the reel's canonical link https://www.instagram.com/reel/shortcode/ 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 currently grok-imagine-1.5 when the replica's avatar speaks a line on camera, and otherwise gemini-omni for an API key's request (the Pamba app's own requests draw it in a default-model experiment); a replica with people besides the avatar, or with no avatar, defaults to gemini-omni, the one model that takes each person's reference image. A TikTok URL is also captured into the workspace's idea library; an Instagram reel is not. The build downloads a reel itself, so a picture or slideshow post, or one Instagram does not show signed out, fails the job with the reason (read the link first with GET /projects/replications/instagram-source).

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", ... }

Read a pasted Instagram link before replicating it: whether it is a reel, and its author, caption, length and cover.

Authentication

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

Request

Query { url: an Instagram reel or post link in any pasted form (/reel/, /reels/, /p/, with the account in the path or a share query) }. Nothing is stored.

Response

200 { instagram_url, author_username, caption, cover_url, duration_seconds, view_count, like_count }: instagram_url is the reel's canonical link (pass it as instagram_url to POST /projects/replications); cover_url is Instagram's own signed link, which expires, for display only; any field but instagram_url may be null. 400 when url is missing or holds no Instagram post link; 404 when Instagram shows no such post signed out (deleted or private); 422 when the post is a picture or a slideshow; 502 when Instagram could not be read, try again.

Example

curl -G https://api.pamba.app/projects/replications/instagram-source \
  -H "X-API-Key: $PAMBA_API_KEY" \
  --data-urlencode "url=https://www.instagram.com/reel/DPxample123/?igsh=abc"
# -> 200 { "instagram_url": "https://www.instagram.com/reel/DPxample123/", "author_username": "creator", "duration_seconds": 14.9, ... }

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, scene_pending }. scene_pending is true while a built script or idea draft's scene change is still rewriting the draft's place and video prompts in the background (PUT /projects/{projectId}/replication/scene). 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.

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, and with code: "video_generation_locked" and required_credits 0 while the workspace is on its free signup credits alone (video generation needs a paid plan, account seats or an admin credit grant); 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, and, for a campaign script's draft, when the script's account cannot post for its avatar, the script was cut or sent on a moment ago, or its post could not be scheduled.

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. A campaign script's draft (the script's pushed_project_id) generates as the script's approval: the approval is recorded on the script as the caller, an automated script's post is scheduled on its plan day, and the generation is billed to the caller. A 409 after the approval was recorded (the post would not schedule, another request claimed the draft first) leaves the script approved and its draft as it was, for the next call.

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.

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.

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), and removed text drops trailing clips. On a draft built on a library scene (a script or idea draft's picked or default scene) with no look canon, and on a plain script draft with no replication job, they take that scene's library frames at once, free; otherwise they arrive with starting_frame_url null and their frames generate in the background (frames_generating). Blank segments are refused so an autosave cannot delete scenes.

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.

Move a script or idea draft onto another scene of its avatar's library; every clip's frame re-deals from it.

Authentication

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

Request

Path { projectId: UUID }; { scene_url }: the image url of a scene in the draft avatar's library (GET /avatars/projects/{projectId}/selected-avatar/starting-frames lists them, called with the avatar's project id); a variation's url picks its scene. At most 4000 characters.

Response

200 { status: "ok", scene_url, clips: [{ clip_id, frame_url }] }: the scene the draft now uses and every clip's new starting frame, in play order. 400 when scene_url is blank or over the cap; 404 when the project is not in the workspace or the url is not a scene of its avatar; 409 when the draft has no avatar or no clips, already generated, its build has not finished (queued, building, being revised or failed), it is still being worked on, or it is a replication draft (its frames follow its reference), or another edit landed meanwhile; 429 past the workspace's daily AI-assist cap.

Notes

The scene's frames (the scene and its active pose variations) are spread across the clips with no two neighbours alike, the way the editor's scene-for-all-clips spreads them, and the same scene lands the same frames each time. The response returns once the frames land. On a built draft the words that describe the place (the setting, outfit, summary, scene labels, scene visuals and camera beats) and every clip's video prompt are then rewritten for the new scene in the background, usually within 15 to 40 seconds, so the video says what its frames show; the script, timing and on-screen text stay as they are. Changes made before a rewrite lands share it, and it rewrites for the last scene picked. A generate that arrives first applies the rewrite before any take is shot: the draft's Generate (POST /projects/{projectId}/replication/generate) inside its job, the clip-generate routes before they answer. Existing images only and no generation, so no charge; each rewrite counts as one AI assist. The draft remembers the scene, so a later rebuild deals from it.

Replace the draft's on-screen text plan: the hook and every timed text entry.

Authentication

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

Request

Path { projectId: UUID }; { overlays: [{ text, start_seconds, end_seconds, position: "top" | "center" | "bottom" }] }, at most 8 entries of at most 200 characters each; an empty list clears the plan.

Response

202 { status: "started", message }. 400 on a malformed project id or a position outside top/center/bottom; 404 when the project has no draft with an on-screen text plan (a plain script draft edits its text from the editor); 409 when an entry is blank, over the entry or character caps, or its end does not come after its start, or when the draft is still being worked on, already generated, or another edit is landing.

Notes

A whole-plan PUT: the request replaces every text entry (entries are ordered by start time) and the draft's editor timeline re-syncs to the plan, so the render burns exactly these words. Overlapping windows accumulate on screen — a persistent hook plus rows revealed at their spoken moments, each ending at the video's end, is the classic stacked list. No generation, no charge.

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.

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.

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.

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.

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.

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", source_url (the url the file was filed from: a captured site's media, a slideshow picture, an agent upload; null for a computer upload), 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).

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"

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.

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

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.

Products

The workspace's physical products an avatar holds, stacks or uses on camera. Pass up to 3 product ids, each of a product with a photo, as product_ids when creating a video or a replication, and the video features them, painted from their photos.

List the workspace's products.

Authentication

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

Request

No body.

Response

200 Product[]; a product has id, name, brand, spoken_name, appearance, usage, offer, image_urls, source (manual | website), source_url, created_at, updated_at.

Add a product from its name and photo links.

Authentication

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

Request

JSON body { "name": string, "image_urls": string[] (public links, up to 8), "brand"?, "spoken_name"?, "appearance"?, "usage"?, "offer"? }.

Response

201 Product. 400 { error } on a blank name, a full workspace (100 products), or when none of the photos could be read.

Notes

Photos are copied to Pamba storage as JPEGs. Without appearance, a one-sentence description of the packaging is written from the photos shortly after the response; it counts as one of the workspace's daily AI assists, and past that limit the product keeps no description.

Import the products of the brand's online store.

Authentication

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

Request

JSON body { "website_url": string }.

Response

200 { created: Product[], already_imported: number }. 400 { error } when the store's catalog cannot be read.

Notes

Reads Shopify stores (their /products.json catalog): name, brand and up to 4 photos per product, at most 40 products per call. Re-running skips products already imported, so a large catalog imports over several calls.

Get one product.

Authentication

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

Request

Path { productId: UUID }; no body.

Response

200 Product. 400 on a malformed id; 404 when the product is not in the workspace.

Change a product's words or reorder and remove its photos.

Authentication

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

Request

Path { productId: UUID }; JSON body with any of { name, brand, spoken_name, appearance, usage, offer, image_urls }. An empty string clears an optional field; image_urls may only reorder or drop the product's own photos.

Response

200 Product. 400 { error } on a blank name, a photo the product does not hold, or a photo listed twice; 404 when the product is not in the workspace.

Remove a product.

Authentication

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

Request

Path { productId: UUID }; no body.

Response

200 { deleted: productId }. 404 when the product is not in the workspace.

Notes

Videos and scenes already made with the product keep their frames.

Upload one photo to a product.

Authentication

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

Request

Path { productId: UUID }; multipart/form-data with one image as the "file" part (up to 25 MB).

Response

201 Product. 400 { error } when the file is not an image or the product already has 8 photos; 404 when the product is not in the workspace; 413 past 25 MB.

Generate scenes of an avatar with products in shot.

Authentication

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

Request

Path { projectId: UUID }; JSON body { "product_ids": UUID[] (1-3), "pose"?: held | stacked | on_surface | product_only, "prompt"?: string, "base_scene_url"?: string, "count"?: 1-4, "camera_style"?: string, "tags"?: string[] }.

Response

200 AvatarFrame[] (the scenes that passed, active, tagged product:<id>; empty when no candidate passed). 400 { error } on an unknown pose, a count outside 1-4, more than 3 products, a product that is not the workspace's or has no photo, or a base_scene_url that is not one of this avatar's scenes; 402 { error, remaining_credits, required_credits } on insufficient credits; 404 when the avatar is missing or has no image.

Notes

15 credits per stored scene; a candidate that fails the checks is free. base_scene_url keeps an existing scene's person, place and framing and changes only the product. A candidate is kept only when the avatar still matches and the product matches its photos (same container, colors and wordmark).

Scheduling and Warming

Operate TikTok and Instagram post queues and pre-post account warming. A post from an account whose seat was cancelled and whose paid period has ended is refused as tiktok_account_paused or instagram_account_paused, a post from an archived account as tiktok_account_archived or instagram_account_paused, and no warming is booked for either. A post from an account the platform took out of use is refused as account_banned or account_suspended, and its pending posts fail with that code.

List every account Pamba manages on the workspace's phones, and its archived accounts: the rows not archived are the 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", frozen: boolean (an archived account Pamba runs and changes nothing on, history only: a request that would run or change anything on it is refused, with 409 account_archived while one of the workspace's phones or seats holds it; false for an archived spare an avatar's list dropped and kept on its phone for reuse, which a link brings back), health_flags: ("BANNED" | "SHADOWBAN_RISK" | "SUSPENDED" | "IDENTITY_VERIFICATION" | "ACCOUNT_WARNING" | "VIDEO_REMOVED" | "VIDEO_NOT_RECOMMENDED")[], avatar_project_id, avatars: [{ avatar_project_id, name, image_url }], on_device, seat_status? (active | pending_cancel), seat_monthly_cents?, seat_bundle_paired?, seat_cancel_effective_at_epoch?, seat_renewal_declined? (true while the seat's last renewal was declined and is still owed, past due or unpaid; POST /billing/card-update-checkout takes a new one), facebook_addon_eligible?, facebook_addon_refusal?, facebook_addon_phone_group?, linked_facebook_username?, linked_instagram_username?, profile_username?, picture_synced?, linked_instagram_profile_picture_url?, name_locked_until?, facebook_picture_follower? { username, display_name, profile_picture_url }, picked_username_not_applied?, instagram_restrictions?, bio_link_warning_until_epoch?, profile_link?, profile_link_block?, recreate_refusal_code?, recreate_cooldown_until_epoch?, replacement_offer? { reason: "suspended" | "identity_verification" | "banned", since_epoch }, position?, ai_creator_label_state?, ai_comment_block_state?, instagram_account_type?, instagram_category?, instagram_show_category?, instagram_audience_countries?, instagram_audience_countries_status?, facebook_cross_post_enabled? (Instagram rows: the account's Cross-post reels to Facebook switch, PUT /devices/accounts/warming), max_posts_per_day?, planned_by_campaign, views_last_7_days, seat_views_last_7_days, slot_rank?, seat_restorable_until_epoch?, seat_restore_offer_id?, recreation?, ... }], avatars, seat_restore_offers: [{ offer_id, accounts: [{ platform, username }], restorable_until_epoch, payment_failed }], recreations: [{ platform, replaced_username, requested_username, avatar_project_id, status, queued_at_epoch, place? { status, health_flags, views_last_7_days, seat_views_last_7_days, slot_rank?, position? } }] }.

Notes

Every account Pamba manages on the workspace's phones (not the other accounts an import's sign-in brings along; see POST /devices/accounts/imports). The rows not archived are the valid destinations for POST /projects/{projectId}/schedule. The workspace's old accounts are listed too, last: an archived account it held (one of its seats named the account, or it posted from it) stays on the list after it leaves its phones, such as an account a recreation replaced, with status archived, frozen true and on_device false and no replacement_offer. A Facebook row reads status archived and frozen true while the Instagram account it follows is archived. A TikTok account Pamba created for a purchased seat is listed once it carries the username picked for it: TikTok assigns its own handle at signup and the pick is applied as a rename, usually within a few minutes, while GET /devices/accounts/seat-options shows the seat as still being set up (stage applying_username). One row per account: avatars lists every avatar that links it, oldest link first (a TikTok or Instagram account can be linked to several, and it is still one account with one status; a Facebook row lists its own avatar, then its Instagram account's), and avatar_project_id names the first; both are empty for an unassigned account (a Facebook one is listed when neither it nor its Instagram account has an avatar, and follows its Instagram account: assign that one). A Facebook account rides the Instagram account it is linked to (its row's linked_instagram_username): it is listed wherever that Instagram account is, avatar-linked or not, with that account's on_device. on_device is true when the account is signed in on one of the workspace's phones; the phone itself is never identified. picked_username_not_applied is the username picked for a purchased account that could not be put on it: the account kept the handle it was created under, and PATCH /devices/tiktok/accounts/{username}/profile or PATCH /devices/instagram/accounts/{username}/profile renames it. It is present for 30 days after the attempt, or until the account is renamed. instagram_restrictions lists, on an Instagram row, each feature Instagram restricted on the account as [{ incident_id, restriction, added_on?, ends_on? }] (restriction is Instagram's wording, such as "You can't share links"; dates are ISO days): the account keeps posting and warming, and each one stays on the row through its end date and leaves it the day after (one Instagram gave no end date stays until closed by hand). bio_link_warning_until_epoch is set on an Instagram account Pamba created less than 7 days ago: until that time a link in its bio draws the warning PATCH /devices/instagram/accounts/{username}/profile returns. profile_link, on a TikTok or Instagram row, is the website link the profile shows: the newer of the one set through POST /devices/accounts/profile-edits and the one read off the phone (every linked account's profile is read once a day), "" when it has none, absent when neither is known. profile_link_block says why new_link would be refused today ({ code: "link_account_too_new" | "link_needs_followers", message, available_from_epoch? }). position is the account's place in the order saved with PUT /devices/accounts/order, 1 at the top and counted over the accounts listed; it is null for an account with no place. Rows are not returned in that order. views_last_7_days is the account's own views over the last 7 days, and seat_views_last_7_days its seat's: its own plus those of the accounts its seat held before it and handed on within those days (a recreation), so an account that replaced another ranks where that account did in the default order (open health flags first, then the most viewed); it equals views_last_7_days for every other account. slot_rank is the account's seat's place among the workspace's live seats, oldest first by when each first took an account (1 for the oldest), which every account that holds the seat in turn shares (absent for an account with no seat): among equal seat views the default order puts the lower slot_rank first. It is a tie-break, not an identifier: the ranks after a seat that ends move up by one. recreations lists the recreations in flight (POST /devices/accounts/recreate): the replaced account is left out of accounts meanwhile, and place carries the order facts its row had (status, health_flags, views_last_7_days, seat_views_last_7_days, slot_rank, position), the place the Accounts page shows the new account being created in; its position is counted as the accounts' are and names the account it stands just above (one past the last placed account's when none follows it), absent when the replaced account had no place. Once the new account exists but still waits on its picked username (a TikTok account carries TikTok's own name for a few minutes), place carries that account's facts instead, which hold the same seat and place by then; place is absent when neither account is on the inventory. ai_creator_label_state, on an Instagram row, is where its AI creator label stands: "on", "turning_on" or "turning_off" while a PATCH /devices/instagram/accounts/{username}/ai-creator job is queued or running on the phone, "off", "failed" when its newest attempt to turn it on failed, or "turn_off_failed" when its newest attempt to turn it off failed (the label is still on). ai_comment_block_state, on a TikTok row, is where its AI-comment filter stands: "applied", "queued", "scheduled", "failed" or "off". Both are null for an account on none of the workspace's phones. instagram_account_type, on an Instagram row, is "personal", "creator" or "business": the newer of the type set through POST /devices/accounts/profile-edits and the one the hourly analytics refresh read off the public profile, null when neither is known; instagram_category is a professional account's profile category and instagram_show_category whether the profile shows it (both null on a personal account or when unknown). instagram_audience_countries, on an Instagram row, is which countries the account's audience comes from, the choice Pamba's record of its Minimum age setting matches (the record is kept country by country as the phone confirms each change): "all", "tier1_tier2", "tier1", "us_only", or "custom" for a setting that matches none of them (set by hand in Instagram, or a change that stopped partway); absent when Pamba never read it or the account is personal. instagram_audience_countries_status says how the latest change stands: { target (the choice it made or is making), changes_planned (the changes its opening read found to make: each country to set or remove, and the default), changes_remaining (0 once the setting is the choice), unconfirmed_change? { change: "default" | "add" | "age" | "remove", country, age } (a change the phone tapped and did not see land), out_of_date (a change's answer was lost: the setting may differ from the record until the next change reads it), read_at, updated_at }, absent when never read (Pamba reads the setting when it changes it: POST /devices/accounts/profile-edits with instagram_audience_countries). seat_restore_offers lists the workspace's seat subscriptions that ended in the last 3 days (cancelled, or Stripe gave up on a failed payment) with accounts still signed in on their phone: those accounts read track_only and carry seat_restorable_until_epoch and seat_restore_offer_id, and POST /devices/accounts/seat-restore-checkout brings them back until then. payment_failed is true when Stripe ended the subscription because its renewal payment didn't go through; Pamba then voids that subscription's unpaid invoices, so the old bill can't be paid for nothing. max_posts_per_day, on a TikTok row, is the posts-per-day limit set with PUT /content/strategy/account-caps (null while the account runs on the default GET /content/strategy/account-caps names), and planned_by_campaign is true while an enabled automation campaign plans the account (false on every Instagram and Facebook row). The limit caps what the planner schedules (an enabled campaign's cycles, and a plan made with POST /content/batches/plan-now), never a post scheduled directly; the Accounts page offers it only where planned_by_campaign is true. seat_monthly_cents is what the seat adds to the bill and what cancelling it alone saves ($50 for the bundled half of a TikTok and Instagram pair, $100 otherwise, a Facebook add-on included); seat_bundle_paired is true when cancelling it breaks a pair (never on a Facebook row). facebook_addon_eligible, on an Instagram row, is true when a Facebook add-on can be bought for the account now (facebook_for_instagram on POST /devices/accounts/seat-checkout); when false, facebook_addon_refusal says why: "not_pamba_created" (only an Instagram account Pamba created on a seat takes it, never one you connected), "has_facebook", "facebook_pending" (one is already being set up), "seat_not_active" (its seat is cancelled), "not_on_phone", "account_unavailable" (banned or suspended), "account_archived", "not_offered" (not available to this workspace yet), "phone_has_facebook" (each phone takes one Facebook account for now, and its phone has one) or "phone_number_unavailable". facebook_addon_phone_group is set while each phone takes one, on a row that can take it: rows with the same value share a phone, so buy for one of them. linked_facebook_username, on an Instagram row, names its Facebook account; linked_instagram_username, on a Facebook row, names the Instagram account it belongs to, and profile_username the username set on its Facebook profile (null until Facebook took one). A Facebook row's picture_synced is true once it shares one picture with that Instagram account (changing either's changes both; its profile_picture_url is then the Instagram account's, as is linked_instagram_profile_picture_url), and name_locked_until (UTC ISO-8601) is when Facebook next lets its name change, while that is in the future. An Instagram row's facebook_picture_follower names the Facebook account that shares its picture. A Facebook row's professional_mode is true once Pamba read its profile in professional mode (null while it never read it), and its facebook_page is the Facebook Page it posts as, { name, category, bio, picture_url }, null while it posts as its profile (set one up with facebook_page on POST /devices/accounts/profile-edits); bio and picture_url are the Page's bio and picture as Pamba last set them (new_bio and new_profile_picture_url on that route, for an account that posts as a Page), each null while Pamba set none, and a pending_profile_edit carries the ones still on their way as new_bio and new_profile_picture_url. A row with a facebook_page reads picture_synced false, since its profile no longer shares the Instagram account's picture. An Instagram row's cross_posts_to_facebook_page is the name of the Page its reels cross-post to, null while they go to the Facebook profile or it has no Facebook account. A Facebook row carries the seat fields of its own add-on seat (seat_monthly_cents 10000).

Save the order the workspace's Accounts list is shown in.

Authentication

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

Request

{ accounts: [{ platform: "tiktok" | "instagram" | "facebook", username }] }, top first. An empty list clears the saved order. An account named more than once keeps its first place (the inventory lists an account once per avatar that tracks it).

Response

200 { accounts: [{ platform, username, position }] }, the saved order with handles lowercase and without @ and each account once. 400 { error_code, error, accounts } with error_code invalid_platform, invalid_username, unknown_account (an account that is not among the workspace's accounts, for example one renamed or removed since the caller listed it) or too_many_accounts (more than 1,000 entries); accounts names the offending entries as platform:username, as sent for invalid_platform and invalid_username (empty for too_many_accounts). Nothing is saved on a 400.

Notes

One order for the whole workspace, which replaces the previous one. Accounts it leaves out, and accounts added later, follow the placed ones in the default order: accounts with open health flags first, then the most viewed in the last 7 days (an account that replaced another counts the views its seat earned in those days, seat_views_last_7_days). Under a saved order, archived accounts are listed last whatever their place. A renamed account keeps its place, and a recreated account takes the place of the account it replaced; an order saved while the recreation runs, when the replaced account is off the inventory, keeps it right after the account it followed. An empty list clears every place. GET /devices/accounts/inventory reports each account's place as position.

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.

Notes

A slideshow post (a TikTok photo post or an Instagram carousel) has a null project_id and names its slideshow in slideshow_id and slideshow_title; its thumbnail_url is the slideshow's slide 1. Both slideshow fields are null on a video post. as_reel is true on a slideshow's Instagram post that goes out as one reel built from its slides (POST /slideshows/{slideshowId}/schedule with as_reel), false on every other post. A failed post carries error_code beside error_message (null on every other status): ig_audio_not_found means Instagram in the US does not have the post's song and the phone could not fall back to another: set another song (PATCH /slideshows/{slideshowId} instagram_sound, or PUT /projects/{projectId}/sounds/instagram for a video), then POST /devices/tiktok/posts/{postId}/retry; a plain retry tries a fallback song and may fail the same way. ig_caption_flagged means Instagram said after the Share tap that the caption looks similar to others that have been reported, and nothing was posted: change the caption (PUT /devices/tiktok/posts/{postId}), then retry. phone_media_failed means the post's video or pictures could not be made into the copy a phone receives (a phone posts only files Pamba writes, never an uploaded file as it is), and nothing reached the phone: Pamba retries it like any failure before the phone. instagram_song_unavailable is true on a pending or failed Instagram post whose song a Pamba phone did not find on an earlier post from an account Pamba knows is not a Business account (a Business account's picker lists only commercial music): it goes out with another version of the song, or a song Instagram picks, unless another song is set first the same way. audio_fallback { reason: other_version | instagram_pick, title, artist, duration_seconds, explicit, requested_title, requested_artist } names the song a completed Instagram post went out with in place of one Instagram in the US does not have, null on every other post. being_fixed is true on a failed post Pamba's engineers are fixing: POST /devices/tiktok/posts/{postId}/retry refuses it (409 post_being_fixed) and nothing is needed from the caller. A pending post whose account the platform took out of use (TikTok or Instagram banned it, its BANNED health flag; Meta suspended it or Instagram asks it for identity verification, its SUSPENDED or IDENTITY_VERIFICATION flag) fails with error_code account_banned or account_suspended and an error_message naming the account. awaiting_replacement is true on an older pending post that waits for its account's replacement: recreating the account (POST /devices/accounts/recreate) moves it to the new account, or cancels it with move_scheduled_posts false; false on every other post.

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. completed_at (UTC ISO) is the actual publication time once status is completed; status completed is the phone's word that it posted; post_verification_status says whether Pamba then found the post on the account's public profile. It reads pending while the checks run (20 minutes after posting, then an hour, another hour and a day later), then verified when the published item is frame-matched on the profile (the verified permalink then fills posted_tiktok_video_url on TikTok legs or posted_instagram_video_url on Instagram legs; an Instagram carousel, and an Instagram reel posted before 2026-10-03, read verified on the phone's word alone until the hourly analytics read matches a post on the profile to its posting time and fills its link (a time match, not a frame match); the check runs once, so verified says the post was found, not that it is still up), video_not_found when no new post appeared on the profile, frame_mismatch when a new post appeared but does not match this one, or error when the check could not finish. status stays completed whatever the check finds, and post_verification_message says it in one plain sentence. The Facebook copy of an Instagram post (share_to_facebook) is not checked. An Instagram reel's share_to_facebook says whether it goes to the linked Facebook account: predicted while it waits and decided when it posts, from share_to_facebook_choice (what the post asked: null follows the account's Cross-post reels to Facebook switch, false opts out, true asks for it) and the account's switch and Facebook account. A TikTok video posted as Only you (before every post became public) never shows on the public profile the check reads, so it is not checked: its post_verification_status stays null (an Only-you photo post reads verified on the phone's word). A TikTok or Instagram post deleted through Pamba (POST /devices/accounts/post-deletions) before the check found it is not checked further either, once the deletion is confirmed: its post_verification_status goes from pending to null and post_verification_message is cleared. Pamba knows it by the video id the phone read as it posted, or, for a post without one (an Instagram reel, a TikTok photo post), by the deleted post of its kind that went up while it was being posted, with its caption; such a reel keeps the deleted reel's link in posted_instagram_video_url, which is no longer live. A post without a caption, or one whose deletion is still being confirmed at the last check, is checked as usual and can read video_not_found or error. An Instagram trial reel (instagram_trial_reel release_to_followers_never or release_to_followers_once_it_performs_well) reads verified on the phone's word, and its posted_instagram_video_url fills once the hourly analytics read finds a reel on the account carrying the trial's own caption, posted while its post ran on the phone; until then it stays null. A trial the phone did not confirm was likely shared to everyone, so the post is recorded as a regular reel (instagram_trial_reel turns null) and checked on the profile like one. A request sets the trial as trial_reel (instagram.trial_reel and multipart instagram_trial_reel on POST /projects/{projectId}/schedule); the post reports it as instagram_trial_reel. ai_label is true for a video post that goes out with the platform's AI label (TikTok's "AI-generated content", Instagram's "AI info"), false otherwise and on every slideshow post. first_frame_cover is true for a video post whose cover is set to the video's first frame, false when the platform picks the cover and on every slideshow post.

Edit timing, caption, 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"?, "keep_tiktok_song"?, "only_create_draft"?, "video_url"?, "tiktok_username"?, "share_to_facebook"?, "ai_label"?, "first_frame_cover"?, "trial_reel"? ("release_to_followers_never" | "release_to_followers_once_it_performs_well" | null, where null takes the trial off; Instagram reels only), "tiktok_product_id"?, "tiktok_product_link_name"?, "remove_tiktok_product"? }.

Response

200 ScheduledPost after a field update (with tiktok_product_id and tiktok_product_link_name, and posting_warning when the update moved a pending post to another time or account, judged on that post alone and not on the Instagram cross-posts a TikTok reschedule moves along: posting_warning is a warning, never a refusal: the post makes its account's 2nd post that day (the workspace's day, counting its pending, posting and posted posts) while Pamba created the account less than 10 days before the post, or any account's 4th, and is booked all the same. It is { code: "new_account_posts_per_day" | "posts_per_day", message, day (yyyy-MM-dd), posts_that_day (this post included), posts_per_day }, and null (or absent) otherwise. Only the schedule and post-edit responses fill it: every other response carrying a scheduled post returns it null.); 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), privacy is sent as anything but "public" (every post is public), the caption breaks its platform's caption rules (a TikTok caption answers in plain text, as does a TikTok post whose kept caption mentions the account tiktok_username moves it to; an Instagram caption holds at most 2,200 characters and 5 hashtags, and answers { error }), or video_url is not a Pamba video URL, or ai_label or first_frame_cover is true on a slideshow (photo) post or a Facebook post, or only_create_draft is true on a Facebook post (a draft is saved in TikTok or Instagram); 400 { error, error_code: invalid_product_id | tiktok_shop_product_not_found | tiktok_shop_not_linked | invalid_product_link_name | tiktok_product_requires_tiktok | tiktok_product_requires_video } for a product link the post cannot carry, and 400 { error, error_code: "tiktok_product_not_in_showcase_yet", earliest_at } for a pending post whose time comes before its product can be in the showcase, with nothing of the update written; 400 { error, error_code: invalid_trial_reel | instagram_trial_reel_with_facebook | instagram_trial_reel_ineligible } for a trial the post cannot take (a trial on a TikTok post or a carousel answers { error }); 400 { error, error_code: "draft_limit_reached" } when the update would make a post a draft, or move a draft to another account, whose account already has its 10 drafts (see Drafts on POST /projects/{projectId}/schedule); 400 { error, error_code: "instagram_draft_not_shareable" } when the update would leave an Instagram post a draft (only_create_draft true, sent or kept) that shares to Facebook or is a trial: an Instagram draft is never shared, so send share_to_facebook false with the draft on a post that cross-posts; 400 { error, error_code: "post_being_fixed" } for trial_reel null on a failed trial reel whose outcome was lost (being_fixed), which may be live, or for an only_create_draft change on a failed Instagram reel whose outcome was lost (error_code ig_post_not_confirmed or ig_post_not_published), which stays what it was sent as, and 400 { error } for a trial_reel value on a failed regular reel whose outcome was lost (error_code ig_post_not_confirmed or ig_post_not_published); 400 { error, error_code: "duplicate_content" } when video_url or tiktok_username would leave the post with a video another account of the workspace has scheduled, posting or posted on the same platform (the same project, or a file with the same bytes), with nothing of the update written; 400 { error, error_code: "schedule_conflict" } when the phone has no room at the new scheduled_at (the error names the next free time; GET /devices/accounts/schedule lists the openings); 400 { error, error_code: "invalid_scheduled_at" } when a pending post's scheduled_at is neither a date (yyyy-MM-dd) nor an ISO 8601 time with its offset (2026-12-25T14:00:00Z), with nothing of the update written; 404 when the post is not in the workspace.

Notes

Every update re-points a pending post at its project's latest render (a render still running included; the post waits for it). video_url must be a Pamba video URL (https://storage.googleapis.com/pamba_videos/..., such as a staged_video_url from POST /uploads/videos); a link to another host is refused with a 400. It replaces the project's final video only when it is a new file from outside the project's own outputs. The project's own video URLs (its edited_video_url or stitched_video_url, an earlier render, a file previously uploaded to it) are ignored: the update still answers 200, the response's video_url stays null, and the post keeps following the latest render. To post an earlier file again, upload it through POST /projects/{projectId}/edited-video, which records a fresh URL. A pending or failed TikTok video post links a TikTok Shop product with tiktok_product_id (an id from GET /devices/tiktok/accounts/{username}/shop) and tiktok_product_link_name (at most 30 letters, numbers, spaces, apostrophes or hyphens; the product's default_link_name when omitted); remove_tiktok_product true drops the link. Moving the post to another account drops a link the update does not restate, since the product belongs to the old account's shop. The link is written with the update's other fields, so an update lands whole or not at all; a product not yet in the showcase is queued for adding once the post holds it. A pending post that links such a product goes out only after the phone can have added it: an exact scheduled_at before that (or the post's kept time, when the update changes its product) is refused with earliest_at, the earliest time the post can take (ISO 8601 UTC), and a date-only scheduled_at gets a slot after it. ai_label true or false turns the platform's AI label on or off for a pending or failed TikTok or Instagram video post, that post alone (its other platform's leg keeps its own); the phone fails the post before its Post or Share tap rather than publish it without the label, and retries it (error_code ai_label_not_set on TikTok, ig_ai_label_not_set on Instagram), and Pamba cannot take a label off a post once it is live. first_frame_cover true makes the video's first frame the cover of a pending or failed TikTok or Instagram video post, that post alone, and false leaves the cover to the platform's own pick (a frame a few seconds in); the phone sets it in the app's Edit cover before its Post or Share tap, fails the post rather than publish it with another cover, and retries it (error_code cover_not_set on TikTok, ig_cover_not_set on Instagram). A TikTok caption given #ad, #ads, #sponsored, #partnership or #paidpartnership posts with TikTok's commercial content disclosure on, labeled "Paid partnership" for good (TikTok asks no brand name); the phone fails such a post before its Post tap rather than publish it undisclosed, and retries it (error_code commercial_disclosure_not_set). Such a caption is refused on an 'Only you' post, which TikTok does not allow. An Instagram reel's trial_reel sets its trial (release_to_followers_never or release_to_followers_once_it_performs_well) or, as null, takes it off (an absent trial_reel leaves it as it is), under the same rules as POST /projects/{projectId}/schedule: to turn a trial on for a post scheduled with share_to_facebook true, send share_to_facebook false with it, and an account change on a trial is checked against the new account's follower count. On an Instagram reel share_to_facebook false keeps it off Facebook and true asks for the share, refused with 400 { error, error_code: facebook_cross_post_off | no_linked_facebook_account | facebook_account_paused } when it cannot happen; an absent share_to_facebook keeps the post's choice (share_to_facebook_choice, null when the reel follows the account's Cross-post reels to Facebook switch). The response's share_to_facebook is what the reel will do as of now. Each ScheduledPost carries instagram_trial_reel (release_to_followers_never, release_to_followers_once_it_performs_well, or null). A Facebook post cannot carry an AI label or a first-frame cover (ai_label or first_frame_cover true is a 400: both belong on its Instagram reel). A slideshow (photo) post cannot link a product, carry an AI label or take a first-frame cover (ai_label or first_frame_cover true is a 400), and changes only its scheduled_at and caption (any other field is refused with a 400; to post it from another account, cancel it and schedule the slideshow again). A new caption on a TikTok post also goes to the project's Instagram posts in the same status (pending with pending, failed with failed) whose caption still matches the old one. An Instagram post given its own caption keeps it, and editing an Instagram post never changes the TikTok post. The response is the TikTok post only; GET an Instagram post to read its caption.

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 }.

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 }. 400 for an invalid post id; 404 when the post is not in this workspace; 409 { message, code: "post_being_fixed" } when Pamba's engineers are fixing the post (being_fixed on the post); 409 { message, code: "draft_limit_reached" } when the post is a cancelled or failed draft and its account already has its 10 drafts; 422 { message } when the post cannot be retried, for example when it is not failed or cancelled, its account is not on a phone, its phone has no free posting slot inside the retry window, or another post of its video or slideshow to the same account is already scheduled, posting or posted. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

Notes

A post whose retry already failed again, in a way that alerted Pamba's engineers, is theirs to fix: it reads being_fixed true, this route refuses it with 409 post_being_fixed, and they retry it once the cause is fixed, usually within a few hours. Nothing is needed from the caller. A hold nobody works ends a day after the failure, and the post is retryable here again. An Instagram trial reel whose outcome was lost (instagram_trial_reel set, error_code ig_post_not_confirmed or ig_post_not_published) reads being_fixed from its first failure and is refused the same way, with no release after a day: it may be live and no lookup can prove a trial absent, so the engineers check it on the phone and settle it. A failure the caller remedies stays retryable: ig_audio_not_found (set another song first) and the TikTok Shop codes (tiktok_shop_not_linked, tiktok_shop_product_not_in_showcase, tiktok_product_not_in_showcase_yet, tiktok_shoppable_video_limit, tiktok_product_link_name_rejected, and tiktok_shop_product_price_changed, TikTok re-priced the product since Pamba last read the shop: Pamba reads the shop again by itself, so retry the post a few minutes later; not tiktok_shop_product_ambiguous, two showcase products the phone cannot tell apart, which Pamba's engineers fix), account_needs_reconnect (an account the workspace connected itself was signed out of its phone: reconnect it with POST /devices/accounts/imports/{importId}/retry first, or the retry fails the same way), copyrighted_sound_muted (TikTok found copyrighted music in the video and would post it muted: replace the video's audio first, or the retry fails the same way; this failure is never retried automatically), sound_not_licensed (the account is a TikTok Business account and the post's sound isn't cleared for commercial use: pick another sound first, or the retry fails the same way; this failure is never retried automatically), sound_unavailable (TikTok says the post's sound isn't available: pick another sound first, or the retry fails the same way; this failure is never retried automatically), mention_not_offered (TikTok's mention list offered no account for an @handle in the TikTok caption, or the handle is the posting account's own, so the mention would have posted as plain text: check the handle or take it out with PUT /devices/tiktok/posts/{postId} first, or the retry fails the same way; this failure is never retried automatically), ig_trial_reel_unavailable (Instagram offered the account no trial reel: switch it to creator with POST /devices/accounts/profile-edits, or clear the trial with PUT /devices/tiktok/posts/{postId} trial_reel null, first), ig_caption_flagged (after the Share tap Instagram said the caption looks similar to others that have been reported, and nothing was posted: change the caption with PUT /devices/tiktok/posts/{postId} first, or the retry fails the same way; this failure is never retried automatically), as does a failure that alerted nobody, such as "Project not approved at scheduled time". So do the failures with no code that the caller fixes first: a slideshow not ready at its time (its images still missing), a slideshow sound that is not from TikTok's library, and an Instagram song or track with no title the phone can search for (pick it again).

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, search_terms: { terms } | null, search_terms_source: own | none | niche, error_message, searches_enabled, follows_enabled, niche: string | null, niche_source: own | workspace, facebook_cross_post_enabled, facebook_cross_post_available }. facebook_cross_post_enabled is the avatar's Instagram accounts' Cross-post reels to Facebook switch as its last edit set it (PUT /avatars/projects/{projectId}/selected-avatar with facebook_cross_post_enabled), and facebook_cross_post_available is true when one of its Instagram accounts has a linked Facebook account.

Notes

search_terms are the effective terms: the avatar's own (own), none because its list was cleared with use_niche_terms false (none: its accounts search nothing), or the workspace's niche terms (niche). error_message is one plain sentence about the last failed warming pass, or null. This is what the avatar's last warming edit wrote to all of its accounts; each account warms under its own config (GET /devices/accounts/warming), which an edit made on that account alone can change. searches_enabled and follows_enabled say whether the avatar's accounts' warming sessions search and follow creators, as the avatar's last edit set them (PUT /avatars/projects/{projectId}/selected-avatar with warming_searches_enabled and warming_follows_enabled), like its terms. niche is the avatar's own warming niche (niche_source own), or null when its accounts warm on the workspace's niche_description (niche_source workspace).

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 one warming session status/detail, with every screenshot its phone kept.

Authentication

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

Request

Path { sessionId: UUID }; no body.

Response

200 WarmingSession status/details. screenshots lists every frame in the order it was taken, each with url, name, captured_at, kind (video | quick | ad | live | live_room | dead_end | comments | dialog | off_feed | skipped | feed, an Instagram home feed scroll | search | follow | not_interested | relaunch | error | frame), video_index, author, liked, on_niche, watch_seconds, description (what the frame shows; never set on an error frame), search_term, step and result (a follow frame's step is before or after the tap, and the after frame's result is what the screen showed: followed, not_followed, profile or other; a not_interested frame, from a 'Not interested' marked on a video off the niche, has step video, the video it was read on, or step0, step1, ... for each screen after it); null for a session that ran before frames were labeled, whose screenshot_urls list still stands. followed_accounts lists the creators the session followed, each { username, confirmed }; confirmed is false when the screen after the tap did not show the follow. The sessions list carries screenshot_urls only.

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 }.

Schedule an Instagram Reel from one of the avatar's accounts, cross-posted to Facebook when the account's switch is on.

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?, ai_label?, first_frame_cover?, trial_reel?, only_create_draft? }. caption is at most 2,200 characters and 5 hashtags (Instagram keeps no more than 5 hashtags on a post). 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 a Pamba video URL (https://storage.googleapis.com/pamba_videos/..., such as a staged_video_url from POST /uploads/videos) for a file from outside the project (with project_id, the project's own video URLs are never pinned and the post follows its latest render instead); 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 absent or null (the default) follows the account's Cross-post reels to Facebook switch (facebook_cross_post_enabled on GET /devices/accounts/warming): the Reel goes to the linked Facebook account while the switch is on and that account is posting, decided again when it posts, so a switch turned off or a Facebook account paused or removed after scheduling stops the share. false keeps this Reel off Facebook; true asks for the share and is refused when it cannot happen (409 facebook_cross_post_off while the switch is off, no_linked_facebook_account, facebook_account_paused). ai_label true turns on Instagram's "Add AI label" switch on the Reel's share screen (shown as "AI info"); false or absent posts without it. The phone fails the post before Share rather than publish it without the label, and retries it (error_code ig_ai_label_not_set); Pamba cannot take a label off a post once it is live. first_frame_cover true makes the Reel's first frame its cover through the share screen's Edit cover, in place of the frame Instagram picks; false or absent leaves the cover to Instagram. The phone fails the post before Share rather than publish it with another cover, and retries it (error_code ig_cover_not_set). trial_reel "release_to_followers_never" or "release_to_followers_once_it_performs_well" shares the Reel as an Instagram trial (see POST /projects/{projectId}/schedule for the rules); absent or null is a regular reel.

Response

202 { post_id, posting_warning }: posting_warning is a warning, never a refusal: the post makes its account's 2nd post that day (the workspace's day, counting its pending, posting and posted posts) while Pamba created the account less than 10 days before the post, or any account's 4th, and is booked all the same. It is { code: "new_account_posts_per_day" | "posts_per_day", message, day (yyyy-MM-dd), posts_that_day (this post included), posts_per_day }, and null (or absent) otherwise. Only the schedule and post-edit responses fill it: every other response carrying a scheduled post returns it null. 400 { error_code: video_url_required | invalid_video_url | invalid_caption | invalid_scheduled_at | invalid_trial_reel | instagram_trial_reel_with_facebook | instagram_draft_not_shareable, 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 | account_needs_reconnect | duplicate_pending_post | duplicate_content | no_linked_facebook_account | facebook_account_paused | facebook_cross_post_off | instagram_trial_reel_ineligible | draft_limit_reached | schedule_conflict, message }, where duplicate_content means another Instagram account of the workspace has this video scheduled, posting or posted (the same project, or a file with the same bytes), and facebook_cross_post_off answers share_to_facebook true on an account whose Cross-post reels to Facebook switch is off, and draft_limit_reached that an only_create_draft reel's account already has its 10 drafts.

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. only_create_draft true saves the reel as a draft in Instagram on the account's phone instead of sharing it, as on POST /projects/{projectId}/schedule: never with share_to_facebook true or a trial_reel (400 instagram_draft_not_shareable), at most 10 drafts per account in its life (409 draft_limit_reached), and Pamba never publishes the draft later.

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 on POST /devices/instagram/avatar-projects/{avatarProjectId}/posts: the linked Facebook account publishes it while the Instagram account's Cross-post reels to Facebook switch is on.

Read an account's own warming config, which governs its warming whichever avatars it is linked to.

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 } | null, search_terms_source: own | none | niche, error_message (one plain sentence about the last failed warming pass, or null), last_run_at, governed_by_avatar_project_id, linked_avatar_project_ids, searches_enabled, follows_enabled, niche: string | null, niche_source: own | workspace, facebook_cross_post_enabled, facebook_cross_post_available }. 400 when platform or username is missing; 404 when the account is not on one of the workspace's phones. facebook_cross_post_enabled is an Instagram account's Cross-post reels to Facebook switch (true on a TikTok account, where it means nothing), and facebook_cross_post_available is true when the Instagram account has a linked Facebook account on a phone, the only case the switch is shown for.

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 (search_terms_source own), none when its list was cleared with use_niche_terms false (none: terms is empty and the account searches nothing), otherwise the workspace's automatic niche terms (niche). searches_enabled and follows_enabled say whether the account's warming sessions may search and follow creators; a new account starts with its avatar's setting, or with no avatar its workspace's defaults (account_warming on GET /workspaces), both true unless changed. An avatar's warming edit (PUT /avatars/projects/{projectId}/selected-avatar) is written onto the config of every account the avatar links, and an account linked to its first avatar takes that avatar's config. linked_avatar_project_ids lists every avatar linking the account, oldest link first, and governed_by_avatar_project_id is the first of them, or null when no avatar links it. An account several avatars share warms as one account, not once per avatar; it warms while it posts and, when avatars link it, while one of them is active. niche is the account's own warming niche, set on it or by its avatar's edit (niche_source own), or null when it warms on the workspace's niche_description (niche_source workspace).

List the accounts and avatars whose warming has a niche of its own instead of the workspace's.

Authentication

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

Request

No parameters.

Response

200 { accounts: [{ platform, username, niche }], avatars: [{ avatar_project_id, name, niche }] }.

Notes

Every account warms on the workspace's niche_description unless it or its avatar has a niche of its own (PUT /devices/accounts/warming niche, PUT /avatars/projects/{projectId}/selected-avatar warming_niche). An account whose niche came from its avatar is listed among the accounts too; an account the workspace no longer has (a recreated account's predecessor, an archived one, one moved to another workspace's phone) is not. An edit of the workspace's niche (PATCH /workspaces/{workspaceId}) keeps these unless it sends own_niches replace.

Set the search terms or the niche one account warms with, turn its warming searches or follows on or off, or an Instagram account's cross-posting to Facebook, whichever avatars it is linked to.

Authentication

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

Request

{ platform: "tiktok" | "instagram", username, warming_search_terms?: string[], use_niche_terms?: boolean, generate_search_terms?: boolean, searches_enabled?: boolean, follows_enabled?: boolean, facebook_cross_post_enabled?: boolean, niche?: string, use_workspace_niche?: boolean }, with at least one of warming_search_terms, use_niche_terms true, generate_search_terms true, searches_enabled, follows_enabled, niche, facebook_cross_post_enabled (Instagram accounts only) and use_workspace_niche true.

Response

200 the same config object GET /devices/accounts/warming returns. 400 when platform or username is missing, when none of warming_search_terms, use_niche_terms true, generate_search_terms true, searches_enabled, follows_enabled, niche, facebook_cross_post_enabled and use_workspace_niche true is sent, when facebook_cross_post_enabled is sent for a TikTok account, when a term carries a word TikTok and Instagram refuse to search ("porn"; the message names the term), when terms are sent with use_niche_terms true, when niche is sent with use_workspace_niche true, when the niche has no letter or digit or is longer than 1,000 characters, when generate_search_terms is sent with warming_search_terms or use_niche_terms, or when generate_search_terms finds no niche of the account's own; 404 when the account is not on one of the workspace's phones; 429 when the workspace has used its AI assists for the day; 503 when the terms could not be generated, in which case nothing in the request was applied. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

Notes

Only what you send changes. niche gives the account a warming niche of its own, one sentence of what it is about, in place of the workspace's niche_description: it steers which videos the account's warming engages with, from its next session; use_workspace_niche true goes back to the workspace's. The niche leaves the search terms as they are: set terms that match it, or send generate_search_terms true, which replaces the account's search terms with terms written for its own niche (the niche sent with it, else the one it already has; it takes a few seconds and counts as one AI assist). With an empty list the account has no terms of its own and sessions use the workspace's niche terms; with an empty list and use_niche_terms false the account keeps an empty list of its own and its warming searches nothing (search_terms_source none) until terms are set again. use_niche_terms true (with an empty list, or no list) goes back to the niche terms. searches_enabled false: the account's warming sessions never search and browse the feed only; its terms are kept for when searches are back on. follows_enabled false: its warming sessions follow no one. facebook_cross_post_enabled false: none of the Instagram account's reels goes to its linked Facebook account, the ones already scheduled included (each reel's share is decided again when it posts), and scheduling one with share_to_facebook true is refused with facebook_cross_post_off; true (the default, or the avatar's setting for a new account it links) lets every video reel of it go to Facebook unless the post was scheduled with share_to_facebook false. A new account starts with its avatar's setting, or with no avatar its workspace's defaults for searches and follows (PUT /workspaces/{workspaceId}/account-warming), all on unless changed; cross-posting has no workspace default and starts on. All apply from the account's next session on TikTok and Instagram alike; warming itself keeps running.

Managed Accounts and Seats

Buy Pamba-hosted TikTok and Instagram accounts, name them or connect accounts you already have, watch them get set up, and manage their seats. Once a cancelled seat's paid period ends, everything still queued for its account (warming sessions, device jobs, comments) is cleared and nothing new can be queued, even while its phone is still held.

Price, 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, facebook_addon_price_cents, facebook_addon_offer (none | with_new_accounts: whether this workspace may buy the Facebook add-on), facebook_addon_unusable_slots (while each phone takes one Facebook account, how many new Instagram accounts fill free slots on phones that can't take it; null otherwise), facebook_addon_accounts: [{ username, phone_group }] (the Instagram accounts you have that can take Facebook now; one per phone_group while each phone takes one; empty when not offered), bundle_eligible, accounts_per_device, current_seats, free_seats_remaining: { tiktok?, instagram?, facebook? }, unpaired_seats: { tiktok?, instagram? }, pending_requests: [{ request_id, platform, seats, satisfied_by_existing, jobs: [{ state, stage, desired_username, parent_instagram_username? }], names_required, names: [{ username, status, applied_username, failure_reason }], unnamed_seats, open_imports, purpose, link_avatar_project_id, parent_instagram_username? }], awaiting_payment: [{ platform, seats, link_avatar_project_id, since }], high_demand, open_slots: { tiktok, instagram }, cards?: [{ subscription_id, card_brand, card_last4, seats, renews_at_epoch, unpaired_seats: { tiktok?, instagram? } }], bundle_deal_prices_cents: number[], regular_bundle_price_cents }. bundle_deal_prices_cents is empty unless the Pamba team agreed a bundle price with you: then it lists the monthly price of each of your next bundles (one new TikTok, one new Instagram and one Facebook account bought in the same order), in the order your paid orders take them; an order's whole bundles take the first of them, its other seats bill as usual, and a bundle keeps its price until one of its seats is cancelled (regular_bundle_price_cents is a bundle's price outside the deal). failure_reason is one plain sentence about why the name could not be set, never the phone's own text. A TikTok job reads state running and stage applying_username from its signup until the account carries desired_username (TikTok assigns its own handle at signup, and the pick is applied as a rename, usually within a few minutes); the purchase stays listed until then, even when every seat already has an account (such an entry carries only the jobs being renamed: seats counts them, names is empty and unnamed_seats 0), and the account joins GET /devices/accounts/inventory under the picked name. A recreation's TikTok account is not listed here: its recreation on GET /devices/accounts/inventory reads running until the new account carries its requested_username. open_imports counts the request's seats an open account import holds (POST /devices/accounts/imports); unnamed_seats already leaves them out. purpose is create or connect: connect marks seats for accounts you already own (bought that way, or switched with POST /devices/accounts/seat-requests/{requestId}/purpose): names_required is false, nothing is created for them, and unnamed_seats counts the seats still waiting for an account to be connected. awaiting_payment lists seats a completed checkout bought with a payment that has not cleared yet (a bank transfer, usually up to 4 business days; since is the checkout's completion, ISO-8601 UTC): nothing is created for them until Stripe confirms the payment, when they move to pending_requests, and a failed payment drops them. A Facebook add-on being set up is a pending request of one facebook seat: names_required is false and unnamed_seats 0 (it takes its Instagram account's name and handle), and parent_instagram_username names that Instagram account once it exists.

Notes

A seat is one Pamba-hosted account: $100 a month, and every two of a platform get a phone of their own for that platform. bundle_eligible is true for a workspace whose owner signed up before the TikTok plus Instagram pair discount ended for new customers: there every TikTok plus Instagram pair across the subscription prices the second at $50 (bundle_seat_price_cents), re-priced when seats are added or cancelled. Every other workspace pays $100 for every seat, and its unpaired_seats is empty. A Facebook add-on to an Instagram account is a flat $100 a month on top of its Instagram seat (facebook_addon_price_cents) and never pairs: Instagram with Facebook is $200, a TikTok and Instagram pair with Facebook $300 ($250 for a pair that keeps the pair price); it takes no phone of its own. From November 16, 2026 at 08:00 UTC (midnight Pacific) no new seat pairs: bundle_eligible, which says whether the seats bought now pair, is false and unpaired_seats is empty unless Pamba agreed a different price with you, so every seat bought from then is $100 a month. Seats bought before keep their pair price for as long as both seats of the pair bill (GET /devices/accounts/seat-billing and each account's seat_monthly_cents price them). unpaired_seats counts the seats on the subscription your next paid purchase raises (the newest one that is active and not set to cancel) that have no partner on the other platform yet: seats of that other platform bought next pair with them at $50. Pairs never count across subscriptions, so a seat on a free-seat grant or on another subscription is not in it. Any quantity up to 2000 accounts per order can be bought: seats no phone can take yet stay being set up until the team adds phones. Phones are handed out per platform: a phone's TikTok side and its Instagram side may serve different workspaces, and your accounts of a platform never share their side with another customer. open_slots counts, per platform, the seats the workspace's own phones still take after its seats being set up. high_demand is true while Pamba's phones are short of room for new accounts: new accounts may then take up to 5 days to be created (accounts bought with purpose connect may wait up to 5 days for a phone to sign them in on), whatever the order. cards is present only for a workspace whose seats the Pamba team split across cards at the customer's request: each card a purchase can add seats to, newest first, with its own unpaired_seats (pairs count within one card); name one with subscription_id on POST /devices/accounts/seat-checkout. Read this before buying: do not buy again while a request is pending or seats are awaiting their payment, and a purchase never adopts a spare account already on the phones (link those from the avatar instead).

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?, facebook_seats?, facebook_for_instagram?, return_path, link_avatar_project_id?, purpose?, subscription_id? }. return_path is the app path Stripe returns to. link_avatar_project_id auto-links the created accounts to that avatar. purpose is create (the default: Pamba creates new accounts) or connect (seats for TikTok and Instagram accounts you already own, each filled later with POST /devices/accounts/imports). facebook_seats adds a Facebook add-on to that many of the order's new Instagram accounts, at most instagram_seats, only where seat-options' facebook_addon_offer is with_new_accounts (400 otherwise), and where its facebook_addon_unusable_slots is set at most one per phone they fill: instagram_seats less those slots, over accounts_per_device, rounded up (an order that also carries facebook_for_instagram counts the free slots on those accounts' phones as taken too, which seat-options does not show, so its cap can be lower; the 400 names it); facebook_for_instagram is a list of Instagram usernames the workspace already has to add a Facebook add-on to, each one whose inventory row reads facebook_addon_eligible true, no two with the same facebook_addon_phone_group, all paid on one card: the order then raises that card's subscription. Neither goes with purpose connect. subscription_id, only for a workspace whose seats bill to more than one card, is the card to add the seats to (from GET /devices/accounts/seat-options' cards); left out, they join the newest subscription.

Response

200 { checkout_url }. 400 (plain text) when every count is zero, an order exceeds 2000 accounts, purpose is neither create nor connect, a connect order carries Facebook, facebook_seats is more than instagram_seats or than the phones they fill while each phone takes one, facebook_seats is sent where facebook_addon_offer is not with_new_accounts, an account in facebook_for_instagram can't take Facebook (the sentence names it and why: the reasons facebook_addon_refusal codes), two of them share a phone while each phone takes one, or the accounts are paid on different cards, or subscription_id names a card that can't take seats (its subscription is not the workspace's, is cancelling or is free), and 400 "Couldn't start checkout, please try again" (plain text) when Stripe could not list the workspace's seat subscriptions or open the confirm page: nothing was charged and no subscription was started, so send the request again.

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. A Facebook add-on is $100 a month on top of its Instagram seat, billed on that seat's own subscription; Pamba creates the Facebook account from its Instagram account (same name and birthday, the Instagram handle as its username when Facebook allows it) and its Instagram reels cross-post to it.

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

{ batch_size?, exclude?, seat_request_ids?, replacing? { platform, username } }, or an empty body. With replacing, the call suggests instead batch_size (3 by default) free usernames for the new account POST /devices/accounts/recreate would put in that account's place, each with that account's seat_request_id and no first_name or last_name (a recreation keeps the old account's name), and an empty list when no live seat funds it. Otherwise batch_size (1-100) names at most that many seats per purchase in this call and leaves the rest out of the reply; exclude is a list of handles you already show and have not saved, never suggested again (nor close variants of them); seat_request_ids limits the call to those purchases (by default every purchase still waiting). A batched call answers within 90 seconds: seats it had no time for come back with username null, so ask again for them. 400 for a batch_size outside 1-100, more than 2000 exclude or seat_request_ids entries, an exclude entry over 64 characters, an invalid id, or an unknown field.

Response

200 { suggestions: [{ seat_request_id, platform, username, first_name, last_name }] }, username null when the search found nothing free; first_name and last_name are a name to offer an Instagram seat with that username (the linked avatar's, else the username's first name, Claire for wanderwithclaire, with a surname picked for it; send it back with that username to keep it, since a one-word avatar name left out gets a new surname at signup; an account made on a phone that already holds an Instagram account, where Instagram asks for no name at signup, gets a name sent back or its avatar's about three days after it is made), null on TikTok and on a seat with neither an avatar nor a username; last_name is also null when the name offered is one word (an offer that would run past 30 characters keeps only its first word). 429 { error, code: "rate_limited" | "free_tier_exhausted", retry_at } with a Retry-After header when seats are waiting for names but the workspace is past its daily AI-assist limit (see Usage limits under Billing).

Notes

One suggestion per purchased seat still waiting for its name, each confirmed free before it is offered, up to 100 per purchase per call. Pamba prepares names for a waiting order in the background (checked free, kept a day), so a call usually answers from them at once; one with none ready takes a few seconds a seat, so for a large order ask in batches (batch_size 8, excluding the names already shown) rather than waiting minutes on one call. Creation does not start until the seat has a name.

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, first_name, last_name }. first_name and last_name (Instagram) are the name an account created as this username gets at signup when the save gives none and no avatar is linked (an account made on a phone that already holds an Instagram account, where Instagram asks for no name at signup, gets it only when it is sent with this username, about three days after it is made): the first name the handle carries (Claire for wanderwithclaire1), else one picked for the handle, with a surname picked for it; the same handle always gets the same name. Send it with this username to keep it, never for a seat linked to an avatar, whose name is the default there. Null on TikTok and for an invalid handle; last_name also null when a name past 30 characters keeps only its first word.

Notes

A strong hint, not a reservation: the real claim happens on the phone. unknown is a provider hiccup and proves nothing either way.

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, display_name_from_username?, first_name?, last_name? }] }, 1 to 1000 entries (a whole order in one call); username null means the platform's default name. display_name_from_username (TikTok seats; false when absent, ignored on Instagram) true sets the account's display name to the username it ends up with, once the rename lands; with username null there is no rename, and the account keeps TikTok's own display name. first_name and last_name (Instagram seats) are the account's name, at most 30 characters together; both absent or blank creates it under the linked avatar's name (a one-word one with a surname drawn at signup), else the name its username gives (the one POST /devices/accounts/username-check returns), else, with username null, a generated one; an account made on a phone that already holds an Instagram account, where Instagram asks for no name at signup, gets first_name and last_name (the default username-suggestions and username-check offer included) or the avatar's name about three days after it is made, a name changed in the meantime winning, and no name when both are absent and no avatar is linked; sending them for a TikTok seat refuses that entry.

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, with the first and last name; TikTok assigns its own handle and applies the pick as a rename right after, which uses TikTok's one rename per thirty days; with display_name_from_username true, a separate edit then sets the display name to the handle that landed, which uses TikTok's one display-name change per seven days. A name that fails when applied falls back to close variations, then keeps the platform handle, and the account's row on GET /devices/accounts/inventory names the pick as picked_username_not_applied. Poll GET /devices/accounts/seat-options until the account appears on GET /devices/accounts/inventory with status posting.

Switch one waiting seat between connecting an account you own and a new account Pamba creates.

Authentication

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

Request

{ purpose: "create" | "connect" }. requestId is a pending purchase's request_id from GET /devices/accounts/seat-options.

Response

200 { seat_request_id, purpose, split_from, changed }. seat_request_id is the purchase now holding the seat: the same one, or, when the purchase had other seats, a new one split from it (split_from names the original, null otherwise). changed is false when a pending purchase already had the purpose (nothing was written). 400 (plain text) when requestId is not an id or purpose is neither create nor connect; 404 { error_code: "seat_not_found", message } for a purchase not in the workspace; 409 { error_code: "seat_not_switchable", message } when no seat of the purchase can switch, including a purchase already set up (fulfilled) or a Facebook one, whatever its purpose, and, to connect, a seat of an Instagram purchase whose new accounts carry a Facebook add-on not yet set up (Pamba adds Facebook only to an account it creates).

Notes

Same seat, same price. Only a seat nothing holds yet switches: not one whose account is being created or connected, and not a seat to create whose name is saved. A seat switched to create waits for its username (PUT /devices/accounts/seat-usernames); one switched to connect waits for an account import. Not safe to replay: calling again with the original request_id after a split moves another seat, so switch a seat back with the seat_request_id the first call returned.

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, renewal_declined, cards?: [{ subscription_id, card_brand, card_last4, seats, pending_cancel_seats, monthly_cents, renews_at_epoch, renewal_declined }], bundle_deal: { bundles_total, bundles_remaining, remaining_tiers: [{ bundles, bundle_monthly_cents }], regular_bundle_monthly_cents, workspaces_sharing } | null }. bundle_deal is null unless the Pamba team agreed a bundle price with you; then it says how many bundles it prices in all, how many are left for your next orders and at what prices, in order. When it covers several of your workspaces (workspaces_sharing above 1), their orders all count against the same bundles.

Notes

monthly_cents is the next renewal with pending-cancel seats excluded and pairs at the bundle price (pairs of seats bought before November 16, 2026 only), and, with a bundle_deal, each bundle still whole at its agreed price (regular_bundle_monthly_cents less its price comes off; a card's monthly_cents too, never an account's seat_monthly_cents). renewal_declined is true while a seat subscription funding one of the accounts has its last renewal declined and still owed (past due or unpaid); POST /billing/card-update-checkout takes a new card. cards is present only for a workspace whose seats the Pamba team split across cards at the customer's request: what each card pays, both renewing on the same day; POST /billing/card-update-checkout with a card's subscription_id replaces that card alone. Per-account seat state (seat_status, seat_monthly_cents, seat_cancel_effective_at_epoch, seat_renewal_declined) is on GET /devices/accounts/inventory.

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, facebook_addon_cancelled }. 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. The account's Enhanced Warming, if on, is turned off with it and ends on the same day. Cancelling an Instagram seat cancels its Facebook add-on with it, on the same date (facebook_addon_cancelled true); a Facebook add-on (platform facebook) can be cancelled on its own. Once the workspace holds no seats, the phones its seats came with are signed out and returned to the pool three days after its last seat subscription ends, even while a credit plan runs.

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, when its effective date has passed (the cancellation took effect), or for a Facebook add-on whose Instagram seat is cancelled too (reinstate the Instagram seat instead).

Notes

Undoes a cancellation before its effective date at no cost: the seat bills again from the next renewal, and Enhanced Warming the cancellation turned off comes back on. Reinstating an Instagram seat brings its cancelled Facebook add-on back with it. A seat subscription cancelled on Stripe's billing page renews with it, its other seats included.

Resubscribe: get back the accounts of a seat subscription that ended in the last 3 days.

Authentication

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

Request

{ offer_id?, return_path }. offer_id is a seat_restore_offers entry's offer_id from GET /devices/accounts/inventory, needed only when more than one is open. return_path is the app path Stripe returns to, with seat_restore=success or seat_restore=cancelled.

Response

200 { checkout_url, seats, monthly_cents, restorable_until_epoch }. 409 { error, message }: error is "no_restore_offer" when no ended subscription's accounts can come back or offer_id names no open offer, "choose_restore_offer" when offer_id is missing and more than one offer is open, and "payment_clearing" when a payment for this offer already went through or is still clearing (a bank transfer): the accounts come back with it, so there is nothing to pay again. 502 { error: "checkout_unavailable", message } when Stripe could not be reached to check earlier payments for the offer, close its earlier pages, or open the page: nothing was charged, so send the request again.

Notes

When a whole seat subscription ends, its accounts stop posting at once but stay signed in on their phone for 3 days; after restorable_until_epoch resubscribing can't bring them back. The user opens checkout_url and pays in the browser: a new subscription for exactly those accounts' seats at the price the ended one billed (its TikTok and Instagram pair discount and any negotiated deal included; monthly_cents says what it bills every month). On payment the same accounts go back to posting on the same phone, with their warming, history and analytics; an account the workspace had paused itself comes back paused. All of an offer's accounts come back together, a Facebook add-on only with its Instagram account. Posts the pause cancelled do not come back. The checkout page expires when the offer does; a new page for the same offer expires the earlier one, and the payment that lands expires any other; seats paid for whose account was gone by the time the payment landed open as new seats that ask for usernames, like any purchase.

Whether a seat-funded Instagram account, or a TikTok account TikTok banned, can be recreated now, and its recreation in flight.

Authentication

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

Request

Query: platform (instagram, or tiktok: a seat-funded tiktok account reads unsupported_platform unless it carries replacement_offer), username.

Response

200 { recreatable, refusal_code, reason, cooldown_until_epoch, recreation: { status: "queued" | "running" | "succeeded", replaced_username, requested_username, new_username, move_scheduled_posts, posts_start_after_days, queued_at_epoch }, replacement_offer?: { reason: "suspended" | "identity_verification" | "banned", since_epoch }, scheduled_post_count }.

Notes

replacement_offer is present when the account can be replaced for free now: Instagram suspended it (suspended), or asks it for identity verification with a video selfie no phone can give (identity_verification), or TikTok banned the TikTok account (banned; the account also carries the BANNED health flag), after its first day. Its recreation then has no cooldown and is not refused phone_on_hold: the new account is made on a new phone, never the one the platform flagged, and the workspace's admins are emailed the offer. An account stopped in its first day earns no offer; Pamba's team replaces it. scheduled_post_count is how many posts the account has scheduled, which the recreation moves or cancels. When recreatable is false, refusal_code is one of not_seat_funded, seat_pending_cancel, has_facebook_addon (the Instagram account has a Facebook add-on, live or being set up, so it can't be recreated for now, nor replaced for free), cooldown_active, already_in_progress, not_on_device, phone_on_hold (the account is suspended or banned with no replacement_offer, on a phone held out of new accounts: Pamba's team replaces it), unsupported_platform, and reason says it in one sentence. A workspace recreates Instagram accounts, and a TikTok account only while it carries replacement_offer (reason banned): every other seat-funded TikTok account reads unsupported_platform ("Recreation is not supported for TikTok"); only Pamba's team replaces it, and its recreation shows here and on the inventory the same way. A TikTok recreation with a requested_username reads running, with no new_username, until its new account carries that name. A recreation in flight (queued or running) takes its account off the rows of GET /devices/accounts/inventory and lists it under recreations ({ platform, replaced_username, requested_username, avatar_project_id, status, queued_at_epoch }); a succeeded one rides the new account's row as recreation for a day. A recreation can take a while: while it is in flight, the seat stays listed as an account being created, a new request is refused with already_in_progress, and there is nothing for the workspace to do (the same as a purchased seat). Each inventory row also carries recreate_refusal_code (the same codes; absent when the account can be recreated now; every Facebook row reads unsupported_platform, as does every seat-funded TikTok row without a replacement_offer) and recreate_cooldown_until_epoch (absent when no cooldown holds, and on a row of a platform the workspace cannot recreate on). While a recreation is in flight, a username change of the replaced account is refused with error_code recreation_in_progress.

Replace an Instagram account with a new one on the same seat (on its phone, or on another phone when its phone takes no new accounts), or replace a TikTok account TikTok banned.

Authentication

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

Request

{ platform: "instagram" | "tiktok", username, new_username?, move_scheduled_posts?, posts_start_after_days? }; tiktok only for an account GET /devices/accounts/recreation offers to replace for free (replacement_offer, reason banned). Omit new_username for the platform's default name. move_scheduled_posts is true when omitted. posts_start_after_days (0-14, with move_scheduled_posts) shifts the moved posts by whole days when the new account lands so the first goes out at least that many days later, each keeping its time of day and spacing; omitted or 0 keeps their times.

Response

202 { recreation_id, status: "queued" }. Every refusal is { code, message }: 400 with code invalid_username (a malformed new_username, the account's own handle, or a name another seat picked), invalid_posts_start_after_days (outside 0-14) or unsupported_platform (any platform but instagram, and a TikTok account with no replacement_offer: only Pamba's team recreates one); 403 with code not_admin unless the caller is a workspace admin; 409 with code not_seat_funded, seat_pending_cancel, already_in_progress, not_on_device, phone_on_hold, seat_being_moved, cooldown_active or recreation_paused (Pamba has paused recreations for maintenance; message says until when, and the request can be sent again then).

Notes

Queued per phone: several recreations on one phone run one after another, each taking tens of minutes. The old account is signed out when the new one is made on its phone (it stays where it is otherwise, below) and is no longer available on Pamba; it stays on GET /devices/accounts/inventory as archived and frozen, and nothing is deleted: its videos stay on Instagram, its analytics keep counting in the workspace's analytics (flagged archived; nothing new is read for it), and Pamba keeps its credentials in case they are ever needed; its pending posts move to the new account, which links to the same avatars and appears on GET /devices/accounts/inventory as posting. With move_scheduled_posts false the pending posts stay with the old account and are cancelled when the new one lands; their videos stay in the workspace to schedule again, so a new account does not start out posting the old one's queue. A recreation can take a while; while it runs, the seat keeps showing as an account being created. A new_username is checked for format and for uniqueness among the workspace's picks, then typed at Instagram signup the way a seat pick is; availability is the live hint of POST /devices/accounts/username-check, and the phone has the last word at signup. Once every 10 days per seat, except for an account GET /devices/accounts/recreation offers to replace for free (replacement_offer): that one is made on a new phone, with no cooldown, and the old account stays where it is, archived. A seat takes one free replacement every 30 days. An account whose phone Pamba holds out of new accounts (say after another account on it was suspended) is recreated on another phone too, another of the workspace's phones with room or else a new one, the old account staying where it is, archived; its cooldown still applies. On such a phone, only a suspended or banned account with no replacement_offer is refused, with phone_on_hold: Pamba's team replaces it. A TikTok account TikTok banned is replaced this way only, its new account made on a brand-new phone. An account the platform took out of use has no pending posts left to move: they failed when it went out of use.

Connect a TikTok or Instagram account you already have: Pamba signs it in on one of the workspace's phones.

Authentication

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

Request

{ platform: "tiktok" | "instagram", username, password, login?, avatar_project_id? }. login is the email, phone or username the account signs in with; the username when omitted. avatar_project_id links the account to that avatar once it is connected.

Response

201 { import: AccountImport }, where AccountImport is { id, platform, username, login, avatar_project_id, status: "queued" | "signing_in" | "awaiting_code" | "needs_customer" | "with_team" | "succeeded" | "cancelled", code_request, failure: { code, message } | null, created_at, updated_at, completed_at, reconnect, opened_username, phone_busy }. phone_busy is set only on a queued import whose phone is busy with other work: { activity: "warming" | "posting" | "editing_profile" | "connecting_account" | "setting_up_account" | "account_work" | "phone_maintenance", platform, account, since, done_by, can_interrupt }; done_by is when that work is usually over (an estimate), and can_interrupt says POST /devices/accounts/imports/{importId}/interrupt may free the phone now. reconnect is true on an import that connected its account, which was later signed out of its phone, while it is open again to reconnect it. opened_username names the account the owner's login opened instead, on a needs_customer import whose failure.code is wrong_account (null otherwise, and on a reconnect, which keeps its handle): the phone has already signed it back out, and retrying with it as username connects it. Every refusal is { error, message }: 409 no_open_seat (no seat on the platform is waiting for a name, leaving out every seat of an Instagram purchase whose new accounts carry a Facebook add-on not yet set up: Pamba adds Facebook only to an account it creates), 409 already_managed (the account is already on one of the workspace's Pamba phones), 409 import_in_progress (the workspace already has an open import of it), 404 avatar_not_found, 400 invalid_request, 503 imports_unavailable.

Notes

An import connects only the account it names: Instagram also signs in every account that shares a Meta Accounts Center with it, and Pamba never lists, warms or posts from those; connecting one takes its own seat and import. An import fills a purchased seat that is still waiting for an account (unnamed_seats on GET /devices/accounts/seat-options), at the seat's normal price, a seat bought to connect before one bought to create; with none on the platform, buy seats with POST /devices/accounts/seat-checkout and purpose connect first, as many as you will connect, and connect them whenever you are ready. The password is stored encrypted and never returned. When the app asks for a verification code, the import turns awaiting_code and carries code_request, and the code is entered with POST /devices/accounts/imports/{importId}/code while the phone waits on the code screen: poll GET /devices/accounts/imports/{importId} every few seconds while it is queued, signing_in or awaiting_code, and keep the account's phone and email at hand. An account that signs in only with Google or Apple needs a password added in the app first. Pamba's phone becomes one more device signed in to the account. Once connected, the account warms and posts like any account Pamba made, and Pamba changes nothing on it by itself. A later password change signs the phone out and reopens the import as needs_customer with failure code signed_out. Facebook is not supported: it rides its Instagram account.

List the workspace's account imports.

Authentication

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

Request

No body.

Response

200 { imports: AccountImport[], can_add }: every open import (queued, signing_in, awaiting_code, needs_customer, with_team) plus those finished in the last 7 days, newest first. can_add says whether the workspace may start one; it is true for every workspace.

Notes

An open import holds its seat: GET /devices/accounts/seat-options counts it in open_imports on the seat's pending request and leaves it out of unnamed_seats.

Read one account import: its status, the code it waits for, or why it stopped.

Authentication

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

Request

Path { importId: UUID }; no body.

Response

200 { import: AccountImport }. 404 when the import is not in the workspace.

Notes

status says who acts. queued and signing_in: nobody, the phone works. awaiting_code: the customer enters the code. needs_customer: the customer fixes failure.code (wrong_password, account_not_found, code_timeout, code_rejected, suspended, too_many_attempts, wrong_account, signed_out, password_required, passkey_required or approve_on_device: an approval asked on the owner's own phone that never came, retried when that phone is with them) and retries; failure.message says it in one sentence. passkey_required: TikTok will only let the account in with a passkey that lives on the owner's own phone, so the owner removes it in TikTok (Settings and privacy, Account, Passkey) and retries with the same password. with_team: Pamba's team finishes it and emails the customer, or hands it back as needs_customer with a failure when only the customer can move it on; once the account is found signed in on its phone the import connects by itself. succeeded: the account is signed in and bound to its seat and avatar. cancelled: the seat is free again. code_request is { kind: "email" | "sms" | "authenticator" | "approve_on_device" | "unknown", destination (masked, such as j***@gmail.com), attempt, wrong_code, requested_by: "phone" | "team", requested_at, expires_at, password_rejected, resend_available_at } while a code is wanted, and null otherwise. approve_on_device takes no code: the customer approves the sign-in in the app on their own phone. For an email or text code the app also asks on the owner's own phone whether the sign-in is theirs, and approving there moves the phone on with no code entered. password_rejected true: the app said the password was wrong and sent this code to an address it chose, so enter it only if that address is the owner's (if it is not, the handle reached someone else's account: cancel and check it); a code never given then fails as wrong_password. resend_available_at: from when a resend is worth asking for (the apps hold their own resend about a minute after a code goes out). requested_by team is a team member signing in by hand who asked for the code (status with_team). No phone identifier is ever returned.

Enter the verification code the phone is waiting for.

Authentication

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

Request

Path { importId: UUID }; { code }: 4 to 8 digits.

Response

200 { import: AccountImport }. 404 not_found when the import is not in the workspace, 409 not_awaiting_code when no code is wanted, 400 invalid_code, 502 phone_unreachable when the phone could not be reached (send it again).

Notes

The phone types the code on the app's code screen while it waits, until code_request.expires_at. A code the app refuses comes back as code_request with wrong_code true; ask for a new one with POST /devices/accounts/imports/{importId}/code/resend.

Ask the app to send a new verification code.

Authentication

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

Request

Path { importId: UUID }; no body.

Response

200 { import: AccountImport }. 404 not_found, 409 not_awaiting_code when no code is wanted, 502 phone_unreachable.

Notes

The phone taps the app's resend on the code screen it waits on. Ask after code_request.resend_available_at: the apps hold their own resend about a minute after a code goes out, and each resend moves that time a minute on.

Try an import that needs the customer again, optionally with a new password, login or handle.

Authentication

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

Request

Path { importId: UUID }; a JSON body { password?, login?, username? } ({} tries the stored login again). username is a corrected handle, or the account a wrong_account login opened (opened_username).

Response

200 { import: AccountImport }, queued again; with_team once five sign-in attempts did not connect it (counted from the sign-out for a reconnect, and from the retry that connects opened_username; another new username keeps the count; the team takes over), cancelled when its seat was cancelled. 404 not_found, 409 not_retryable when the import is not waiting on the customer, 409 already_managed when the new handle is already on one of the workspace's Pamba phones, 409 import_in_progress when another open import has it, 409 handle_not_changeable on a reconnect, 400 invalid_request, 503 imports_unavailable.

Notes

Without a password the stored one is tried again. A wrong_password failure needs the current password. A signed_out failure is an account the import connected that TikTok or Instagram later signed out of its phone: retrying reconnects it, with the stored password unless the owner changed it (send the new one), and usually a code the app sends, as on the first connect; until then the account's schedules are refused and its scheduled posts fail with account_needs_reconnect. A wrong_account, account_not_found or wrong_password one may need the login the account signs in with. An account_not_found one may be a mistyped handle: send the right one as username, and a login that was the old handle follows it. A wrong_account one is a login that opened another of the owner's accounts, named in opened_username and already signed back out of the phone: send it as username (with no other field) to connect that account, or the username and login of the account the owner meant. A passkey_required one takes the same password once the owner removed the passkey. A reconnect keeps its handle.

Stop an open account import.

Authentication

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

Request

Path { importId: UUID }; no body.

Response

200 { import: AccountImport } with status cancelled. 404 not_found, 409 already_finished when it already succeeded or was cancelled, 409 reconnect_not_cancellable on a reconnect (reconnect true) while its account still fills its seat; one whose seat ended stops like any import, and Pamba closes it 3 days later, when the seat can no longer be bought back.

Notes

The seat it held is free again, for another import or a new account. A reconnect cannot be stopped while its account still fills its seat, since stopping would leave it signed out with no way back: reconnect it with POST /devices/accounts/imports/{importId}/retry, or cancel its seat. Once that seat has ended, a reconnect stops like any import (it frees no seat, having none), and Pamba closes it 3 days after the seat ended.

Free the phone a queued import waits for, so its sign-in starts now.

Authentication

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

Request

Path { importId: UUID }; no body.

Response

200 { import: AccountImport }, still queued: the sign-in starts within a minute. Also 200 when the phone was already free or its work ended meanwhile, which makes the sign-in due at once too. 404 not_found, 409 not_waiting_for_phone when the import is not queued, 409 not_interruptible when the phone's work can't be cut short (phone_busy.can_interrupt false).

Notes

Only a warming can be interrupted, and only when its end would start the sign-in (no post booked right after it): it is stopped and rebooked for later. A post on either platform (its browse after the publish included: the app is uploading the video), a profile edit and an account's setup run to their end, and the sign-in starts by itself right after.

Devices and Account Linking

The workspace's phones, the spare accounts on them, and binding accounts to avatars.

The workspace's phones and the TikTok and Instagram accounts signed in on each, 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: string[] }].

Notes

Only the workspace's own phones, one entry each and no identifier: a phone is what its accounts say it is. On a phone whose other side another workspace holds, only the accounts of the side yours holds are listed. One call to the phone controller: ready is the phone's state right now. A TikTok account Pamba just created for a purchased seat is listed once it carries the username picked for it (GET /devices/accounts/seat-options shows its seat as being set up until then).

TikTok accounts on the workspace's phones that no avatar links yet, or with include_linked=true every one an avatar can link.

Authentication

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

Request

Optional query include_linked (true | false, default false); no body.

Response

200 [{ username, avatars: [{ avatar_project_id, name, image_url }] }]. avatars is empty unless include_linked=true, which also returns the accounts the workspace's avatars already link, each naming those avatars, oldest link first.

Notes

The spares an avatar can link, or with include_linked=true every account POST /devices/accounts/link accepts. A seat purchase never adopts one of these; link it to the avatar instead. An account another workspace's avatar links is never listed, and an account Pamba just created for a seat is listed once it carries the username picked for it (GET /devices/accounts/seat-options shows its seat as being set up until then).

The accounts each of the workspace's videos and slideshows may be scheduled from.

Authentication

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

Request

No query or body.

Response

200 { workspace: PostingAccounts, avatars: { <avatarProjectId>: PostingAccounts } }, where PostingAccounts is { scope: "avatar" | "workspace", tiktok_usernames, instagram_usernames, instagram_usernames_with_facebook, instagram_usernames_cross_posting_to_facebook, instagram_usernames_below_trial_floor, instagram_usernames_personal }: the accounts a schedule may name, the choices the app's schedule dialog offers. scope avatar lists the avatar's post-enabled accounts (an Instagram account only while it is on its phone); scope workspace, for a project or slideshow with no avatar, lists every postable account on the workspace's own phones, whichever avatar it is linked to (a TikTok account the workspace paused or archived is left out). A listed TikTok account that is not signed in on one of the workspace's phones, or one with an open suspension, is still refused when scheduled. instagram_usernames_with_facebook names the Instagram accounts whose linked Facebook account can post too (share_to_facebook), and instagram_usernames_cross_posting_to_facebook those of them whose Cross-post reels to Facebook switch is on: their reels go to Facebook unless a post sends share_to_facebook false, and only they take share_to_facebook true; instagram_usernames_below_trial_floor names those whose last follower count Pamba read is under the 200 Instagram asks of a trial reel (trial_reel is refused on them; an account Pamba has not read yet is not named), and instagram_usernames_personal those Pamba knows are personal accounts with no switch to creator queued (trial_reel is refused on them too; POST /devices/accounts/profile-edits with instagram_account_type creator queues the switch). Handles carry no @.

Notes

avatars holds each avatar's accounts by avatar project id; an avatar with no post-enabled account is absent (it needs an account linked or bought first). workspace is the pool a project or slideshow with no avatar posts from. These are the choices the app's schedule dialogs offer, and the accounts POST /projects/{projectId}/schedule and the slideshow schedule accept. GET /projects/{projectId} carries one project's as posting_accounts.

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, new_accounts: [{ platform: "tiktok" | "instagram", username, new_until }] }.

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. new_accounts lists the accounts on the workspace's phones that Pamba created less than 10 days ago, with new_until (ISO 8601 UTC), when each stops counting as new: until then a schedule warns (posting_warning) on its 2nd post in a day instead of its 4th.

Instagram accounts on the workspace's phones that no avatar links yet, or with include_linked=true every one an avatar can link.

Authentication

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

Request

Optional query include_linked (true | false, default false); no body.

Response

200 [{ username, avatars: [{ avatar_project_id, name, image_url }] }]. avatars is empty unless include_linked=true, which also returns the accounts the workspace's avatars already link, each naming those avatars, oldest link first.

Notes

Adopt a spare with POST /devices/instagram/avatar-projects/{avatarProjectId}/adopt-account; link an account other avatars use with POST /devices/accounts/link. An account another workspace's avatar links is never listed.

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 }, also when the account already posts for this avatar (nothing changes). 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 neither in GET /devices/instagram/linkable-accounts nor already posting for this avatar (a linked account that is paused is refused).

Notes

No phone work: the binding is a database change. Posts waiting for the avatar's first Instagram account attach to it. POST /devices/accounts/reassign with no from_avatar_project_id does the same for any platform; an account that already posts for another avatar is linked to this one too with POST /devices/accounts/link, or moved with POST /devices/accounts/reassign, not adopted.

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 }.

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; 409 { error_code: "no_workspace_phone", message } when the workspace holds the Instagram side of none of its phones (a phone's Instagram side a workspace never used goes to other workspaces; an Instagram seat brings one).

Notes

Pamba picks the phone: one of the workspace's own whose Instagram side the workspace holds, a reachable one first, then the one with the fewest Instagram accounts. The login is a stored job on that phone: it waits its turn behind the phone's scheduled work, retries a busy or unreachable phone, and survives a backend restart; it reads as running meanwhile and fails with a plain sentence if the phone stays unavailable for 24 hours; poll GET /devices/instagram/link-account-status?job_id=... until it is completed or failed. 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.

Assign an account to an avatar, or move it 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 }. With from_avatar_project_id (the binding the caller saw) the call moves the account, refused when that binding is no longer current. Without it the account is one no avatar links yet (avatar_project_id null on GET /devices/accounts/inventory) and the call assigns it: a TikTok or Instagram spare on a workspace phone links with no phone work; a Facebook account is refused, it follows its Instagram account.

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, when an account assigned without from_avatar_project_id turns out to be bound to another avatar, or when the handle is on none of the workspace's phones.

Notes

Only the avatar binding changes: the account keeps its posting status, phone and analytics history. A move takes the account off from_avatar_project_id only: a TikTok or Instagram account other avatars also link stays on them, and moving it to an avatar that already links it just drops the from avatar's link (that avatar keeps its own link and the account keeps its warming). Same source and target is a no-op 200, and so is assigning without from_avatar_project_id to the avatar the account already has. To add an avatar without taking the account from any, use POST /devices/accounts/link.

Link an account to one more avatar, keeping the avatars that already have it.

Authentication

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

Request

{ platform: "tiktok" | "instagram", username, avatar_project_id }.

Response

200 { status: "ok", already_linked, other_avatars: [{ avatar_project_id, name }] }; already_linked is true when the avatar already had the account (nothing changes), and other_avatars are the account's other avatars, which keep it. 400 { message } on an unknown platform or malformed id, when the avatar is not in the workspace, when the account is on none of the workspace's phones, or for a Facebook account. 409 { error_code: "account_archived", message } when the account is archived.

Notes

A TikTok or Instagram account can post for any number of avatars and stays one account: one posting status, posts-per-day limit and warming config, which the new link takes as they are. No phone work. An Instagram spare with no avatar yet is adopted as POST /devices/instagram/avatar-projects/{avatarProjectId}/adopt-account does. A Facebook account is not linked on its own: it posts for every avatar its Instagram account is linked to. Posts waiting for the avatar's first account on the platform attach to it.

Unlink an account from one avatar; with no avatar left it is an unassigned account again.

Authentication

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

Request

{ platform: "tiktok" | "instagram", username, avatar_project_id }. avatar_project_id is the binding the caller saw (GET /devices/accounts/inventory); the call is refused when the account is no longer bound to it. A Facebook account is refused: it follows its Instagram account's cross-posting and can be moved with POST /devices/accounts/reassign, not unlinked; unlinking the Instagram account leaves the Facebook account where it is.

Response

200 { status: "ok" }. 400 { message } on an unknown platform or malformed id, when the avatar is not in the workspace, or when the account is not (or no longer) bound to avatar_project_id.

Notes

The reverse of a first assignment through POST /devices/accounts/reassign or of POST /devices/accounts/link, and no phone work either: only that binding is dropped. A TikTok or Instagram account other avatars also link stays on them. Otherwise the account stays signed in on its phone and keeps its posting status, cap and analytics history, so it is a spare again — assignable with POST /devices/accounts/reassign without from_avatar_project_id, and postable from projects with no avatar. Posts already scheduled on it and warming sessions already booked are not touched.

Account Profiles

Change what an account's profile shows and whether it posts; every profile edit runs on the phone as a device job. An account whose seat was cancelled and whose paid period has ended runs nothing more: a request that would queue work on it answers 409 { error_code: "account_seat_ended", message }. An archived account (one a recreation replaced, or that was signed out of its phone) is frozen: every request that would run or change anything on it answers 409 { error_code: "account_archived", message }, and only Pamba support can bring it back. An account the platform took out of use (TikTok or Instagram banned it, its BANNED health flag; Meta suspended it or Instagram asks it for identity verification, its SUSPENDED or IDENTITY_VERIFICATION flag) takes nothing more: every request that would run or change anything on it answers 409 with error_code account_banned or account_suspended and a sentence naming the account (in message, or in error on a schedule), and its pending posts fail with the same code and sentence (a TikTok ban about an hour after Pamba finds it, before which its work waits).

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, or when the image part is empty (0 bytes); 413 when the file is over 25 MB.

Notes

new_profile_picture_url on the TikTok, Instagram and Facebook profile edits takes a Pamba image URL (https://storage.googleapis.com/pamba_images/..., no query string), such as the one this route returns: the phone downloads pictures from Pamba's image bucket and nowhere else, so any other URL (a media-library link, another host) is refused with 400 profile_picture_invalid before the edit is saved. 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 "[email protected];type=image/jpeg"

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. Each @handle in it is linked as a tappable mention, which TikTok shows as @ and that account's display name; an email address stays text.

Response

200 { username, display_name, profile_picture_changed, verification_screenshot, operations: [{ name, result: { requested, succeeded, applied_value, error_code, 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 (message says the change is queued or already applying, never which phone holds it). 400 { error_code: missing_fields | bio_too_long | bio_invalid | profile_picture_invalid | username_taken | username_invalid | recreation_in_progress, 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; message is one plain sentence for the code, never the phone's own text.

Notes

Blocks while the phone applies the edit (POST /devices/accounts/profile-edits takes the same edit and answers once its job is saved instead): for the profile fields up to 8 minutes for one name, handle or bio (9 for a picture alone), 90 seconds more for each further field (150 for a picture) and 45 seconds more for each @handle in the bio (12 minutes for a name, picture and bio), 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, and one the phone had no time left to start with field_out_of_time, beside the fields it did apply. A bio @handle TikTok offers no account for fails the bio field with error_code bio_mention_not_found, and a mention the phone could not show links the account asked for (a pick on a suggestion whose caption TikTok cut short, or a bio TikTok already held whose mentions are not drawn as links) fails it with bio_mention_unconfirmed. 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.

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_reason } }], warning? }. 202 { queued: true, job_id, message, next_attempt_at, applying, warning? } when the phone is busy, scheduled or unreachable (message says the change is queued or already applying, never which phone holds it). 400 { error_code: missing_fields | bio_too_long | bio_invalid | profile_picture_invalid | username_taken | username_invalid | recreation_in_progress, message }; 404 { error_code: "account_not_found", message }; a failure the phone reports as final is { error_code, message } with the status it carries; message is one plain sentence for the code, never the phone's own text.

Notes

Blocks up to 6 minutes while the phone applies the edit; POST /devices/accounts/profile-edits takes the same edit and answers once its job is saved instead. 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. warning is present when new_bio carries a link and Pamba created the account less than 7 days ago: the edit still goes on, but Instagram often restricts link sharing on accounts that add a link this early.

Start a profile edit, AI creator label change or profile link on any platform and get its job at once.

Authentication

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

Request

JSON body { platform: "tiktok" | "instagram" | "facebook", username, new_display_name?, new_username?, new_profile_picture_url?, new_bio?, disable_ai_comments?, auto_block_ai_comments?, ai_creator_enabled?, new_link?, new_link_title?, remove_link?, instagram_account_type?, instagram_category?, instagram_show_category?, instagram_audience_countries?, facebook_professional_mode?, facebook_page? { name, category } }. username is the account's handle (with or without @); a Facebook account's is its inventory key, the username GET /devices/accounts/inventory gives its row, sent as given or with a leading @, in any case. A Facebook account takes new_display_name (a first and a last name, any middle name between, at most 64 characters), new_username (its facebook.com username: 5 to 50 letters, digits and periods) and new_profile_picture_url only. Its picture is shared with the Instagram account it was made from, so a new one changes that account's picture too (and the edit turns that sharing on when it is not). Facebook allows one name change every 60 days (409 facebook_name_locked until the inventory's name_locked_until) and refuses a name that does not read as a person's, a brand's for one: that field then reads failed with fb_name_refused. facebook_professional_mode true (Facebook only, alone in its request) turns professional mode on for the Facebook profile as its own job; Pamba only turns it on (false is 400 facebook_professional_mode_invalid), and a profile already in it answers 409 facebook_professional_mode_on. facebook_page { name, category } (Facebook only, alone in its request) sets up a Facebook Page the profile owns as its own job, which takes the phone a few minutes: name is at most 75 characters with no emoji or symbols such as ™ (Facebook does not allow them in a Page name) and can be a brand's, category is Digital creator, Blogger, Entrepreneur, Education, Health/beauty, Clothing (Brand), Community, Artist or Musician/band (any case). The Page is connected to the Instagram account the Facebook account was made from, whose reels then post to the Page instead of the profile; that Instagram account becomes a Creator account when it is personal; and the profile no longer shares its picture with the Instagram account, and its new_display_name, new_username and new_profile_picture_url are refused while the Page is being set up (409 facebook_profile_unlinked). An account takes one Page, which this API does not rename or remove yet: a second request, or one sent while a Page is being set up, answers 409 facebook_page_exists. Once the account posts as its Page (the inventory row carries facebook_page), its edit is the Page's: new_profile_picture_url and new_bio, together or alone, change the Page's picture and bio as one job (kind facebook_page_edit). new_bio there is at most 255 characters (code points) on one line (runs of spaces and line breaks become one space), and "" clears it; new_display_name or new_username on such an account answers 409 facebook_page_name_unsupported, since a Page's name can't be changed from Pamba yet. new_bio on a Facebook account that posts as its profile is refused (400 unsupported_field). A name Facebook does not accept fails the job with fb_page_name_refused, and another name can be sent. The fields and their rules are the platform's PATCH profile route's: new_bio on TikTok and Instagram (and a Facebook Page's, above), disable_ai_comments and auto_block_ai_comments on TikTok only, and ai_creator_enabled on Instagram only, alone in its request (the label is its own job, as on PATCH /devices/instagram/accounts/{username}/ai-creator). new_profile_picture_url must come from POST /devices/accounts/profile-pictures. new_link (TikTok and Instagram, alone in its request) puts a website link on the profile as its own job: https://example.com or a bare example.com. Pamba keeps one link per profile: the link replaces the one there (TikTok's website, Instagram's first link; other Instagram links are left alone) or is added when there is none. new_link_title (Instagram only, at most 30 characters) is the title it is listed under; left out, the link keeps the title it has. remove_link: true (TikTok and Instagram, alone in its request) takes the profile's link off through the same job, whatever the account's age or followers. instagram_account_type ("personal" | "creator"), instagram_category and instagram_show_category (Instagram only, together and alone in their request) switch the account between Personal and Creator (a professional account), set its profile category and whether the profile shows the category, as their own job. Pamba never switches an account to Business, since Business accounts lose most of Instagram's licensed music: "business" is refused, and an account that is a Business account already (the inventory reads instagram_account_type "business") can go to personal or change its category, never to creator. instagram_account_type "creator" on an account that is creator or business while its switch to personal still waits for the phone, never yet sent to it, withdraws that switch instead: nothing is queued and the account stays as it is, and an instagram_category or instagram_show_category in the same request is queued as its own change; on a creator account after an earlier type switch of it ended unread, the request is queued over the waiting switch instead (202), for the phone to read the type the account has. instagram_category is one of the categories Pamba offers, Instagram's own names matched in any case: Digital creator, Artist, Musician/band, Blogger, Clothing (Brand), Community, Education, Entrepreneur, Health/beauty, Writer, Personal blog, Product/service, Gamer, Restaurant, "Beauty, cosmetic & personal care", Grocery Store, Photographer, Shopping & retail, Reel creator, Gaming video creator, Social Media Agency, Camera/photo, Computer Company, Makeup Artist, Creator. A personal account, or one whose type Pamba has not read, switched to creator takes the category "Digital creator", hidden, unless the request (or a change already queued for it) names a category or display; an account that is creator or business already changes only the fields the request names, so the category and its display stay as they are unless sent. Instagram also turns on stricter filtering for professional accounts, which hides more comments and message requests. A personal account has no category, so personal goes alone, and a category or its display sent without a type only changes an account that is creator or business, or has a switch to creator queued. TikTok has no such setting Pamba changes: its one switch is to a Business account, which limits the account to TikTok's commercial music library. instagram_audience_countries (Instagram only, alone in its request) keeps the account's audience in chosen countries through Instagram's Minimum age setting, as its own job: "tier1" is United States, Canada, United Kingdom, Australia, New Zealand, Ireland, Singapore, Germany, France, Netherlands, Belgium, Luxembourg, Austria, Switzerland, Sweden, Norway, Denmark, Finland; "tier1_tier2" adds Poland, Czechia, Slovakia, Hungary, Spain, Portugal, Italy, Greece, Estonia, Latvia, Lithuania, Slovenia, Croatia, Japan, South Korea, Israel, United Arab Emirates, Saudi Arabia, Qatar, Hong Kong, Taiwan; "us_only" is the United States; "all" clears the setting. Every country outside the choice gets a minimum age of 25 and each kept country 13. It is an age gate, not a block: outside the chosen countries Instagram shows the account only to people it knows are 25 or older; it does not block a country. Only a professional (creator or business) account has the setting: a personal account is refused, and one switched to creator by an instagram_account_type request sent first takes it once that switch is queued; an account whose type Pamba has not read goes ahead, and its job fails if the phone finds it personal. A change still queued takes the newest choice whole. A profile already as asked is left as it is. Instagram takes only about 15 such changes a day on an account (each country set or removed, and the default), so a bigger change continues by itself the next day, for up to 5 days from when it was first asked ("tier1_tier2" from scratch takes three; still not whole then, the job fails with ig_minimum_age_failed): between runs its job reads queued with next_attempt_at its next run, a pick sent meanwhile joins it and keeps that time, and what is set stays set (instagram_audience_countries_status on GET /devices/accounts/inventory counts what is left). A change starts with the United States and then the default of 25, so until it finishes the chosen countries not added yet are gated too.

Response

202 { queued: true, job_id, message, next_attempt_at, applying, withdrawn_job_ids? } once the job is saved (an Instagram edit adds warning? as its PATCH route does, and new_link always adds warning: adding a link can make TikTok ask the account's owner to verify their identity, and on Instagram often triggers identity verification which locks Pamba out of the account, and recommends a highlight with a link in it instead; remove_link, the account type fields and instagram_audience_countries add none); message says the change is saved and the phone applies it in the background, that the phone is busy and the change runs once it is free, that Instagram paces audience changes and the phone continues this one at next_attempt_at (an instagram_audience_countries change waiting for its next day, which the request joined), or (applying true) that the phone is applying this change now; applying true means the phone is already applying this same change and job_id is that job; withdrawn_job_ids lists the switches to personal an instagram_account_type "creator" request withdrew before queueing the instagram_category or instagram_show_category it also sent (each reads failed with error_code instagram_account_type_withdrawn), and is null when the request withdrew none. 200 with the TikTok PATCH route's body when the request only sets auto_block_ai_comments, a setting with no phone work. 200 { withdrawn: true, withdrawn_job_ids, message } when instagram_account_type "creator" withdrew a switch to personal that was waiting for its phone and nothing else was asked (each withdrawn job reads failed with error_code instagram_account_type_withdrawn on GET /devices/accounts/profile-edits/{jobId}). 400 { error_code: invalid_platform | unsupported_field | missing_fields | facebook_name_needs_first_and_last | facebook_name_too_long | facebook_name_invalid | facebook_name_unchanged | facebook_account_not_linked | facebook_professional_mode_invalid | facebook_page_invalid | bio_too_long | bio_invalid | profile_picture_invalid | username_taken | username_invalid | recreation_in_progress | link_invalid | link_title_too_long | instagram_account_type_invalid | instagram_category_invalid | instagram_audience_countries_invalid, message } (bio_too_long and bio_invalid also answer a Facebook Page's new_bio over 255 characters or holding a character the phone cannot type; facebook_name_invalid answers a name holding a character the phone cannot type, a control, format or private-use character; facebook_name_unchanged answers a Facebook new_display_name the profile already has, capitals aside, sent alone (beside another field it is dropped); facebook_account_not_linked answers a Facebook add-on account linked to no Instagram account, or whose Instagram account left its phone; username_invalid also answers a Facebook username that is not 5 to 50 letters, digits and periods; facebook_page_invalid answers a facebook_page with no name, a name over 75 characters or holding a character the phone cannot type, a name holding an emoji or a symbol such as ™ (Facebook does not allow them in a Page name), or a category outside the list; link_invalid also answers a blank new_link: remove_link takes a link off; instagram_account_type_invalid answers any type but personal or creator, business included, its message saying Pamba switches accounts to Creator only, because Business accounts lose most of Instagram's licensed music; instagram_category_invalid's message lists the categories; instagram_audience_countries_invalid answers anything but all, tier1_tier2, tier1 or us_only, custom included), unsupported_field naming a field the platform does not take (instagram_audience_countries on TikTok, or sent with any other field, included) and missing_fields also answering a blank username; 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones; 409 { error_code: "facebook_name_locked", message } when new_display_name goes to a Facebook account whose name Facebook still holds (message names the day the next change opens: "Facebook allows one name change every 60 days: this profile's next one opens Jan 6, 2027"; the inventory's name_locked_until says so up front); 409 { error_code: "facebook_professional_mode_on" | "facebook_page_exists" | "facebook_profile_unlinked" | "facebook_page_name_unsupported", message } when facebook_professional_mode goes to a profile already in professional mode, facebook_page to an account that has a Page or one being set up, a name, username or picture to a Facebook account whose Page is being set up, or a name or username to one that posts as a Page (the inventory's professional_mode and facebook_page say so up front); 409 { error_code: "change_in_progress", message } when an AI creator label change, a link, an account type change or an audience countries change contradicts the one the phone is applying right now (creator included, once a switch to personal has been sent to the phone, one queued again after a refused or interrupted try included); 409 { error_code: "instagram_account_not_professional", message } when instagram_category or instagram_show_category is sent without a type, or instagram_audience_countries is sent, for an account that is personal or has a switch to personal queued; 409 { error_code: "instagram_account_business", message } when instagram_account_type "creator" is sent for an account that is a Business account with no switch to personal queued or running (Pamba never switches one to Creator: send personal, or leave the type out to change its category); 409 { error_code: "link_account_too_new" | "link_needs_followers", message } when new_link goes to an account Pamba created less than 7 days ago or a TikTok account with fewer than 1,000 followers (TikTok only offers the link field from 1,000; profile_link_block on GET /devices/accounts/inventory says so up front). 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended.

Notes

The job form of the PATCH profile and ai-creator routes, which wait for the phone and stay available; this route never does. The job runs on the account's phone in the background, is retried automatically for 24 hours and survives restarts, and GET /devices/accounts/profile-edits/{jobId} reads its outcome. Edits of one account merge as they do on the PATCH routes: a change still queued takes the newest values, and fields sent while the phone applies another change run right after it.

Example

curl -X POST https://api.pamba.app/devices/accounts/profile-edits \
  -H "X-API-Key: $PAMBA_API_KEY" -H "Content-Type: application/json" \
  -d '{"platform": "tiktok", "username": "maya.makes", "new_display_name": "Maya | Makes"}'

Read a profile edit, AI creator label change or profile link: its status and each field's outcome.

Authentication

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

Request

Path { jobId }: the job_id from POST /devices/accounts/profile-edits, or from a 202 of a PATCH profile or ai-creator route.

Response

200 { job_id, platform, username, kind: "profile_edit" | "ai_creator" | "profile_link" | "instagram_account_type" | "instagram_audience_countries" | "facebook_professional_mode" | "facebook_page" | "facebook_page_edit", status: "queued" | "applying" | "completed" | "failed", requested_fields, ai_creator_enabled, link, instagram_account_type, instagram_category, instagram_show_category, instagram_audience_countries, facebook_page, operations: [{ name, result: { requested, succeeded, applied_value, error_code, error_reason } }], next_attempt_at, error_code, error_reason, created_at, completed_at }. 400 { error_code: "invalid_job_id", message }; 404 { error_code: "job_not_found", message } when the id is not a profile edit, AI creator label change, profile link, Instagram account type change, Instagram audience countries change, Facebook professional mode turn-on, Facebook Page set-up or Facebook Page edit on one of the workspace's phones.

Notes

next_attempt_at is set while the job is queued: when its phone is next tried. operations lists the requested fields only, each once the phone has reported it; a completed job can still carry a failed field (a platform's limit on name or handle changes, for example), so read each one. error_code and error_reason are set when status is failed, error_reason one plain sentence. requested_fields is drawn from display_name, username, profile_picture, bio, block_ai_comments, allow_ai_comments, ai_creator_label, profile_link, account_type, category, show_category, minimum_age, professional_mode and facebook_page; ai_creator_enabled is the state an ai_creator job turns the label to, link the link a profile_link job puts on the profile ("" for one that takes it off), and instagram_account_type, instagram_category and instagram_show_category what an instagram_account_type job asked for (null for a field it leaves as it is), and instagram_audience_countries the choice an instagram_audience_countries job asked for (all, tier1_tier2, tier1 or us_only; null on the other kinds), and facebook_page the { name, category } a facebook_page job sets up (null on the other kinds). A facebook_professional_mode job reports the one operation professional_mode and a facebook_page job the one operation facebook_page, whose applied_value is the Page's name; a facebook_page job fails with fb_page_name_refused when Facebook did not accept the name (send another), and one that completed without the Instagram account connected to the Page reports the operation failed with fb_page_not_connected. A facebook_page_edit job (a Facebook Page's picture or bio) requests profile_picture and bio, whichever it was sent, and reports each as an operation: bio's applied_value is the bio the phone read back on the Page, profile_picture's the picture's URL; a field the phone did not save reads failed with fb_page_bio_not_saved or fb_page_picture_not_set, and the job fails with fb_page_edit_failed when the phone could not finish, fb_page_not_listed when the profile has no Page of the recorded name, or fb_profile_session_failed when it could not sign in to the profile. An instagram_account_type job reports account_type, category and show_category as operations; a category Instagram's search has no exact match for fails with ig_category_not_found, and a switch to creator of an account the phone finds is a Business account fails with ig_account_business, nothing changed (Pamba never switches a Business account to Creator). A switch to personal withdrawn before its phone ran it reads failed with instagram_account_type_withdrawn. An instagram_audience_countries job requests minimum_age and reports it as one operation, whose applied_value is the choice the phone read back (all, tier1_tier2, tier1, us_only or custom). Among the codes it fails with: ig_account_not_professional when the phone finds a personal account, ig_minimum_age_country_not_found when Instagram's country list has no country by one of the names, ig_minimum_age_failed when the phone could not finish, none of them retried; a run whose answer Pamba lost is followed again for up to a day, then fails with minimum_age_timeout. GET /devices/accounts/inventory's instagram_audience_countries and instagram_audience_countries_status say what it is set to and how the change stands, updated as the phone confirms each country: a run that stopped partway (a failure, a restart) leaves every change the phone confirmed, so sending the same choice again finishes it. A tier choice adds a country at a time, and Instagram takes only about 15 such changes a day on an account, so a bigger change continues by itself the next day: between runs the job reads queued, next_attempt_at its next run (tier1_tier2 from scratch takes three days). A profile_link job fails with profile_link_rejected when the app refuses the link and profile_link_unavailable when TikTok shows the account no link field. username is the handle after a rename the job applied. A profile edit usually finishes within 2 to 5 minutes; poll every 15 seconds or more.

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 }. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

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.

Pause or resume a Facebook account's posting (the Instagram cross-posts it receives).

Authentication

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

Request

Path { username }: the Facebook account's username as GET /devices/accounts/inventory lists it (a Facebook add-on's is its Instagram account's handle; an account made before 2026-10-26 is its profile's display name), matched exactly after trimming; 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 }. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

Notes

No phone work: no phone runs the Facebook app, and a Facebook account never warms. Pausing cancels anything still scheduled for it. A paused Facebook account also stops receiving Instagram cross-posts: share_to_facebook true is refused with facebook_account_paused, and every Instagram reel already scheduled goes out without its Facebook share, since a reel's share is decided again when it posts.

Turn Instagram's AI creator label on or off for the account.

Authentication

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

Request

Path { username }; JSON body { enabled: boolean } (true turns the label on, false turns it off).

Response

200 { ai_creator_enabled, already_enabled: false }, ai_creator_enabled being the state asked for. 202 { queued: true, job_id, message, next_attempt_at, applying } when the phone is busy, scheduled or unreachable (message says the change is queued or already applying, never which phone holds it). 404 { error_code: "account_not_found", message }; 409 { error_code: "change_in_progress", message } when the phone is applying the other direction right now (call again once it settles); a failure the phone reports as final is { error_code, message } with the status it carries; message is one plain sentence for the code, never the phone's own text.

Notes

Runs on the phone as a device job and blocks up to 6 minutes; POST /devices/accounts/profile-edits with ai_creator_enabled makes the same change and answers once its job is saved instead. A label already in the asked state is left untouched. A change still queued for a busy phone takes the newest request's direction. already_enabled is always false on this route. While the job is queued or running, the account's ai_creator_label_state on GET /devices/accounts/inventory and GET /avatars/projects/{projectId} reads turning_on or turning_off; it then reads on or off, or failed (turning on) or turn_off_failed (turning off, the label still on) when the attempt failed. GET /devices/accounts/activity names a turn-off's job_kind disable_ai_creator.

Post a picture or a video to the account's Instagram story with an optional link sticker and song, then add it to a highlight.

Authentication

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

Request

Path { username }; JSON body { image_url?, video_url?, link_url?, link_text?, link_position?, highlight_name?, scheduled_at?, instagram_audio_id?, surprise_song? }, with exactly one of image_url and video_url. image_url is a Pamba-hosted picture (https://storage.googleapis.com/pamba_images/..., or a media-library asset's url, which is copied there). video_url is a Pamba-hosted video of at most 60 seconds (https://storage.googleapis.com/pamba_videos/... with no query string, such as a project's rendered video or POST /uploads/videos' staged_video_url, or a media-library video's url, which is copied there); a video plays its own sound, so it takes no instagram_audio_id or surprise_song. link_url is an http(s) address (https:// is added to a bare domain) the story's link sticker opens; omit it to post the picture with no sticker. link_text is the sticker's own words instead of the address (one line, at most 40 characters). link_position is top, middle (Instagram's own placement, the default) or bottom. link_text and link_position need link_url. highlight_name (one line, at most 15 characters, which is all Instagram keeps) is the highlight the story goes into, made when the account has none by that name; omit it to post the story alone. scheduled_at is ISO 8601 with an offset, up to 30 days ahead; omit it to post as soon as the phone is free. instagram_audio_id is a song for the story: an Instagram track's audio_id from GET /sounds/instagram/top or /sounds/instagram/search; omit it for a silent story. surprise_song: true (never with instagram_audio_id) lets Instagram pick the song instead: the phone takes one of the songs Instagram suggests for the account, or keeps a song Instagram already put on the story.

Response

202 Story { story_id, username, status: "scheduled" | "posting" | "posted" | "failed", story_live, image_url (null for a video story), video_url (null for a picture story), link_url?, link_text?, link_position?, highlight_name?, highlight_created?, scheduled_at, shared_at?, completed_at?, error_code?, error_message?, song?: { instagram_audio_id, title, artist, duration_seconds?, explicit?, cover_url?, preview_url? }, song_confirmed?, surprise_song, song_played? }. 400 { error_code, message } with error_code invalid_media (both image_url and video_url, or neither), invalid_link_url, invalid_link_text, invalid_link_position, invalid_highlight_name, invalid_scheduled_at, invalid_image_url, invalid_video_url (not a Pamba video, not one Pamba can read, or longer than 60 seconds), invalid_instagram_audio_id or invalid_song (instagram_audio_id and surprise_song together, or either with video_url), invalid_link_text and invalid_link_position also answering one sent without link_url; 404 { error_code: "account_not_found", message } when no phone in the workspace hosts the account; 409 { error_code: "account_needs_reconnect", message } when the account is one the workspace connected itself that was signed out of its phone (reconnect it with POST /devices/accounts/imports/{importId}/retry first), or { error_code: "account_seat_ended", message } when the account's seat was cancelled and its paid period has ended; 503 { error_code: "song_search_unavailable", message } when Instagram's music library cannot be reached to look the song up.

Notes

The story runs on the account's phone as a device job at scheduled_at, booked on the phone's schedule like a post, and takes about four minutes there: the picture goes on the story with the link sticker, then the phone opens the live story and adds it to the highlight. Each request is a story of its own. A story is never posted twice: once its Share tap is made (shared_at), a retry only looks for the live story and adds it to its highlight. story_live is true once the phone saw the story live on the account, so a failed story with story_live true is up without its highlight (error_code ig_highlight_failed), and one with shared_at but story_live false is unconfirmed (ig_post_not_confirmed: the phone tapped Share but could not find the story afterwards; check the account before asking again). ig_story_link_unavailable (Instagram offers the account no link sticker, for example while it is restricted from sharing links) and ig_story_link_rejected (Instagram refused the address) post nothing. A song is played as music only, with no sticker over the picture, and Instagram names it at the top of the story; the phone picks the same track in Instagram's own music library and fails the story before sharing rather than post it with another song or none (ig_audio_not_found when Instagram does not offer the song to the account, which happens on business accounts; ig_audio_not_attached is retried). song_confirmed is true once the phone read the song on the posted story. A surprise_song story names the song the phone put on as song_played ("artist · title") once the phone reports it. A song adds about a minute on the phone, a video a few. The phone receives a copy of the video Pamba encodes for it, made when the story runs; a copy that cannot be made is retried before the phone runs anything (error_code phone_media_failed once the retries are spent). A story that cannot run within 6 hours of its time fails.

Example

curl -X POST https://api.pamba.app/devices/instagram/accounts/trailnotes/stories \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "image_url": "https://storage.googleapis.com/pamba_images/...", "link_url": "https://example.com/guide", "link_text": "the full guide", "link_position": "bottom", "highlight_name": "Guides" }'

List the latest stories the workspace asked for on the account.

Authentication

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

Request

Path { username }; no body.

Response

200 { stories: Story[] }, newest first, at most 20 (the Story shape is POST /devices/instagram/accounts/{username}/stories's response). 404 { error_code: "account_not_found", message } when no phone in the workspace hosts the account.

Notes

Only the stories this workspace asked for through Pamba; stories posted on the phone by hand are not listed. error_message is one plain sentence for error_code, never the phone's own text.

Post a picture or a video to the account's TikTok story, seen by Everyone.

Authentication

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

Request

Path { username }; JSON body { image_url?, video_url?, scheduled_at? }, with exactly one of image_url and video_url. image_url is a Pamba-hosted picture (https://storage.googleapis.com/pamba_images/..., or a media-library asset's url, which is copied there). video_url is a Pamba-hosted video of at most 60 seconds (https://storage.googleapis.com/pamba_videos/... with no query string, such as a project's rendered video or POST /uploads/videos' staged_video_url, or a media-library video's url, which is copied there). scheduled_at is ISO 8601 with an offset, up to 30 days ahead; omit it to post as soon as the phone is free. Any TikTok account on the workspace's phones takes a story, linked to an avatar or not.

Response

202 TikTokStory { story_id, username, status: "scheduled" | "posting" | "posted" | "failed", story_live, image_url (null for a video story), video_url (null for a picture story), scheduled_at, shared_at?, completed_at?, error_code?, error_message? }. 400 { error_code, message } with error_code invalid_media (both image_url and video_url, or neither), invalid_image_url, invalid_video_url (not a Pamba video, not one Pamba can read, or longer than 60 seconds) or invalid_scheduled_at; 404 { error_code: "account_not_found", message } when no phone in the workspace hosts the account; 409 { error_code: "account_needs_reconnect", message } when the account is one the workspace connected itself that was signed out of its phone (reconnect it with POST /devices/accounts/imports/{importId}/retry first), { error_code: "account_seat_ended", message } when the account's seat was cancelled and its paid period has ended, or { error_code: "account_archived", message } when the account is archived.

Notes

A TikTok story has no link, song or highlight (TikTok offers none on a story): a picture posts silent and a video with its own sound, seen by Everyone for 24 hours. The story runs on the account's phone as a device job at scheduled_at, booked on the phone's schedule like a post. Each request is a story of its own. A story is never posted twice: once the phone has tapped Your Story (shared_at), a retry only looks for the story. story_live is true once the phone saw the story in the account's viewer, shared to Everyone; a failed story with shared_at but story_live false is unconfirmed (tiktok_story_not_seen: check the account before asking again). tiktok_story_unavailable means TikTok offers the account no Add to Story, and posts nothing. tiktok_story_media_not_found, tiktok_story_privacy_unconfirmed and tiktok_story_sound_not_removed stop the phone before it shares, and are retried. The phone receives a copy of the picture or video Pamba makes for it when the story runs (phone_media_failed when it cannot be made, retried first). A story that cannot run within 6 hours of its time fails.

Example

curl -X POST https://api.pamba.app/devices/tiktok/accounts/trailnotes/stories \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "video_url": "https://storage.googleapis.com/pamba_videos/...", "scheduled_at": "2026-11-05T15:00:00Z" }'

List the latest TikTok stories the workspace asked for on the account.

Authentication

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

Request

Path { username }; no body.

Response

200 { stories: TikTokStory[] }, newest first, at most 20 (the TikTokStory shape is POST /devices/tiktok/accounts/{username}/stories's response). 404 { error_code: "account_not_found", message } when no phone in the workspace hosts the account and the workspace asked for no story on it.

Notes

Only the stories this workspace asked for through Pamba; stories posted on the phone by hand are not listed. error_message is one plain sentence for error_code, never the phone's own text.

List the account's Instagram highlights as its profile shows them, with any delete in progress on each.

Authentication

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

Request

Path { username }; no body.

Response

200 { username, highlights: Highlight[] | null, read_error?, deletes: HighlightDelete[] }. Highlight { highlight_id, name, cover_url?, delete?: HighlightDelete }, in the profile's order. HighlightDelete { delete_id, username, highlight_name, highlight_id?, status: "deleting" | "deleted" | "failed", requested_at, completed_at?, error_code?, error_message? }. 404 { error_code: "account_not_found", message } when no phone in the workspace hosts the account.

Notes

highlights is Instagram's own list, read through a data provider (it is reused for a minute, and a cover is copied to Pamba's storage). A highlight carries delete while a delete asked for it waits for the phone or runs there (status deleting), or for a day after one failed (status failed, with error_message). A highlight the phone has deleted is left out, even while Instagram's list still shows it. deletes lists the account's deletes from the last day, newest first, including those still deleting and those for a name the list does not show. highlights is null, with read_error, when Instagram's list could not be read; deletes is still returned.

Example

curl https://api.pamba.app/devices/instagram/accounts/trailnotes/highlights \
  -H "X-API-Key: $PAMBA_API_KEY"

Delete one of the account's Instagram highlights from its profile.

Authentication

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

Request

Path { username }; JSON body { highlight_name, highlight_id? }: the name the profile shows under the highlight's cover (one line, at most 15 characters; case and spacing aside), and its highlight_id from GET /devices/instagram/accounts/{username}/highlights when it was picked there.

Response

202 HighlightDelete (the shape GET /devices/instagram/accounts/{username}/highlights lists), status "deleting". 400 { error_code: "invalid_highlight_name" | "invalid_highlight_id", message }; 404 { error_code: "account_not_found", message } when no phone in the workspace hosts the account; 409 { error_code: "highlight_name_ambiguous", message } when two of the account's highlights have the name. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended.

Notes

The delete runs on the account's phone as a device job as soon as the phone is free and takes about a minute and a half: the phone finds the highlight's cover on the profile, deletes it from the cover's menu and reads the profile back. The stories that were in the highlight stay in the account's story archive. Until the phone is done, the highlight's list entry carries the delete as deleting; then the highlight leaves the list. A request for a name a delete is already waiting or running for returns that delete. error_code ig_highlight_not_found means the profile had no highlight by that name, and ig_highlight_name_ambiguous that two highlights had it, so neither was deleted (Instagram gives no way to tell them apart); neither is retried. Other failures are retried for up to 6 hours.

Example

curl -X POST https://api.pamba.app/devices/instagram/accounts/trailnotes/highlight-deletes \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "highlight_name": "Guides", "highlight_id": "17923725321432692" }'

TikTok Shop

A TikTok account enrolled in TikTok Shop has a showcase: the products its profile's Shop tab shows, and the only products a post can link. The phone reads the shop and adds products to the showcase as device jobs; a scheduled post links one with tiktok_product_id. An account whose seat was cancelled and whose paid period has ended runs nothing more: a request that would queue work on it answers 409 { error_code: "account_seat_ended", message }. An archived account (one a recreation replaced, or that was signed out of its phone) is frozen: every request that would run or change anything on it answers 409 { error_code: "account_archived", message }, and only Pamba support can bring it back. An account the platform took out of use (TikTok or Instagram banned it, its BANNED health flag; Meta suspended it or Instagram asks it for identity verification, its SUSPENDED or IDENTITY_VERIFICATION flag) takes nothing more: every request that would run or change anything on it answers 409 with error_code account_banned or account_suspended and a sentence naming the account (in message, or in error on a schedule), and its pending posts fail with the same code and sentence (a TikTok ban about an hour after Pamba finds it, before which its work waits).

Read a TikTok account's TikTok Shop as its phone last saw it.

Authentication

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

Request

Path { username } (with or without @, case-insensitive).

Response

200 { username, linked, synced_at, catalog_synced_at, reading, adding, shoppable_videos_left, showcase_complete, catalog_complete, products: [{ id, title, display_title, price, stock, thumbnail_url, showcase_state: "in_showcase" | "adding" | "add_failed" | "not_in_showcase", listed, default_link_name }] }. 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones.

Notes

linked is null until the phone has read the account's shop once (POST /devices/tiktok/accounts/{username}/shop/sync); false means the account is not in TikTok Shop, and products is then empty. reading is true while a read is queued or running on the phone; adding while a showcase add is queued or running, or any product still reads as being added. products lists every product the shop's catalog showed, with listed false for one its latest read no longer showed. showcase_state in_showcase is what the profile's Shop tab shows; add_failed means the phone could not add the product (the shop stopped listing it, or the add did not land). shoppable_videos_left is TikTok's pilot counter as the latest post that linked a product left it, null until one has. title is the product's title as the phone read it off the screen, ending in … where the screen cut a long one short; display_title is the name the workspace gave the product (PUT /devices/tiktok/accounts/{username}/shop/products/{productId}/display-title), null when it gave none, and is the name to show. default_link_name is the name a post's product link shows when the post names none, made from display_title when set. showcase_complete and catalog_complete are false when the phone's latest read could not read a list whole (past about 80 products per list, or a product it saw but could not read): products it missed are missing from products, a product the showcase read did not reach keeps the state it had, and the Pamba team is alerted.

Example

curl https://api.pamba.app/devices/tiktok/accounts/blackfridayjenn/shop \
  -H "X-API-Key: $PAMBA_API_KEY"

Read the account's TikTok Shop off its phone: enrollment, showcase and the shop's catalog.

Authentication

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

Request

Path { username }; no body.

Response

202 { queued: true, job_id, shop } with shop in the GET shape (reading true). 404 { error_code: "account_not_found", message }. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended.

Notes

Runs on the phone as a device job, a couple of minutes between its posts and warming sessions; poll GET /devices/tiktok/accounts/{username}/shop until reading is false. One read per account at a time: a request while one is queued rides it.

Add products of the account's shop to its showcase.

Authentication

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

Request

Path { username }; JSON body { product_ids: [string] }, 1 to 20 ids from GET /devices/tiktok/accounts/{username}/shop.

Response

202 { queued: true, job_id, shop } with each product asked for at showcase_state adding. 200 { queued: false, job_id: null, shop } when every product is already in the showcase. 400 { error_code: invalid_product_id | invalid_product_ids | tiktok_shop_product_not_found, message }; 404 { error_code: "account_not_found", message }; 409 { error_code: "tiktok_shop_not_linked", message } when the account is not in TikTok Shop or its shop has not been read yet. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended.

Notes

Runs on the phone as a device job; each product reads adding until the phone confirms it from the Showcase page (in_showcase) or could not add it (add_failed). Products asked for while one add is queued ride it. Scheduling a post whose product is not in the showcase queues this for that product.

Name a product of the account's shop the way people should read it, or take the name off.

Authentication

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

Request

Path { username, productId } (an id from GET /devices/tiktok/accounts/{username}/shop); JSON body { display_title: string | null }, at most 200 characters; null or blank takes the name off.

Response

200 with the shop in the GET shape, the product carrying its display_title. 400 { error_code: invalid_product_id | invalid_display_title, message }; 404 { error_code: account_not_found | tiktok_shop_product_not_found, message }.

Notes

Only people read the name: the phone keeps finding the product by its title, which a later read of the shop may make fuller but never shorter, and a read never changes display_title. The product's default_link_name follows the name; a post already scheduled keeps its link's name. Runs at once, with no device job.

Example

curl -X PUT https://api.pamba.app/devices/tiktok/accounts/blackfridayjenn/shop/products/3f1c2a9e-5b7d-4c1e-9a2b-6d8e0f4a1b2c/display-title \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "display_title": "Glow Serum Vitamin C" }'

Following Accounts

Have one of the workspace's TikTok or Instagram accounts follow a list of other accounts; the phone follows them as a device job, at most one a day and none in a new account's first 7 days. An account whose seat was cancelled and whose paid period has ended runs nothing more: a request that would queue work on it answers 409 { error_code: "account_seat_ended", message }. An archived account (one a recreation replaced, or that was signed out of its phone) is frozen: every request that would run or change anything on it answers 409 { error_code: "account_archived", message }, and only Pamba support can bring it back. An account the platform took out of use (TikTok or Instagram banned it, its BANNED health flag; Meta suspended it or Instagram asks it for identity verification, its SUSPENDED or IDENTITY_VERIFICATION flag) takes nothing more: every request that would run or change anything on it answers 409 with error_code account_banned or account_suspended and a sentence naming the account (in message, or in error on a schedule), and its pending posts fail with the same code and sentence (a TikTok ban about an hour after Pamba finds it, before which its work waits).

Queue a list of accounts for one of the workspace's accounts to follow.

Authentication

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

Request

JSON body { platform: "tiktok" | "instagram", username: the workspace's account that follows (with or without @), targets: ["handle", ...] }. A target may be written as a handle, an @handle or a profile link on the account's own platform (https://www.tiktok.com/@handle, https://www.instagram.com/handle/; the other platform's link is invalid, since its handle names someone else here); targets are lowercased and de-duplicated.

Response

202 { job: FollowJob, added: ["handle", ...], skipped: [{ username, reason: "already_queued" | "self" }] }. FollowJob is { job_id, platform, username, status: "queued" | "running" | "completed" | "failed" | "cancelled", cancel_requested, targets: [{ username, outcome: "pending" | "followed" | "requested" | "already_following" | "not_found" | "failed" | "cancelled" | "skipped", reason (one plain sentence where the outcome needs more than its name, else null) }], followed_count, pending_count, stoppable_count (the waiting accounts a stop would cancel: those outside the session on the phone, 0 once a stop was asked for), next_session_at, error, created_at, completed_at }. 400 { error_code: "missing_username" | "missing_targets" | "invalid_targets" | "invalid_platform", message } (invalid_targets names the entries); 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones; 409 { error_code: "follow_queue_full" | "follow_list_full" | "recreation_in_progress", message } (follow_queue_full says how much room is left of the 50 waiting; follow_list_full says how much is left of the 100 accounts one request holds in all, and the next request opens once it finishes). 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended.

Notes

Answers at once; the following happens on the account's phone as a device job. An account's lists keep to a daily follow limit, because TikTok and Instagram can flag an account that follows too much, and warming's own follows neither wait for nor count toward it: none in its first 7 days on a Pamba phone (counted from its first warming session or follow list), so the first follow lands once the account is a week old, then at most 1 a day, however old the account; a session follows no more than the day has left, and a list with nothing left today waits for the next day the limit allows (next_session_at). The phone follows one account per session, at a different time each day between 8 AM and 1 AM Pacific, each session starting when the phone is free around posts and warming; a session that followed no one (already_following, not_found) leaves the day's follow for the next session, at least 3 hours later (up to 30 minutes more). The pause holds per account: a request that opens a new list within 3 hours of the account's last session waits out the rest of it (next_session_at). At most 50 accounts wait at once on the request that takes new names: while the account has a request queued or running, new targets join it (added), and targets it already holds come back in skipped as already_queued. A request being stopped takes nothing new, so a POST then opens a new request (a new job_id) while the stopped one's session finishes. The account's own handle is skipped as self. Outcomes: followed (the phone confirmed it), requested (a private account; a follow request was sent), already_following (nothing to tap), not_found (the app showed its own not-found page), failed (the phone could not follow it or confirm the follow; a mistyped or deleted username usually lands here, with a reason asking to check the username), cancelled (stopped before its session), skipped (the request failed before reaching it; a stop reads cancelled). followed_count counts followed, requested and already_following. status queued covers the wait before the first session and between sessions, with next_session_at saying when the next one is due; error is one plain sentence on a failed request, or on one Pamba stopped itself (the account recreated, gone from the workspace or from its phone), and null otherwise.

Example

curl -X POST https://api.pamba.app/devices/accounts/follows \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"platform": "tiktok", "username": "maya.makes", "targets": ["@latteart.lab", "https://www.tiktok.com/@homecafe"]}'

An account's follow request in progress and its latest finished one.

Authentication

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

Request

Query platform (tiktok | instagram) and username; no body.

Response

200 { active: FollowJob | null, recent: [FollowJob, ...], max_pending: 50, max_list_size: 100, session_size: 1, session_gap_minutes: 180, daily_limit: { today, max, starts_at } }, FollowJob as in POST /devices/accounts/follows. daily_limit is how many accounts the account's lists may follow in the current warming day (today, 0 while a new account's first 7 days last; max is what a day allows once they are over, 1 unless the Pamba team raised it) and, during those days, the day its lists start following (starts_at, ISO 8601 UTC: that day's 8 AM Pacific opening, the first follow landing at a random time of it; null after). 400 { error_code: "invalid_platform" | "missing_username", message }; 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones.

Notes

active is the request that takes new accounts now, queued or running, or, when the only one left is finishing after a stop, that one; recent holds the latest finished request, and older ones are follow_request items of GET /devices/accounts/activity. Poll it to watch outcomes arrive: each handle's outcome lands when its session ends. Finished requests also appear in GET /devices/accounts/activity as follow_request items.

Stop a follow request: the accounts still waiting are not followed.

Authentication

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

Request

Path { jobId }: FollowJob.job_id; no body.

Response

200 { job: FollowJob }: status cancelled at once, or completed when nothing was left waiting; with a session on the phone, running (queued while a server restart hands the session over) with cancel_requested true while that session finishes (the accounts past it read cancelled at once), then cancelled (a session lost on the way included), completed when that session reached every account left, or failed when the session fails for good (the app's rate limit, the account signed out or missing on the phone, a phone error), the accounts past it still cancelled. 404 { error_code: "follow_job_not_found", message } when the request is not one of the workspace's; 409 { error_code: "follow_job_finished", message } when it already finished.

Notes

Waiting targets become cancelled. A session already on the phone finishes its own accounts (at most 5) before the request stops, so a stop never cuts a follow in half; accounts already followed stay followed.

Deleting and Archiving Posts

Delete published posts (TikTok videos and photo posts; Instagram reels, carousels and pictures) from one of the workspace's TikTok or Instagram accounts, or archive them (off the profile without deleting them) and bring them back; the phone works through them a few at a time as a device job, proving each post on screen first. An account whose seat was cancelled and whose paid period has ended runs nothing more: a request that would queue work on it answers 409 { error_code: "account_seat_ended", message }. An archived account (one a recreation replaced, or that was signed out of its phone) is frozen: every request that would run or change anything on it answers 409 { error_code: "account_archived", message }, and only Pamba support can bring it back. An account the platform took out of use (TikTok or Instagram banned it, its BANNED health flag; Meta suspended it or Instagram asks it for identity verification, its SUSPENDED or IDENTITY_VERIFICATION flag) takes nothing more: every request that would run or change anything on it answers 409 with error_code account_banned or account_suspended and a sentence naming the account (in message, or in error on a schedule), and its pending posts fail with the same code and sentence (a TikTok ban about an hour after Pamba finds it, before which its work waits).

An account's published posts, each with its deletion and archive state, and its running deletion and archives.

Authentication

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

Request

Query platform (tiktok | instagram), username, and optionally post_ids (comma-separated, at most 100: post ids or the posts' links, such as a scheduled post's posted_tiktok_video_url); no body.

Response

200 { posts: [{ post_id, url, kind, caption, title, cover_url, posted_at, views, deletion: { job_id, state, reason } | null, listed, archive: { job_id, action, state, reason } | null, archived }], active: PostDeletionJob | null, max_per_request: 20, max_waiting: 50, active_archives: [PostArchiveJob], max_per_archive_request: 20, archivable_count, archived_count }. post_id is TikTok's post id or the Instagram post's shortcode. kind is "video" (a TikTok video, an Instagram reel), "slideshow" (a TikTok photo post, an Instagram carousel) or "picture" (an Instagram post of one picture). deletion is what the account's newest deletion that named the post did, its state one of PostDeletionJob's post states; archive is what the account's newest archive or unarchive that named it did (action archive | unarchive, state one of PostArchiveJob's post states), and archived is true while the post is off the profile as that one left it (archived by Pamba, or found already off the profile by an archive, or an unarchive not finished yet). 400 { error_code: "invalid_platform" | "missing_username" | "too_many_posts", message }; 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones.

Notes

The newest 100 posts, as Pamba's analytics read last saw them, so a post published minutes ago may not be listed yet. title is the title Pamba generated for a TikTok video without a caption of its own; caption is the caption the post shows. active is the deletion queued or running on the account, active_archives its archive and unarchive queued or running (POST /devices/accounts/post-archives). Poll it to watch each post's state change. Without post_ids, posts Pamba archived are listed too once the platform stops listing them (listed false), in their place by when they were posted, the listing still the newest 100; and archivable_count (the posts up now that archiving all would take, at most 500) and archived_count (the posts Pamba took off the profile, which unarchiving all would bring back; not a post an archive found already off it) are given; with post_ids both counts are null. With post_ids the listing holds only those posts, and also a post the platform no longer lists that one of the account's deletions or archives named (as that one recorded it, with no cover or views, and listed false: a deletion request refuses it), so a deleted post keeps its state. Every other post reads listed true.

Queue published posts of one of the workspace's accounts for deletion from the platform.

Authentication

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

Request

JSON body { platform: "tiktok" | "instagram", username: the workspace's account (with or without @), post_ids: ["post_id", ...] } with post_id values from GET /devices/accounts/posts or the posts' links (a TikTok video or photo link, an Instagram permalink, such as a scheduled post's posted_tiktok_video_url or posted_instagram_video_url), at most 20.

Response

202 { job: PostDeletionJob, added: ["post_id", ...], already_queued: ["post_id", ...] }. PostDeletionJob is { job_id, platform, username, status: "queued" | "running" | "completed" | "failed" | "cancelled", posts: [{ post_id, state: "pending" | "deleting" | "deleted" | "not_found" | "not_confirmed" | "delete_unconfirmed" | "failed" | "cancelled" | "skipped", reason }], deleted_count, pending_count, stoppable_count (the waiting posts a stop would cancel: those outside the session on the phone, 0 once a stop was asked for), error, created_at, completed_at }. 400 { error_code: "missing_username" | "missing_posts" | "too_many_posts" | "unknown_posts" | "invalid_platform", message } (unknown_posts names the ids that are not the account's posts); 404 { error_code: "account_not_found", message } when the account is not on one of the workspace's phones; 409 { error_code: "delete_queue_full" | "recreation_in_progress" | "post_archived", message } (delete_queue_full says how much room is left of the 50 waiting; post_archived names posts archived or being archived, or that an archive may have changed without confirming, which are unarchived first). 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended.

Notes

This deletes the posts from TikTok or Instagram itself, not just from Pamba. TikTok and Instagram keep a deleted post 30 days in Recently deleted, where only someone on the phone can restore it. Answers at once; the deleting happens on the account's phone as a device job, 5 posts per session, each session starting when the phone is free around posts and warming. Before deleting a post the phone proves it is the one asked for: a TikTok post (video or photo post) by the minute its analytics page says it was posted, an Instagram post by the page its link (a carousel's or picture's media id) opened, with the account as its author. While the account has a deletion queued or running, new posts join it (added), and posts it already holds as waiting or gone come back in already_queued. A deletion being stopped takes nothing new, so a POST then opens a new deletion (a new job_id) while the stopped one's session finishes. A post on the account's analytics that its pulls stopped returning (an Instagram reel removed from the app) is neither listed nor deleted. States: deleted (deleted, and its link then said it is gone), not_found (gone before anything was tapped: deleted already), not_confirmed (the phone could not prove the post on screen was this one and deleted nothing), delete_unconfirmed (the delete was confirmed, or the post was handed to the phone in a session nobody heard back from, but its link did not show it gone; not final: Pamba checks the account's own listing about fifteen minutes after the session and settles the post deleted once the account no longer lists it, or failed when it still shows it; a post the listing cannot see, such as an Only-you TikTok post or an Instagram reel off the profile grid, is settled by Pamba's team instead), failed (the phone could not reach its Delete, so nothing was deleted; or the account still shows the post after the phone's delete, its reason saying so), cancelled (stopped before the phone reached it), skipped (the deletion failed before reaching it), deleting (in the session on the phone now). error is one plain sentence on a failed deletion.

Example

curl -X POST https://api.pamba.app/devices/accounts/post-deletions \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"platform": "tiktok", "username": "maya.makes", "post_ids": ["7693220600046734623", "7692898639651294495"]}'

Archive every post an account has up now, or bring back every post Pamba archived: the way to hide an account's posts.

Authentication

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

Request

JSON body { platform: "tiktok" | "instagram", username: the workspace's account (with or without @), action: "archive" (the default) | "unarchive" }; no post ids.

Response

202 { job: PostArchiveJob, added: ["post_id", ...], already_queued: ["post_id", ...], more_left }. PostArchiveJob is { job_id, platform, username, action: "archive" | "unarchive", status: "queued" | "running" | "completed" | "failed" | "cancelled", posts: [{ post_id, state: "pending" | "archiving" | "archived" | "unarchiving" | "unarchived" | "not_found" | "not_confirmed" | "failed" | "cancelled" | "skipped", reason }], done_count, pending_count, stoppable_count, error, created_at, completed_at }. more_left counts the posts left out past the 500 one account can have waiting; ask again once these are done. 400 { error_code: "invalid_platform" | "missing_username" | "invalid_action", message }; 404 { error_code: "account_not_found", message }; 409 { error_code: "no_posts" | "post_busy" | "post_being_deleted" | "archive_queue_full" | "recreation_in_progress" | "account_seat_ended", message } (no_posts: nothing up to archive, or nothing Pamba archived to bring back; post_busy: some posts wait in the account's job of the other action; post_being_deleted: some wait in its deletion).

Notes

To hide all or most of an account's posts, call this once rather than POST /devices/accounts/post-archives post by post: it takes every post at once with no ids and no listing first. Archiving keeps each post, with its views, likes and comments: TikTok sets it to Only you, Instagram moves it to its Archive, and unarchiving puts it back in the same spot. It is a snapshot: posts that go out after the call stay public (pause the account's posting to stop new ones). archive takes every post Pamba's analytics read sees up on the account now, leaving out posts deleted or being deleted through Pamba and posts already archived; unarchive takes every post Pamba archived on the account that is still off the profile, and any an archive may have changed without confirming (posts archived outside Pamba are not included). Answers at once; the phone works through the posts as a device job, 20 per session (about half an hour), sessions 20 minutes apart so the account's posts and warming carry on, proving each post before it changes it (as a deletion proves it; an unarchive on TikTok by the post's Private label) and reading it back after. Posts already in the account's job of the same action come back in already_queued. Facebook posts are not archived.

Example

curl -X POST https://api.pamba.app/devices/accounts/post-archives/all \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"platform": "instagram", "username": "maya.makes", "action": "archive"}'

Archive (or unarchive) a few named posts of one of the workspace's accounts.

Authentication

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

Request

JSON body { platform: "tiktok" | "instagram", username, post_ids: ["post_id", ...], action: "archive" (the default) | "unarchive" } with post_id values from GET /devices/accounts/posts or the posts' links, at most 20.

Response

202 { job: PostArchiveJob, added: ["post_id", ...], already_queued: ["post_id", ...], more_left: 0 } (PostArchiveJob as on POST /devices/accounts/post-archives/all; already_queued also holds posts already as asked). 400 { error_code: "missing_username" | "missing_posts" | "too_many_posts" | "unknown_posts" | "invalid_platform" | "invalid_action", message }; 404 { error_code: "account_not_found", message }; 409 { error_code: "no_posts" | "post_deleted" | "post_being_deleted" | "post_busy" | "archive_queue_full" | "recreation_in_progress" | "account_seat_ended", message } (no_posts: every post named is already as asked and no job of that action is running; with one running, the 202 answers with that job).

Notes

For one or a few specific posts. To archive all or most of an account's posts, call POST /devices/accounts/post-archives/all instead: one call, every post. States: archived or unarchived (changed, and the post read back so), not_found (the platform says the post is not there), not_confirmed (the phone could not prove the post on screen was this one and changed nothing), failed (the phone could not make the change; or it may have made it without confirming, as its reason says: ask again, or unarchive it, which reads what the phone left, and a deletion refuses it until then), cancelled, skipped, archiving or unarchiving (in the session on the phone now). An archived post is refused by POST /devices/accounts/post-deletions (409 post_archived) until it is unarchived. A request while the account's archive runs joins it.

Example

curl -X POST https://api.pamba.app/devices/accounts/post-archives \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"platform": "tiktok", "username": "maya.makes", "post_ids": ["7693220600046734623"], "action": "archive"}'

Stop an archive or unarchive: the posts still waiting are left as they are.

Authentication

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

Request

Path { jobId }: PostArchiveJob.job_id; no body.

Response

200 { job: PostArchiveJob }: waiting posts read cancelled at once. 404 { error_code: "archive_not_found", message } when the job is not one of the workspace's archives; 409 { error_code: "archive_finished", message } when it already finished.

Notes

A session already on the phone finishes its own posts (at most 20) first. Posts already archived stay archived: to bring back only those, name them in POST /devices/accounts/post-archives with action unarchive (POST /devices/accounts/post-archives/all with action unarchive brings back every post Pamba archived on the account).

Stop a post deletion: the posts still waiting are not deleted.

Authentication

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

Request

Path { jobId }: PostDeletionJob.job_id; no body.

Response

200 { job: PostDeletionJob }: waiting posts read cancelled at once. With no session on the phone the deletion ends at once (status cancelled, or completed when some posts were already settled); with one, it stays running until that session's posts are settled. 404 { error_code: "deletion_not_found", message } when the deletion is not one of the workspace's; 409 { error_code: "deletion_finished", message } when it already finished.

Notes

A session already on the phone finishes its own posts (at most 5) first, so a stop never lands between a Delete and its confirmation; posts already deleted stay deleted.

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.

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, reason_explanation, posted_on, evidence_urls, opened_at, appealable, appeal_requested_at, appeal_processed_at, appeal_verdict, appeal_decided_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 a daily health check on the account's phone. Issue kinds are ACCOUNT_WARNING, VIDEO_REMOVED and VIDEO_NOT_RECOMMENDED, all appealable; a video kind opens one issue per video. reason is the policy TikTok names on the video's restriction page, null until a check read that page; reason_explanation is TikTok's text under it as far as the page showed it (a cut one ends in an ellipsis), null when there is none. An appeal's progress is appeal_requested_at (the workspace asked), appeal_processed_at (the appeal is on TikTok) and appeal_verdict (APPROVED or REJECTED, TikTok's verdict as read off the account's notifications, off the video's own page when the phone goes back to an appeal TikTok has not answered in four days, or an approval when a removed video comes back in the public listing, with appeal_decided_at). An approved appeal closes its issue, and the issue drops out of issues. A rejected appeal keeps its issue open. A video issue whose appeal is still being filed, or waits for TikTok's verdict (up to 21 days after filing), does not close on a clean check.

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 [].

Whether a TikTok account appeals its restrictions automatically.

Authentication

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

Request

Path { username } (with or without @); no body.

Response

200 { username, enabled, appeals_requested: 0 }. 404 when the account is not on one of the workspace's phones.

Turn a TikTok account's auto-appeal on or off.

Authentication

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

Request

Path { username }; body { enabled: boolean }.

Response

200 { username, enabled, appeals_requested }: appeals_requested counts the open issues appealed as the switch went on. 404 when the account is not on one of the workspace's phones.

Notes

While on, every restriction a daily check finds (VIDEO_REMOVED, VIDEO_NOT_RECOMMENDED, ACCOUNT_WARNING) is appealed at once, as if POST /devices/accounts/incidents/{id}/appeal had been called for it, and the workspace's email says the appeal is under way instead of asking. TikTok allows one appeal per issue. Off by default; turning it off leaves appeals already requested running.

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.

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 Instagram asks it for identity verification with a video selfie: detail.error_code ig_account_suspended or ig_identity_verification; the second reads as the IDENTITY_VERIFICATION health flag in GET /devices/accounts/inventory), ACCOUNT_WARNING, VIDEO_REMOVED, VIDEO_NOT_RECOMMENDED (TikTok health), FEATURE_RESTRICTED (Instagram limited one feature of the account until a date; its detail carries restriction, added_on and ends_on, it never stops posting, and it closes with closed_by RESTORED the day after its end date; one with no end date stays open until closed by hand), or BANNED (Pamba confirmed TikTok or Instagram banned the account, which takes nothing more: its pending posts fail with error_code account_banned and every new request on it answers 409 account_banned; Pamba replaces it with a new account on its seat at no extra cost (on TikTok, past its first day, the workspace is offered that new account itself: replacement_offer with reason banned on GET /devices/accounts/recreation), and closes the incident itself, with closed_by RESTORED when the account comes back or MANUAL once the new account replaces it; source MANUAL when Pamba's team marked it, DEVICE_VISION when Pamba confirmed a TikTok ban from the phone's notice and TikTok's public profile, and the account carries a BANNED health flag in GET /devices/accounts/inventory; a TikTok ban opens about an hour after Pamba finds it, or later when Pamba's team reviews several bans at once, and its opened_at is when it opened). source is DEVICE_VISION, LINKED_ACCOUNT or MANUAL; closed_by is CLEAN_SESSION, MANUAL, RESTORED or DELETED (a TikTok video issue whose video the workspace deleted through Pamba, closed once the deletion confirmed it gone, for issues opened before the deletion: its appeal stops, nothing is filed or followed for it; an ACCOUNT_WARNING given for that video stays open). When Pamba replaces an account with a new one, every open incident of the old account closes with closed_by MANUAL and its appeals stop. detail is a JSON object whose keys depend on the kind; a SUSPENDED detail carries error_code, session_kind, the session id and reason (one plain sentence for the code), plus linked_from { platform, account_key } when the incident was opened from a linked account's suspension; a TikTok health detail carries summary, caption, posted_on, view_count, restriction_reason, restriction_explanation, evidence_text, evidence_urls and the sighting bookkeeping. A username filter matches the lowercase handle, or the exact display name on Facebook. While an account's SUSPENDED incident is open it takes nothing more: its warming sessions are cancelled, its pending posts fail with error_code account_suspended, and every new request on it answers 409 account_suspended.

Appeal a TikTok health issue: the account's phone files it with TikTok and reads the verdict.

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; 409 { error_code: "account_banned" | "account_suspended", message } when the platform took the account out of use.

Notes

Only ACCOUNT_WARNING, VIDEO_REMOVED and VIDEO_NOT_RECOMMENDED can be appealed. The account's phone files the appeal itself, submitting only once a TikTok screen prints the video's posted time; an ACCOUNT_WARNING is appealed through the video TikTok removed with it (appealing it also appeals the one removed video the same daily check found, and one verdict decides both; with none or several, Pamba's team files it by hand); a video the phone cannot pin down is filed by Pamba's team by hand. A second call returns ALREADY_REQUESTED with the original time. Progress shows as appeal_requested_at, appeal_processed_at and appeal_verdict on the health issue.

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, or 409 { code: "pamba_closes", message } when it is a BANNED incident, which only Pamba closes.

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. Closing a FEATURE_RESTRICTED incident only takes it off the account rows: Instagram keeps enforcing the restriction until its end date, and the incident opens again if a job meets Instagram's restriction page again. Closing one on sending messages also puts the comment automation messages it held (detail messaging_restricted in GET /comments/automations/{automationId}/activity) back in line within about 15 minutes, which Instagram refuses while it still enforces the restriction.

One account's activity feed: warming sessions, health checks, posts, profile edits, follow requests, post deletions and the comments it posted, 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" | "follow_request" | "post_deletion" | "post_archive" | "comment" | "comment_like" | "comment_pin" | "comment_delete", 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, follows (creators the session followed), followed_accounts (who, each { username, confirmed }), search_term, screenshot_count, sample_screenshot_urls, and for the longer warming Enhanced Warming gives around a post chain_stage (before_post or after_post) and chain_post_id; health_check (TikTok only) adds account_warning, issue_count, screenshot_count; post adds media (video, or slideshow for a slideshow's photo post), title, caption, error_message, screenshot_count; profile_edit adds job_kind, fields, failed_fields, failed_field_reasons (each { field, reason }: why a field the phone reported was not applied; a completed edit with failed fields applied the rest), error_message; follow_request (a finished request of POST /devices/accounts/follows, status completed, failed or cancelled) adds follow_targets (each { username, outcome, reason }, the outcomes of that route) and error_message (that route's error: why the request failed, or why Pamba stopped it itself); post_deletion (a finished deletion of POST /devices/accounts/post-deletions, status completed, failed or cancelled) adds deleted_posts (each { post_id, state, reason }, post states of that route) and error_message; post_archive (a finished archive or unarchive of POST /devices/accounts/post-archives or /all) adds post_action (archive | unarchive), archive_posts (each { post_id, state, reason }) and error_message; comment (a comment or reply the account posted through POST /comments/{commentId}/replies or POST /comments/videos/{platform}/{videoId}, comment_action comment; comment_like, comment_pin and comment_delete are a like, pin or delete it made through POST /comments/{commentId}/likes, POST /comments/{commentId}/pin or DELETE /comments/{commentId}, with comment_action like, pin or delete, the same fields and an empty comment_text, reply_to_username and reply_to_text naming the comment it liked, pinned or deleted; once a phone took it, or once it failed without one; status queued (back in line for another attempt after one failed), sending, posted, live, not_visible, failed or cancelled, dated by when it was sent, else the phone's latest attempt, else the request) adds comment_text, reply_to_username and reply_to_text (a reply's comment), video_url, caption (the video's), video_owner_username (when the video is another of the workspace's accounts'), error_message (why it failed, is not showing or waits for another attempt), screenshot_count, sample_screenshot_urls and screenshot_urls (every frame the phone took of it, in order). Fields a kind does not carry are null or absent. Page by passing next_before back as before; null ends the feed.

When an account's phone is free to post: the stretches a post can start in, and what holds the phone.

Authentication

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

Request

Query platform (tiktok | instagram), username, optional from and to (ISO 8601 with an offset, each within a year of now; from defaults to now, to to 7 days after from, at most 14 days after it); no body.

Response

200 { platform, username, from, to, earliest_post_at, post_minutes, openings: [{ start, end }], busy: [{ start, end, kind: "post" | "task" | "maintenance", post_id, post_platform, post_username }] }, every time UTC. 400 when platform or username is missing, a time is malformed or more than a year from now, to is not after from, or the range is longer than 14 days; 404 when the account is not on one of the workspace's phones.

Notes

A phone runs one post or task at a time for every account on it: a TikTok scheduled_at that overlaps another post, a task running on the phone or booked on it to set up an account, or a maintenance window is refused as 409 schedule_conflict (400 with error_code schedule_conflict on PUT /devices/tiktok/posts/{postId}), and an Instagram one is moved to the next free slot that day, in Pacific time (refused only when the day has no room left). Any whole minute in an opening, both ends included, is a scheduled_at the phone keeps as picked (a schedule with automate, or while its video is still being finished, waits at least 30 minutes from the request instead); openings start no earlier than earliest_post_at, which moves with the clock, so a pick must still be that far ahead when it is sent. post_minutes is how long one post holds the phone: space exact picks on one phone at least that far apart. When a TikTok and an Instagram account share a phone, the Instagram post takes the first free time after the TikTok one that day (right after it when the phone is free), and a day with no room left for it refuses both, so leave post_minutes free after a TikTok time that cross-posts; a Facebook share rides the Instagram post. A post being moved (PUT /devices/tiktok/posts/{postId}) is not blocked by itself or its pending cross-posts; the cross-posts then follow it to the first free time after the new time that day, and one with no room left keeps its old time, so leave post_minutes free after the new time for each cross-post on the same phone. busy lists the phone's posts (post_id, post_platform and post_username on the workspace's own), the work running on it and the account setup booked on it, a new account's signup at the time it is expected to reach the phone (task), and maintenance; warming sessions give way to a post and are not listed. The openings are a read, not a booking: another schedule can take the time first, and on rare occasions the fleet-wide cap on posts at one time refuses a pick inside one, with the next free time in the refusal.

Operator Account Creation

Create a platform account on a specific phone for an avatar and follow the signup; operators only, customers buy seats instead.

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 { device_udid, replace_username?, identity_name? }. device_udid is the phone's identifier from the operator device listing; 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, device_udid }. 400 on a malformed avatar_project_id; 404 when the avatar has no selected avatar or the phone is not one the fleet lists; 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.

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, device_udid, 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.

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, device_udid }. 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}.

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 { device_udid, full_name? }. device_udid is the phone's identifier from the operator device listing; full_name overrides the avatar's name on the new profile.

Response

202 { job_id, device_udid }. 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}.

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, device_udid, 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.

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, device_udid, 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.

Comments

Read the comments on the workspace's own TikTok and Instagram videos (the accounts it pays for: on its Pamba phones, or under a live seat; an account it only tracks for analytics has none), by video or by thread; reply, comment, like and pin from any of the workspace's accounts on that platform, now or at a scheduled time, and delete comments from its own videos through the video's own account; the account's phone does it.

List the comments on the workspace's own videos, newest, most liked or the platform's top first, each with its replies.

Authentication

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

Request

Query platform (tiktok | instagram), username (the account that owns the videos, with or without @), video_id (a TikTok video id or an Instagram shortcode), questions_only (true | false, default false), mine (true | false, default false: only threads where one of the workspace's accounts wrote the comment or a reply, or asked Pamba to reply), q (case-insensitive search over the comment's text, its author's handle and display name, and its replies' text and authors), sort (newest | most_liked | top, default newest), cursor (next_cursor from the page before, read with the same sort), limit (default 50, at most 100); all optional, no body.

Response

200 { threads: [{ comment: Comment, replies: [Comment, ...], outgoing: [OutgoingComment, ...], actions: [OutgoingComment, ...], reply_actions: [OutgoingComment, ...], automations: [{ automation_id, automation_name, status, detail, note, next_attempt_at, sent_at, follow_up_status, follow_up_detail, replied_at }, ...], video: Video }], next_cursor, synced_at, accounts: [{ platform, username, can_reply, reply_blocked_reason, cross_account_blocked_reason, cross_account_available_at, cross_account_like_blocked_reason, cross_account_like_available_at, ai_comment_block_state, auto_block_ai_comments, phone_mates }, ...], cross_account_per_day, cross_account_likes_per_day, checked_at }. Comment is { id, platform: "tiktok" | "instagram", platform_comment_id, author_username, author_display_name, text, like_count, reply_count, posted_at, by_account, is_question, about_ai, hidden_by_platform }. Video is { platform, video_id, account_username, caption, cover_url, url, posted_at, comment_count, can_reply, reply_blocked_reason: null | "not_on_phone" | "account_being_recreated", source: "own", can_pin, author_display_name, can_delete }. OutgoingComment as in GET /comments/outgoing.

Notes

Each thread is a top-level comment, and its replies come oldest first. sort orders the threads: newest by the comment's posted_at; most_liked by its like_count, newest first among equal likes; top in the order TikTok or Instagram itself ranks the video's comments (neither by likes nor by time), as of Pamba's last read of the video (on TikTok a comment that read missed keeps the place an earlier read gave it), comments with no place after the ranked ones, newest first; across videos, the comments ranked first on each come first. Pass next_cursor as cursor, with the same sort, for the next page (null on the last page). A cursor that is not a next_cursor of that sort is refused with 400 { error_code: "invalid_cursor", message }, an unknown sort with 400. Pamba reads the comments on the workspace's accounts' videos from the last 60 days whenever a video's comment count grows, so a new comment shows up within about an hour; checked_at is when Pamba last checked the accounts' videos for new comments, whether or not one was new (null before any check and when the workspace has no account of its own to check), synced_at the newest read across the listed videos. by_account is true when the video's own account wrote the comment. is_question is true for a comment that asks the creator something or asks for something the video offered (a link, a part 2, a keyword's resource), false for everything else and for the account's own comments, and null until the comment has been classified; questions_only true lists only the comments classified as questions. about_ai is true for a comment that says, asks or suspects that the video, the person in it or their voice is AI-made, a bot or not a real person (a comment about AI as a subject is not), false for everything else and for the account's own comments, and null until the comment has been judged. hidden_by_platform is true for a comment Instagram hides from the post's comments (it shows only under "See hidden comments", by Instagram's own filter or the account's choice), and for the replies under it: the phones look for a comment in the post's list, so replies, likes, pins and deletes of it are refused (comment_hidden) and comment automations skip it; it is false until a read of the reel says so. outgoing holds the replies this workspace sent to the comment that are not live yet; once live, a reply is one of replies. actions holds the workspace's likes, pins and deletes of the comment, every status but cancelled; a comment whose delete went live is no longer listed. automations holds what the workspace's comment automations did with the comment (GET /comments/automations/{automationId}/activity lists the same, with the same status, detail and note). accounts lists every account whose videos' comments the workspace sees (the accounts it pays for: on its Pamba phones, or under a live seat; an account it only tracks for analytics is not listed), whatever the filters, and which of them can post (for an account picker). can_reply is false when the account is off the workspace's phones right now (not_on_phone: a seat whose login the platform signed out, or whose phone is being swapped) or is being recreated (account_being_recreated); a video's can_reply is its own account's. can_pin is true on the workspace's own Instagram reel whose account is on one of its phones, and can_delete on the workspace's own video (TikTok or Instagram) whose account is on one of its phones. cross_account_blocked_reason says why the account cannot comment on other accounts' videos (the workspace's others) yet, until cross_account_available_at (ISO-8601 UTC): new_account in the first 7 days after Pamba created it, cross_account_daily_limit while cross_account_per_day of its comments on other accounts' videos are on their way or went out in the last 24 hours; both are null when it can or cannot post at all (can_reply false), and its own videos are never held. cross_account_per_day is the workspace's limit, 1 unless the Pamba team raised it. cross_account_like_blocked_reason (cross_account_daily_limit) says the account cannot like comments on other accounts' videos until cross_account_like_available_at, while cross_account_likes_per_day of its likes on them are on their way or went out in the last 24 hours; cross_account_likes_per_day is the workspace's limit on those likes, 1 unless the Pamba team raised it. phone_mates lists the workspace's other accounts on the platform signed in on the same phone as the account: it never comments on their videos, nor replies to or likes a comment on them or one they wrote (same_phone). ai_comment_block_state is where a TikTok account's AI-comment filter stands (applied: on; queued or scheduled: on its way; failed: the last attempt failed; off), for an account on one of the workspace's phones, and null for an Instagram account or one on no workspace phone; auto_block_ai_comments beside it is whether Pamba may turn the filter on by itself (false after the user allowed AI comments). PATCH /devices/tiktok/accounts/{username}/profile with disable_ai_comments and auto_block_ai_comments both true turns the filter on and keeps it Pamba's to manage.

List the workspace's own videos that have comments and its recent posts before their first, newest activity first.

Authentication

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

Request

Query platform, username, video_id, questions_only, mine and q as for GET /comments; cursor (next_cursor from the page before), limit (default 30, at most 100); all optional, no body.

Response

200 { videos: [{ video: Video, thread_count, question_count, pending_count, latest_activity_at }, ...], next_cursor, synced_at, accounts: [{ platform, username, can_reply, reply_blocked_reason, cross_account_blocked_reason, cross_account_available_at, cross_account_like_blocked_reason, cross_account_like_available_at, ai_comment_block_state, auto_block_ai_comments, phone_mates }, ...], cross_account_per_day, cross_account_likes_per_day, checked_at, automations_on }. Video as in GET /comments. 400 { error_code: "invalid_cursor", message } for a cursor that is not a next_cursor.

Notes

One row per video with a top-level comment that matches the filters: thread_count counts them and question_count those judged a question or a request. pending_count counts the workspace's comments and replies on the video still on their way (queued, sending or posted). A video with a new comment of the workspace's that is not live (on its way, failed or not showing) is listed even before any comment on it was read, unless questions_only or q is set. Unless questions_only, q or mine is set, every video posted in the last 7 days, and the video video_id names, is listed even with no comment (thread_count 0), so an account can leave the first with POST /comments/videos/{platform}/{videoId}. latest_activity_at is the newest matching comment's time (or the newest new comment of the workspace's asked for, or the post's own time), the order of the list. checked_at and synced_at are as in GET /comments; automations_on counts the workspace's comment automations that are on. Read a video's threads with GET /comments?platform=…&video_id=… and the same filters.

Reply to a comment from one of the workspace's accounts, now or at a scheduled time, or in advance to one of the workspace's own comments not live yet; the account's phone sends it.

Authentication

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

Request

Path { commentId: Comment.id, or the OutgoingComment.id of one of the workspace's own top-level comments to reply in advance }; JSON body { text, account?, scheduled_for? }. text is at most 150 characters on TikTok and 2200 on Instagram; on TikTok an @username in it is picked from TikTok's own list as the phone types it, so it posts as a mention showing that account's display name, and a username TikTok does not offer fails the comment unsent (failed, with a reason naming it). account is the handle of the workspace's account on the video's platform that replies; the video's own account when omitted. scheduled_for is an ISO-8601 time with its offset (2026-10-12T15:00:00Z) at least a minute and at most 30 days ahead; omitted, it goes out now.

Response

202 { outgoing: OutgoingComment } with status queued. 400 { error_code: "text_empty" | "text_too_long" | "account_not_found" | "invalid_scheduled_for" | "scheduled_for_in_past" | "scheduled_for_too_far", message }; 404 { error_code: "comment_not_found", message } when the comment is not on one of the workspace's videos; 409 { error_code: "not_on_phone" | "account_being_recreated" | "new_account" | "same_phone" | "reply_to_reply" | "chain_full" | "parent_not_live" | "comment_being_deleted" | "comment_hidden", message, available_at? }; 429 { error_code: "comment_limit_reached" | "cross_account_daily_limit", message, available_at }.

Notes

Answers at once. The reply goes out from the posting account's phone, as a device job, when the phone is free around posts and warming, and with scheduled_for within a few minutes of that time. What an account is asked to do now on one video (comments, replies, likes, pins and deletes) goes out together in one phone session, as a creator does it in one sitting: each request waits 2 minutes for the next, and the run goes out at most 10 minutes after its first, up to 10 in a session; requests with scheduled_for go out at their own times, so the same time groups them and different times keep them apart. Only a top-level comment takes a reply (reply_to_reply otherwise), and only from an account on one of the workspace's phones (not_on_phone otherwise; GET /comments lists which accounts can_reply). An account is asked for at most 30 comments in any rolling day (comment_limit_reached). On another account's video (another of the workspace's), TikTok and Instagram lock an account that comments too early or too often for suspicious activity, so an account Pamba created comments on them only after its first 7 days (new_account), and any account at most the workspace's cross_account_per_day (1 unless the Pamba team raised it) in any rolling day (cross_account_daily_limit). Two accounts signed in on the same phone never reply to each other: a reply from an account on the same phone as the video's account or as the comment's author is refused at any time (same_phone; GET /comments lists each account's phone_mates), and one that reaches the phone anyway, because the accounts came to share a phone after it was asked for, is cancelled with a status_detail saying why. Every limit counts at the time the reply goes out, the scheduled time when it has one, against the account's comments before and after it: a refusal's available_at is the first time the same request would pass, so passing it as scheduled_for queues it, and a comment can be scheduled a day past another. A comment that comes due inside a full day (its phone ran late) waits, queued, until the day has room. Follow it on GET /comments/outgoing, and cancel it with DELETE /comments/outgoing/{id} while it is still queued, scheduled ones included. To reply in advance, pass the OutgoingComment.id of one of the workspace's own top-level comments that is not live yet: the reply carries parent_outgoing_id and waits for it (waiting_for_id), and the replies after it go out one at a time in the order they were asked for, each once the one before it is live and 15 to 45 minutes after it was sent (and not before its scheduled_for). At most 3 replies wait behind one comment (chain_full past that); a comment that failed, was cancelled or is not showing takes none (parent_not_live), and when one fails, is cancelled or still does not show two days after it was sent while replies wait behind it, they are cancelled (status cancelled, with a status_detail saying why). Cancelling a comment withdraws the replies waiting behind it, as cancelled with no status_detail, like the comment itself. A reply to a live comment of the workspace's that has replies in advance behind it (on their way or live), by its Comment.id or its OutgoingComment.id, joins the end of that line, chain_full included; the OutgoingComment.id of a live comment replies to it as its Comment.id does.

Example

curl -X POST https://api.pamba.app/comments/3f1c2a9e-5b7d-4e21-9c0a-8d6f4b2e1a77/replies \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "It ships worldwide, link in bio!", "scheduled_for": "2026-10-12T15:00:00Z"}'

Post a new comment on one of the workspace's own videos from one of the workspace's accounts, now or at a scheduled time.

Authentication

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

Request

Path { platform: tiktok | instagram, videoId: Video.video_id (a TikTok video id or an Instagram shortcode) }; JSON body { text, account?, scheduled_for?, pin? }. text is at most 150 characters on TikTok and 2200 on Instagram, with an @username on TikTok picked as for a reply. account is the handle of the workspace's account on that platform that comments; the video's own account when omitted. scheduled_for as for a reply. pin (default false), on the workspace's own Instagram reel only, has the reel's own account pin the comment once it is live.

Response

202 { outgoing: OutgoingComment, pin: OutgoingComment | null } with parent_comment_id null and status queued; pin is the pin, with action pin, when one was asked for. 400 { error_code: "text_empty" | "text_too_long" | "account_not_found" | "invalid_scheduled_for" | "scheduled_for_in_past" | "scheduled_for_too_far", message }; 404 { error_code: "video_not_found", message } when the video is not one of the workspace's accounts' videos (an account it pays for; one it only tracks for analytics takes no comment); 409 { error_code: "not_on_phone" | "account_being_recreated" | "new_account" | "same_phone" | "pin_not_available", message, available_at? }; 429 { error_code: "comment_limit_reached" | "cross_account_daily_limit" | "action_limit_reached", message, available_at }.

Notes

Sent and followed like a reply: the posting account comments from its phone, and GET /comments/outgoing shows where the comment stands. From another account than the video's own, the reply's new_account and cross_account_daily_limit holds apply, and an account on the same phone as the video's account is refused (same_phone). With pin, the pin waits for the comment to go live, then goes out 15 to 45 minutes after it; it counts toward the video account's likes, pins and deletes (action_limit_reached), and both are stored or neither. A TikTok video cannot take a pin (pin_not_available): TikTok no longer lets creators pin comments.

Like a top-level comment from one of the workspace's accounts, now or at a scheduled time; the account's phone taps its heart.

Authentication

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

Request

Path { commentId: Comment.id }; JSON body { account?, scheduled_for? }. account is the handle of the workspace's account on the video's platform that likes it; the video's own account when omitted. scheduled_for as for a reply.

Response

202 { outgoing: OutgoingComment } with action like, text empty and status queued. 400 { error_code: "account_not_found" | "invalid_scheduled_for" | "scheduled_for_in_past" | "scheduled_for_too_far", message }; 404 { error_code: "comment_not_found", message }; 409 { error_code: "not_on_phone" | "account_being_recreated" | "same_phone" | "not_top_level" | "already_liked" | "comment_being_deleted" | "comment_hidden", message }; 429 { error_code: "action_limit_reached" | "cross_account_daily_limit", message, available_at }.

Notes

The phone opens the video, finds the comment among its top-level comments and taps its heart, unless it is already filled. The like is live once the phone saw the heart filled, and stays posted when the phone tapped it and could not confirm it took (no read of the platform can tell whose like it is; it is never tapped twice). One like per account and comment (already_liked while one is on its way or done). An account likes, pins or deletes at most 30 comments in any rolling day, counted at the time each goes out (action_limit_reached); likes do not count toward the comment limits. A like from an account that does not own the video (on another of the workspace's accounts' videos) keeps to the workspace's cross_account_likes_per_day (1 unless the Pamba team raised it) in any rolling day, so TikTok and Instagram do not lock the account for suspicious activity (cross_account_daily_limit, with available_at); the video's own account is never held. No account likes a comment on the video of, or written by, another account on its phone (same_phone), as for a reply. Likes in bulk are spread by giving each its scheduled_for: a refusal's available_at passed as scheduled_for queues it. Asked for now, a like goes out with the account's other actions on that video, as a reply does. GET /comments lists it in the thread's actions.

Example

curl -X POST https://api.pamba.app/comments/3f1c2a9e-5b7d-4e21-9c0a-8d6f4b2e1a77/likes \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"account": "jane.budgets"}'

Pin a top-level comment on one of the workspace's own Instagram reels, from the reel's own account.

Authentication

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

Request

Path { commentId: Comment.id, or the OutgoingComment.id of the workspace's own top-level comment on the video }; JSON body { scheduled_for? }, as for a reply.

Response

202 { outgoing: OutgoingComment } with action pin, text empty and status queued. 400 { error_code: "invalid_scheduled_for" | "scheduled_for_in_past" | "scheduled_for_too_far", message }; 404 { error_code: "comment_not_found", message }; 409 { error_code: "pin_not_available" | "not_top_level" | "already_pinned" | "not_on_phone" | "account_being_recreated" | "parent_not_live" | "comment_being_deleted" | "comment_hidden", message }; 429 { error_code: "action_limit_reached", message, available_at }.

Notes

Only a video's own account can pin, so this works on the workspace's own videos (can_pin on the Video), and only on Instagram: TikTok no longer lets creators pin comments (pin_not_available). The phone long-presses the comment and taps Pin. Instagram keeps up to 2 pinned comments per post: a third fails (status failed, with a status_detail saying to unpin one in the app first). With the OutgoingComment.id of the workspace's own comment that is not live yet, the pin waits for it like a reply in advance. Live once the phone saw the comment pinned, posted when it could not confirm; already_pinned while a pin of the comment is on its way or done. Counts toward the account's 30 likes, pins and deletes a day.

Delete a comment or a reply from one of the workspace's own TikTok videos or Instagram reels, through the video's own account.

Authentication

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

Request

Path { commentId: Comment.id }; no body.

Response

202 { outgoing: OutgoingComment } with action delete, text empty and status queued. 404 { error_code: "comment_not_found", message } when the comment is not on one of the workspace's videos, or was deleted already; 409 { error_code: "delete_not_allowed" | "not_deletable" | "already_deleting" | "comment_being_deleted" | "comment_hidden" | "not_on_phone" | "account_being_recreated", message }; 429 { error_code: "action_limit_reached", message, available_at }.

Notes

Only a video's own account can delete other people's comments on it, so this works on the workspace's own videos (can_delete on the Video), on TikTok and Instagram alike; a comment on a video the workspace added cannot be deleted (delete_not_allowed). A comment or a reply (its id among a thread's replies), and only one with words of its own: the phone checks the comment it presses by its words, so a comment that is only a sticker, photo, GIF, @mentions or punctuation is deleted in the app (not_deletable), and so is a reply under such a comment, since the phone finds a reply under its comment by that comment's words. The video's own account's phone opens the video, long-presses the comment (a reply once it opened the replies under its comment) and taps Delete, with the account's other actions on that video, as a reply goes out; there is no scheduled_for. Deleting cannot be undone, a comment's replies go with it, and a queued delete can still be cancelled with DELETE /comments/outgoing/{id}. The delete withdraws the workspace's queued replies, likes and pins of the comment and deletes of its replies (cancelled, status_detail saying the comment is being deleted), and while it is on its way a reply, like or pin of the comment, or a delete of one of its replies, is refused (comment_being_deleted). A reply's delete shows in its thread's reply_actions until it is live. Live once the phone saw the comment gone, after which GET /comments no longer lists it and every route answers comment_not_found for it. One the phone could not confirm goes again in a later session (queued, status_detail saying so), and stays posted when it still could not after 3 tries, or when a later session no longer finds the comment (which proves nothing): the comment stays listed, check it in the app, and a new delete can be asked for. Failed when the comment was not under the video and the phone had not tapped Delete on it, or the app did not offer to delete it (status_detail says which). already_deleting while a delete of the comment is queued or sending. Counts toward the account's 30 likes, pins and deletes a day.

Example

curl -X DELETE https://api.pamba.app/comments/3f1c2a9e-5b7d-4e21-9c0a-8d6f4b2e1a77 \
  -H "X-API-Key: $PAMBA_API_KEY"

The replies and comments the workspace sent through Pamba (and with action, its likes, pins and deletes), and where each one stands.

Authentication

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

Request

Query platform (tiktok | instagram), username (the account that sends), status (queued | sending | posted | live | not_visible | failed | cancelled, or several comma-separated, such as queued,sending,posted), action (comment | like | pin | delete, or several comma-separated; comment when omitted), limit (default 50, at most 100); all optional, no body.

Response

200 { outgoing: [OutgoingComment, ...] }, newest first. OutgoingComment is { id, platform, account_username (the workspace's account that posts it), video_id, video_url, video_caption, parent_comment_id (the Comment.id it replies to, likes, pins or deletes; null for a new comment on the video, and for a reply in advance until its comment is live), parent_outgoing_id (a reply or pin in advance: the OutgoingComment.id of the workspace's comment it waits on; null otherwise), waiting_for_id (still waiting: the OutgoingComment.id of the one before it that must go live first; null otherwise), text (empty for a like, pin or delete), status: "queued" | "sending" | "posted" | "live" | "not_visible" | "failed" | "cancelled", status_detail, created_at, sent_at, live_at, platform_comment_id, action: "comment" | "like" | "pin" | "delete", scheduled_for }.

Notes

queued waits for its time (scheduled_for) or the phone; sending: the phone has it; posted: the phone saw it go out (or Pamba lost the phone's answer, in which case status_detail says so once it turns not_visible), and Pamba is checking that it shows on the platform; for a like, pin or delete, posted means the phone tapped it and could not confirm it took (a delete only after its last try); live: a read of the video's comments found it (platform_comment_id is set, and the comment appears in GET /comments), or for a like, pin or delete the phone saw it land (a deleted comment gone); not_visible: no read found it 6 hours after it went out, since the platform can hide a comment; failed: the phone could not send it; cancelled: stopped before the phone had it, a reply in advance whose comment before it did not go live, a reply, like or pin of a comment the workspace deleted, a reply, like, pin or delete of a comment Instagram hides (hidden_by_platform) or of a reply under one, or a comment, reply or like between two accounts that came to share a phone. status_detail is one plain sentence when the status needs a word of explanation, such as why a comment failed, and null otherwise.

Cancel a queued reply, comment, like, pin or delete (a scheduled one included) before the phone does it.

Authentication

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

Request

Path { id: OutgoingComment.id }; no body.

Response

200 { outgoing: OutgoingComment } with status cancelled (also for one already cancelled). 404 { error_code: "outgoing_comment_not_found", message }; 409 { error_code: "already_sending", message } once the phone has it (sending or posted), or { error_code: "not_cancellable", message } for one that is live, not_visible or failed.

Notes

Only a queued one can be cancelled. One the phone has already picked up goes out. Cancelling a comment also cancels the replies and pin waiting behind it in advance.

Comment automations

Answer comments by direct message, in two steps: when someone comments a chosen word on one of the workspace's TikTok or Instagram videos, the account's phone sends them a first message with no link that asks them to reply, once per person, and replies under their comment; once they reply yes, it sends the message with the link.

List the workspace's comment automations with their last 7 days, and the accounts one can name.

Authentication

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

Request

No query, no body.

Response

200 { automations: [Automation, ...], accounts: [{ platform, username, can_send, blocked_reason, messaging_restriction, messaging_resumes_on }, ...], daily_dm_limit }. daily_dm_limit is the most first messages each account sends a day (the messages with the link do not count). Automation is { id, name, enabled, keywords: [string], match_mode: "contains" | "exact", all_accounts, accounts: [{ platform, username }], dm_text, follow_up_text, reply_variants: [string], fallback_reply, match_from, created_at, updated_at, last_7_days: { matched, sent, waiting, not_sent, skipped, replied_yes, links_sent } | null }. follow_up_text is null only on an automation that is off without one (saved before the two messages with no link to send second), which answers nothing until a save gives it one.

Notes

Oldest first. last_7_days counts the comments the automation answered in the last 7 days: sent (the first message went out), waiting (queued or on the phone now), not_sent and skipped (the person already got its message, or their Instagram profile is private), and of those sent, replied_yes (the person replied yes) and links_sent (the message with the link went out). accounts lists the workspace's accounts whose comments it sees; can_send is false when the account is off the workspace's phones (not_on_phone) or being recreated (account_being_recreated). messaging_restriction is set on an Instagram account Instagram stopped sending messages, and null otherwise: { incident_id, restriction (Instagram's words, e.g. "You can't send messages"), added_on, ends_on } (ISO dates, each null when Instagram's page printed none). messaging_resumes_on (ISO date, Pacific, null without a restriction) is the day its waiting messages go out again: the day after ends_on, or, when Instagram gave no end date, 31 days after added_on (the day Pamba read the restriction when that is null too), and never later than 91 days after it, whatever end date was read. Until then they wait (detail messaging_restricted), and one whose comment gets 7 days old first ends not_sent with the same detail, while the account's replies to first messages are read again only once it ends; closing the restriction (POST /devices/accounts/incidents/{id}/close) puts them back in line within about 15 minutes, to go out at the account's usual pace. The account can still be set on an automation. An automation answers comments on every post of its accounts. last_7_days is set on this list and on GET /comments/automations/{automationId}, and null in what POST, PATCH and the activity answer.

Create a comment automation: on at once unless enabled is false.

Authentication

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

Request

JSON body { name, keywords, dm_text, follow_up_text, all_accounts?, accounts?, match_mode?, reply_variants?, fallback_reply?, answer_recent_comments?, enabled? }. name: up to 80 characters, only the workspace sees it. keywords: 1 to 20 words or phrases of up to 40 characters. match_mode: contains (default: a keyword anywhere in the comment, as whole words, ignoring capitals, accents, punctuation and emoji) or exact (the comment is the keyword and nothing else). all_accounts: true for every account on the workspace's phones, those added later included; otherwise accounts: [{ platform: tiktok | instagram, username }] of accounts on the workspace's phones. It answers comments on every post of those accounts, those posted later included. dm_text: the first message, up to 1,000 characters, with no link (refused link_in_first_message): it asks them to reply, such as "Reply YES and I'll send it". follow_up_text: the message with the link, up to 1,000 characters, sent once they reply yes; the link goes in the text where it should read, with a few words (refused follow_up_required when missing or only a link, follow_up_too_long past 1,000). reply_variants: up to 5 public replies, each up to 150 characters; fallback_reply: one reply for a person whose settings refuse the message. {username} in either message or a reply becomes the commenter's @handle. answer_recent_comments: true (default) also answers matching comments from the last 7 days. enabled: false creates it turned off.

Response

201 { automation: Automation }. 400 { error_code: "name_required" | "name_too_long" | "keywords_required" | "too_many_keywords" | "keyword_too_long" | "keyword_has_no_letters" | "invalid_match_mode" | "accounts_required" | "invalid_platform" | "account_not_found" | "not_on_phone" | "message_required" | "message_too_long" | "link_in_first_message" | "follow_up_required" | "follow_up_too_long" | "too_many_replies" | "reply_too_long", message }; 409 { error_code: "keyword_in_use", message } when another automation that is on already answers one of the keywords on one of the same accounts.

Notes

A video's comments are read again once its comment count grows, so a new comment is seen within about an hour; a comment that matches is answered from the account's own phone after that read. The automation answers a top-level comment by anyone but the workspace's own accounts, at most 7 days old, once per person (a second comment by the same person is skipped), and one comment gets one message whatever automations it matches (the oldest automation's), on the account's phone, which reaches the person from their comment (it opens the post, finds the comment and taps their name), up to 20 a day from each account (daily_dm_limit), spread out through the day (the rest wait: on TikTok the oldest comment first; on Instagram each post's newest waiting comment first, the posts taking turns). A message whose comment or post the phone cannot find ends not_sent (comment_not_found, video_not_found) and does not count toward daily_dm_limit. On Instagram a message to someone who does not follow the account lands in their message requests, where a link cannot be tapped until they reply: that is why the link waits for their answer, which also keeps the accounts from sending links to people who did not ask for one, on both platforms. After the first message, the account's phone checks its direct messages through the day for the person's reply, and when it says yes or asks for the link (in any language) sends follow_up_text in the same conversation, usually within a few hours of the reply; a reply that is not a yes keeps it waiting, and a person who does not say yes within 7 days of the first message gets nothing more. The messages with the link do not count toward daily_dm_limit. On TikTok a message whose recipient's settings refuse it is not sent (not_sent, messages_not_accepted). A TikTok message goes out as one paragraph: TikTok's keyboard sends on a line break, so line breaks become spaces. Once a message went out, one of reply_variants (picked per comment) is posted under the comment from the same account in the same visit, through the comment queue's limits (30 comments a day per account): a reply the queue refuses is left out, the message stands. A message refused by the person's settings gets fallback_reply instead, when set, once per person. A waiting message whose comment gets older than 7 days is never sent (not_sent, expired).

Example

curl -X POST https://api.pamba.app/comments/automations \
  -H "X-API-Key: $PAMBA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Free guide", "keywords": ["guide"], "all_accounts": true, "dm_text": "Thanks for commenting! Want the free guide from the video? Reply YES and it is yours.", "follow_up_text": "Here you go: https://example.com/guide", "reply_variants": ["Just sent you a DM!", "Check your messages"]}'

Read one comment automation, with its last 7 days.

Authentication

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

Request

Path { automationId: Automation.id }; no body.

Response

200 { automation: Automation }. 404 { error_code: "automation_not_found", message }.

Change a comment automation, or turn it off or on.

Authentication

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

Request

Path { automationId: Automation.id }; JSON body with any of the fields POST /comments/automations takes but answer_recent_comments; a field left out stays as it is. fallback_reply "" removes it; reply_variants [] removes the replies.

Response

200 { automation: Automation }. 400 as for POST /comments/automations; 404 { error_code: "automation_not_found", message }; 409 { error_code: "keyword_in_use", message }.

Notes

enabled false turns it off: it answers no new comment, and its waiting messages wait. Turned back on, or set to answer other comments (keywords, match_mode, accounts or all_accounts changed), it answers comments made from then on (match_from moves to that moment), never what was said before. A change to dm_text applies to the messages still waiting too (one already on a phone goes out as it was), and a change to follow_up_text to the people still waiting for the link. Every save checks both messages (link_in_first_message, follow_up_required, follow_up_too_long); an automation that is off without follow_up_text can be saved while it stays off, and enabled true needs follow_up_text. An account the automation already names stays on it after the account leaves the workspace's phones, so the automation can still be changed; its waiting messages end not_sent (not_on_phone), and its new comments are not answered while it is off the phones.

Delete a comment automation: it stops answering comments and leaves the list.

Authentication

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

Request

Path { automationId: Automation.id }; no body.

Response

204 No Content. 404 { error_code: "automation_not_found", message }.

Notes

Its waiting messages are not sent (cancelled, automation_deleted); a message already on a phone goes out, and one the phone hands back unsent is cancelled too. What it sent still counts toward the account's daily limit, and a comment it answered is not answered again by another automation. Replies already queued under comments stay queued (DELETE /comments/outgoing/{id} cancels one).

List the comments a comment automation answered, newest first, and where each message stands.

Authentication

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

Request

Path { automationId: Automation.id }; query status (queued | sending | sent | not_sent | skipped | cancelled, or several comma-separated), cursor (next_cursor from the page before), limit (default 50, at most 100); all optional.

Response

200 { automation: Automation, runs: [{ id, automation_id, platform, account_username, video_id, comment_id, commenter_username, comment_text, comment_posted_at, status: "queued" | "sending" | "sent" | "not_sent" | "skipped" | "cancelled", detail, note, next_attempt_at, sent_at, reply_outgoing_id, reply_detail, follow_up_status: null | "waiting" | "sent" | "not_sent" | "no_reply" | "cancelled", follow_up_detail, follow_up_sent_at, replied_at, created_at }, ...], next_cursor }. 400 { error_code: "invalid_cursor", message }, or a plain-text 400 for a status, limit or automationId that is not one; 404 { error_code: "automation_not_found", message }.

Notes

detail says why, as a code: a queued message waits for the account's daily limit (daily_limit, next_attempt_at says from when), its next round of messages (spacing), a retry (retrying), the app's own limit (rate_limited, a day), the account being unable to send (account_held: signed out of its phone, under review or being recreated), Instagram's restriction on the account sending messages (messaging_restricted, until messaging_resumes_on, which GET /comments/automations names on the account) or the automation being off (paused); a sent one is unconfirmed when the phone tapped Send and could not see it in the chat, or outcome_unknown when the phone never answered for it (it is never sent twice); not_sent is messages_not_accepted (the person's settings refuse it), no_message_button, recipient_not_found (their account could not be shown), comment_not_found (their comment was not on the post: deleted, or too far down its comments), video_not_found (the post did not open), not_on_phone, invalid_handle, expired (the comment got older than 7 days before the account could send it), messaging_restricted (the comment got 7 days old while Instagram's restriction on sending messages held it) or failed (after 3 tries); skipped is already_messaged, comment_hidden (Instagram hides the comment, so the phone cannot find it to open the commenter's profile: nothing is sent and the comment gets no reply) or private_profile (an Instagram commenter whose profile is private, which has no Message button: no message is tried, it uses none of daily_dm_limit, and the comment gets the fallback reply, paced with the account's comments: reply_detail is null while it waits); cancelled is account_seat_ended, account_archived or automation_deleted. note says the same in one plain sentence. comment_id is the comment's id in GET /comments. reply_outgoing_id is the public reply posted (or, while the message is sending, being posted) under the comment in the same visit, or, for a private_profile skip, the fallback reply queued for a comment session (GET /comments/outgoing), and reply_detail why there is none: no_reply (none set for this case), fallback_already_sent (the person already got the fallback reply), expired (a private_profile skip's fallback reply waited for room in the account's comments until the comment was 7 days old), reply_not_posted (the phone did not get to post it), comment_not_found, automation_deleted, or the comment queue's refusal (comment_limit_reached and the others); with reply_outgoing_id set, reply_detail is why the phone could not post it. status is the first message's; follow_up_status is where the message with the link stands once the first went out (null before that, and on a message sent before the two messages): waiting (for their reply; follow_up_detail replied when they replied without a yes and Pamba keeps checking, failed when they said yes and the phone's send failed once and is tried again; replied_at set means they said yes and the link goes out on the account's next round of messages while the automation is on: pausing it holds everyone's link until it is on again), sent (at follow_up_sent_at; follow_up_detail unconfirmed when the phone tapped Send and could not see it in the chat after), not_sent (they said yes and the link was not sent: messages_not_accepted, failed, expired when the link could not reach them before the wait ended, or automation_paused when the automation was still off when the wait ended), no_reply (no yes within 7 days of the first message) or cancelled (automation_deleted, not_on_phone, account_seat_ended or account_archived). replied_at is when Pamba first saw their yes. GET /comments carries follow_up_status, follow_up_detail and replied_at on each comment's automations too.

Content Strategy and Campaigns

The automation layer: strategy, campaigns, avatar assignments, cadence, and the content plans they produce.

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.

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.

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.

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. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

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).

Pause or reactivate an assignment's account. A paused account 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. 409 { error_code: "account_seat_ended", message } when the account's seat was cancelled and has ended. 409 { error_code: "account_archived", message } when the account is archived: Pamba no longer runs or changes anything on it.

Notes

Pausing sets the account to analytics-only for every avatar sharing it and cancels their pending posts and warming sessions; the account stays linked. reason is recorded in the audit log.

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

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.

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 is a 400; 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's account is skipped, for every avatar sharing it, 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.

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.

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, currently grok-imagine-1.5. 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.

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.

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.

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.

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.

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.

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}.

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.

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.

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.

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.

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.

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.

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 the built-in default, currently grok-imagine-1.5.

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).

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.

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.

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 or over 64 characters, or when label is over 128 and is written (a new entry, or any request that sends description).

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.

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 or a label over 128 characters; 403 for a global format or one not in the workspace.

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.

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.

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 or over 64 characters, or when label is over 128 and is written (a new entry, or any request that sends description).

Notes

name is trimmed. An existing name returns its id; when description is sent, that entry's label and description are overwritten.

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 or a label over 128 characters; 403 when the audience is not in the workspace.

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.

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.

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 or over 64 characters, or when label is over 128 and is written (a new entry, or any request that sends description).

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.

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 or a label over 128 characters; 403 for a global type or one not in the workspace.

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.

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.

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.

Each account's posting frequency (posts a day the planner schedules, a hard limit), 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.

Set one TikTok account's posting frequency: the most posts a day the planner schedules for it.

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

A hard limit shared by every avatar on the account: slots over it are not planned, the avatars taking turns at the account's days. Clamped to 1..20. The username is stored lowercased and is not checked against the workspace's accounts.

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.

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.

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.

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.

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.

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.

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

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 or assignment id is a 400; a malformed 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.

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.

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.

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.

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.

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, or naming each script (by its position, the first ten, then a count) whose avatar_project_id is not a live (not deleted) avatar project of the workspace (pass the project_id from GET /avatars, not the avatar's id; one that is not a UUID is ignored and the avatar is matched by avatar_name); 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. Within a minute each script becomes a draft on the Drafts tab (its pushed_project_id in GET /content/batches/{batchId}). Approve one by generating its draft (POST /projects/{projectId}/replication/generate) or with POST /content/scripts/{scriptId}/decision, or a set with 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."
    }]
  }'

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 invalid scripts. The scripts' avatar_project_ids are checked first: each that is not a live (not deleted) avatar project of the workspace is named, the first ten then a count (one that is not a UUID is ignored and the avatar is matched by avatar_name). Then the first script with a missing or unknown automation_class, unknown mode, unparseable scheduled_at or 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.

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.

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.

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.

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.

Check which approved scripts can be pushed, 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[], 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.

Push approved scripts into 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 }], blocked: [{ 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

A script up for review already has its draft: within a minute of its batch reaching review, each script gets a draft project (its pushed_project_id) built from its words, scene and on-screen text, reviewed on the Drafts tab like any draft. A pushed script generates that draft; a script with no draft yet gets a new project built and generated. An automated script also schedules its post (plus its Instagram mirrors) on its plan day and waits for a person to approve the render before it posts, so it spends the workspace's video credits; a workspace on its free signup credits alone is refused for those scripts at preflight. A manual script schedules nothing, though its draft still generates and spends video credits (a workspace on its free signup credits alone gets it back under blocked). A drafted script whose draft cannot generate yet (still building, an edit landing, too few credits) comes back under blocked with the reason and stays approved. Open to every workspace member. Generation continues in the background after the 202. Poll GET /content/batches/{batchId}/push-status for the per-script outcome. A script a concurrent push already claimed appears in none of pushed, failed or blocked.

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

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 }? }] }. error is one plain sentence (a push that failed on Pamba's side, one interrupted by a restart, an automation failure, a concept that never became ready), never an exception's text. 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.

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: it generates the script's draft (or builds a project when it has none yet) on the caller's credits, so status comes back pushed with project_id. When the push was blocked (a batch push already running, a duplicate already sent, a preflight block, a draft not ready to generate) the approval stands and status is approved with push_error; when the push 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 whose project already generated returns that project instead of creating another. cut archives the script's ungenerated draft within a minute and is final; needs_changes and cut set the matching status. Approving the draft itself (POST /projects/{projectId}/replication/generate) is the same approval.

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.

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.

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.

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.

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.

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.

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.

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.

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, batch_id, stage, group_key, state, attempt, max_attempts, 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. A run's payload and error are engine internals and are not served; the batch detail route carries the user-facing message of a failed run. 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.

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.

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.

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.

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.

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.

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.

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.

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.

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 }].

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 a 400.

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.

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.

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.

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

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.

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, postStatus, errorCode, slideshowId }], onIt: [{ id, kind, label, detail, since }], recentlyDone: [{ id, kind, label, detail, at }], tally: { postsPublished, browseSessions, scriptsWritten }, setup: { hasAvatar, hasAccount, automationOn, hasBrand, hasDraft, hasDraftBuilding, hasRender, goal, hasLivePost, hasPostableAccount, selfPostMode, accountProvisioning, accountsAwaitingNames, readinessKnown, firstDraftProjectId, firstDraftTitle, firstDraftCoverUrl, firstDraftAvatarName, firstDraftAvatarImageUrl, firstDraftDurationSeconds, firstDraftSceneCount, needsBootstrapHeal }, hasHistory }.

Notes

Field names are camelCase. setup.goal is what the workspace is for (accounts, videos or both; null when it never answered), and setup.hasDraftBuilding is true while a draft is still being written and none has landed. An onIt item of kind drafting is a draft being written; kind rendering is a video being generated or rendered. A needsYou item with an attentionId can be dismissed with POST /content/studio/attention/{itemId}/acknowledge. A post item carries postStatus: failed for a post that failed in the last 14 days, pending for an Instagram post whose song US Instagram does not have (it goes out with a fallback song unless another song is set first); errorCode is the failed post's code (ig_audio_not_found: set another song, or retry as is), and slideshowId names a slideshow post's slideshow. A needsYou item of kind renewal-declined says a renewal payment was declined (account seats first, else the plan); its deepLink opens the page whose Update card fixes it (POST /billing/card-update-checkout). A needsYou item of kind seat-names says bought accounts are waiting for their usernames and nothing is created on them until they are saved (PUT /devices/accounts/seat-usernames); setup.accountsAwaitingNames counts those seats, and onIt's account-setup items count only the seats being set up. A failed post Pamba's engineers are fixing (being_fixed on the post) is listed under onIt with kind post-fixing instead, its id the post's id and since when it failed. setup.readinessKnown false means the posting-readiness read failed and the posting flags are fallbacks, not facts.

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.

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>, sort=newest | oldest | posttime | views (views: most views first, summed over each item's published TikTok and Instagram posts, with the count on each item as views), and kind=all | videos | slideshows (default all). No parameters: the legacy full read.

Response

200 { items: [{ itemId, projectId, batchId, stage, failureKind, avatarName, avatarImageUrl, tiktokUsername, tiktokUsernames, instagramUsernames, instagramUsernamesWithFacebook, postingAccounts, title, format, script, description, frameUrl, videoUrl, editedVideoUrl, stitchedVideoUrl, clipVideoUrls, generatedAt, scheduledAt, hasPendingPost, postActivity, awaitingPurchase, avatarProjectId, suggestedScheduledAt, renderState, building, generating, progressMessage, creationStage, promotional, archived, videoModel, replicationOrigin, motionOverlays, referenceVideo?, storyboard?, draftBuild?, sourceProject?, views?, slideshow?: { slideshowId, status, slideCount, readyCount, coverImageUrl, coverTextItems: [{ id, text, x, y, w, style, style_preset, wrap_lines }], coverMediaItems: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id }] } }], 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, except inside postingAccounts, the accounts the item's video may post from (null on a slideshow item): { scope: "avatar" | "workspace", tiktok_usernames, instagram_usernames, instagram_usernames_with_facebook, instagram_usernames_cross_posting_to_facebook, instagram_usernames_below_trial_floor, instagram_usernames_personal }: the accounts a schedule may name, the choices the app's schedule dialog offers. scope avatar lists the avatar's post-enabled accounts (an Instagram account only while it is on its phone); scope workspace, for a project or slideshow with no avatar, lists every postable account on the workspace's own phones, whichever avatar it is linked to (a TikTok account the workspace paused or archived is left out). A listed TikTok account that is not signed in on one of the workspace's phones, or one with an open suspension, is still refused when scheduled. instagram_usernames_with_facebook names the Instagram accounts whose linked Facebook account can post too (share_to_facebook), and instagram_usernames_cross_posting_to_facebook those of them whose Cross-post reels to Facebook switch is on: their reels go to Facebook unless a post sends share_to_facebook false, and only they take share_to_facebook true; instagram_usernames_below_trial_floor names those whose last follower count Pamba read is under the 200 Instagram asks of a trial reel (trial_reel is refused on them; an account Pamba has not read yet is not named), and instagram_usernames_personal those Pamba knows are personal accounts with no switch to creator queued (trial_reel is refused on them too; POST /devices/accounts/profile-edits with instagram_account_type creator queues the switch). Handles carry no @. tiktokUsernames, instagramUsernames and instagramUsernamesWithFacebook are the avatar's alone, empty for an item with no avatar; postingAccounts supersedes them. A video item with no avatar reads avatarName "No avatar", like a slideshow without one: avatarOptions never offers it, and avatar=No avatar selects the items with none. A video item names its project's avatar. queue is generating plus review; approved and scheduled split on hasPendingPost; postActivity is 'posting' while a phone publishes one of the item's posts, 'verifying' while a post waits on the profile check that decides whether an attempt's upload went up, and null otherwise; 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. The windowed read's population holds both formats in one order: a slideshow item carries slideshow (its itemId is the slideshow id, projectId is null, the video fields are empty, and it is always in queue or archived); kind narrows to one format. sort orders the whole population before the window cut, so do not re-sort a page client-side. views is set only on a sort=views read: the views of the item's published TikTok and Instagram posts (0 when they were found with none), null when no published post has views yet; every other sort leaves it null. An unknown sort, kind, date or tz degrades silently (newest, all, no date filter, UTC). counts are per-bucket totals with the search, avatar, date and kind filters applied, across all buckets. Video item ids feed GET /content/items/{id}; slideshow ids feed GET /slideshows/{slideshowId}.

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.

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.

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.

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.

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"

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.

The workspace's content-engine LLM spend at cost, by agent role and model.

Authentication

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

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.

Slideshows

Image-based multi-slide posts (a TikTok photo post or an Instagram carousel): a topic becomes 2-20 vertical slides, each a picture with one line of text burned in. Creating writes the copy for free; a slide's picture is either painted by the image model (GPT Image 2.5 by default, or Gemini per slideshow; generate charges 15 credits per painted image and runs in the background) or one you provide for free — a Pamba-hosted upload, media-library asset or the avatar's photo (slide_images on create, or background_image_url on the slide), burned at once. A slideshow is ready once every slide has its picture and posts through POST /slideshows/{slideshowId}/schedule, as a TikTok photo post or an Instagram carousel (once per platform); the finished slide images are public URLs.

List the workspace's slideshows, newest first.

Authentication

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

Request

Query: include_archived (optional, true to include archived slideshows).

Response

200 [{ id, title, topic, avatar_project_id, avatar_name, style_prompt, image_model (gpt-image-2.5 | gemini), status (draft | generating | ready), error_message, slide_count, ready_count, cover_image_url, credits_to_generate, archived, created_at, updated_at, sound ({ src, label, source_duration, media_in, volume, tiktok_music_id, author, cover_url } or null), tiktok_picks_sound (true: TikTok picks the sound when it posts; false with a null sound: the post has no sound), instagram_sound (the Instagram carousel's own sound: { src, label, author, source_duration, media_in, volume, instagram_audio_id, explicit, cover_url } or null for none), user_review_state (not_reviewed | approved), slides: [{ id, position, text_line, sub_line, text_items: [{ id, text, x, y, w, style, style_preset, wrap_lines, rotation }], media_items: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id, crop ({ x, y, w, h } or null) }], background_prompt, background_source (none | generated | avatar_photo | library), background_image_url, background_fit ({ x, y, w } or null for the cover-fit), frame_aspect (the frame's width ÷ height, 0.5625 for 9:16 to 1.78 for 16:9: the picked picture's own shape, or 9:16; the burned image_url is 1080 wide and this tall), image_url, status (pending | generating | ready | failed), error_message, updated_at }], replication (null, or the photo-post replication that built the show: { id, status (building | completed | failed), step (source | analyze | compose | paint | done), step_label, source_tiktok_url, source_author, source_slide_count, source_slides: [{ image_url, role, text, visual }], instructions, error_message, created_at, completed_at }) }]

Write a slideshow draft from your own slide lines (or a topic).

Authentication

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

Request

Body: { topic (optional when lines are given), lines (optional string[] kept verbatim as slides 1..n), slide_count (2-20, default 6 or the number of lines), avatar_project_id (optional, borrows the avatar's look), first_slide (text_hook | avatar_photo; avatar_photo opens with the avatar's own picture, free), write_with_ai (default true; false makes a manual draft from the lines alone — one blank slide when none — with no model call), title (optional, manual drafts), slide_images (optional string[] aligned to the slides: a Pamba-hosted picture that slide keeps instead of a painted image, free, burned at once; null or a missing entry leaves the slide for generate (or a later pick); with write_with_ai false the draft grows to one slide per picture, and a slide with a picture but no line has no text), slide_prompts (optional string[] aligned to the slides, manual drafts only: what the image model paints for that slide; null or blank paints a backdrop from the slide's words; the writer drafts its own), image_model (optional, gpt-image-2.5 (default) | gemini — the model that paints every generated picture of the slideshow) }

Response

201 { id, title, topic, avatar_project_id, avatar_name, style_prompt, image_model (gpt-image-2.5 | gemini), status (draft | generating | ready), error_message, slide_count, ready_count, cover_image_url, credits_to_generate, archived, created_at, updated_at, sound ({ src, label, source_duration, media_in, volume, tiktok_music_id, author, cover_url } or null), tiktok_picks_sound (true: TikTok picks the sound when it posts; false with a null sound: the post has no sound), instagram_sound (the Instagram carousel's own sound: { src, label, author, source_duration, media_in, volume, instagram_audio_id, explicit, cover_url } or null for none), user_review_state (not_reviewed | approved), slides: [{ id, position, text_line, sub_line, text_items: [{ id, text, x, y, w, style, style_preset, wrap_lines, rotation }], media_items: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id, crop ({ x, y, w, h } or null) }], background_prompt, background_source (none | generated | avatar_photo | library), background_image_url, background_fit ({ x, y, w } or null for the cover-fit), frame_aspect (the frame's width ÷ height, 0.5625 for 9:16 to 1.78 for 16:9: the picked picture's own shape, or 9:16; the burned image_url is 1080 wide and this tall), image_url, status (pending | generating | ready | failed), error_message, updated_at }], replication (null, or the photo-post replication that built the show: { id, status (building | completed | failed), step (source | analyze | compose | paint | done), step_label, source_tiktok_url, source_author, source_slide_count, source_slides: [{ image_url, role, text, visual }], instructions, error_message, created_at, completed_at }) }. 400 when neither a topic nor a line is given, or image_model is neither value. 502 { error } when the writer fails.

Notes

Blocks for the writer's model call (a few seconds). Nothing is painted or charged; call generate afterwards, or give slides their pictures with PATCH /slideshows/{slideshowId}/slides/{slideId} background_image_url.

Example

curl -X POST https://api.pamba.app/slideshows -H 'X-API-Key: pamba_...' -H 'Content-Type: application/json' -d '{"topic": "3 habits that finally fixed my sleep", "slide_count": 6}'

Read one slideshow with its slides in order.

Authentication

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

Request

Path: slideshowId.

Response

200 { id, title, topic, avatar_project_id, avatar_name, style_prompt, image_model (gpt-image-2.5 | gemini), status (draft | generating | ready), error_message, slide_count, ready_count, cover_image_url, credits_to_generate, archived, created_at, updated_at, sound ({ src, label, source_duration, media_in, volume, tiktok_music_id, author, cover_url } or null), tiktok_picks_sound (true: TikTok picks the sound when it posts; false with a null sound: the post has no sound), instagram_sound (the Instagram carousel's own sound: { src, label, author, source_duration, media_in, volume, instagram_audio_id, explicit, cover_url } or null for none), user_review_state (not_reviewed | approved), slides: [{ id, position, text_line, sub_line, text_items: [{ id, text, x, y, w, style, style_preset, wrap_lines, rotation }], media_items: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id, crop ({ x, y, w, h } or null) }], background_prompt, background_source (none | generated | avatar_photo | library), background_image_url, background_fit ({ x, y, w } or null for the cover-fit), frame_aspect (the frame's width ÷ height, 0.5625 for 9:16 to 1.78 for 16:9: the picked picture's own shape, or 9:16; the burned image_url is 1080 wide and this tall), image_url, status (pending | generating | ready | failed), error_message, updated_at }], replication (null, or the photo-post replication that built the show: { id, status (building | completed | failed), step (source | analyze | compose | paint | done), step_label, source_tiktok_url, source_author, source_slide_count, source_slides: [{ image_url, role, text, visual }], instructions, error_message, created_at, completed_at }) }. 404 when not in the workspace.

Notes

Poll this while status is generating; slide image_url values appear as each slide lands.

Rename, change the shared style prompt or image model, assign or clear the avatar, restyle every slide's text, pick or clear the sound, or archive / restore a slideshow.

Authentication

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

Request

Body: { title?, style_prompt?, archived?, avatar_project_id? (assigns that avatar — a slideshow has one at most; an empty string or 'none' clears it), text_style? (a text style applied to every element of every slide; each keeps its words, size and placement), text_style_preset? (a preset id from GET /editor/text-style-presets, applied the same way; wins over text_style), sound? ({ src, label?, source_duration?, media_in? (seconds into the track the post starts), volume? (0-1) } — src is a Pamba-hosted .mp3/.wav: a library track's url stays as is, an uploaded audio asset's url is copied to permanent storage; with tiktok_music_id from GET /sounds/search, src may be empty — the sound's title, artist, cover and clip come from TikTok and the clip is copied to permanent storage), clear_sound? (true removes the sound: the post then has no sound, and the phone takes off any sound TikTok adds by itself), tiktok_picks_sound? (true lets TikTok pick the sound when the slideshow posts and keeps it, replacing a picked sound; the response's tiktok_picks_sound reads true and sound null), instagram_sound? ({ instagram_audio_id, volume? } — a track from GET /sounds/instagram/top or /sounds/instagram/search for the Instagram carousel: its title, artist, cover and song come from Instagram, it starts where Instagram's picker starts it, and the song is copied to permanent storage), clear_instagram_sound? (true takes the Instagram sound off), user_review_state? (approved marks the finished slideshow ready to post — scheduling sets it; not_reviewed takes it back), image_model? (gpt-image-2.5 | gemini — the model that paints the slideshow's generated pictures from the next generate on; pictures already painted stay) } — only the fields present change.

Response

200 { id, title, topic, avatar_project_id, avatar_name, style_prompt, image_model (gpt-image-2.5 | gemini), status (draft | generating | ready), error_message, slide_count, ready_count, cover_image_url, credits_to_generate, archived, created_at, updated_at, sound ({ src, label, source_duration, media_in, volume, tiktok_music_id, author, cover_url } or null), tiktok_picks_sound (true: TikTok picks the sound when it posts; false with a null sound: the post has no sound), instagram_sound (the Instagram carousel's own sound: { src, label, author, source_duration, media_in, volume, instagram_audio_id, explicit, cover_url } or null for none), user_review_state (not_reviewed | approved), slides: [{ id, position, text_line, sub_line, text_items: [{ id, text, x, y, w, style, style_preset, wrap_lines, rotation }], media_items: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id, crop ({ x, y, w, h } or null) }], background_prompt, background_source (none | generated | avatar_photo | library), background_image_url, background_fit ({ x, y, w } or null for the cover-fit), frame_aspect (the frame's width ÷ height, 0.5625 for 9:16 to 1.78 for 16:9: the picked picture's own shape, or 9:16; the burned image_url is 1080 wide and this tall), image_url, status (pending | generating | ready | failed), error_message, updated_at }], replication (null, or the photo-post replication that built the show: { id, status (building | completed | failed), step (source | analyze | compose | paint | done), step_label, source_tiktok_url, source_author, source_slide_count, source_slides: [{ image_url, role, text, visual }], instructions, error_message, created_at, completed_at }) }. 400 for an empty title, an invalid avatar id, an unknown preset, a sound src off Pamba storage, tiktok_picks_sound beside sound or clear_sound, an instagram_sound without a numeric instagram_audio_id or beside clear_instagram_sound, clear_instagram_sound while an Instagram post (a carousel or a reel) is scheduled, an Instagram track no longer available, an image_model that is neither value, or an approval while a slide still has no picture. 404 when the avatar is not in the workspace. 409 while the slideshow is generating and a restyle or an image_model change was asked. 429 with retry_at when an instagram_sound the chart or the day's searches did not list needs a lookup past the workspace's search rate. 503 with a message when Instagram's audio page, or TikTok's sound page a sound.tiktok_music_id needs, is not answering.

Notes

A slide's words are text elements (text_items): each has its words, its placement as frame fractions (x, y = the block's centre, w = the wrap box width), its `rotation` (degrees clockwise about the centre, 0 when upright) and the video editor's text style (`style`, px at the 1080-wide frame — the same fields and presets as the editor's text items; `style_preset` names the preset the look came from and is re-resolved on save; `wrap_lines` are the editor's exact line breaks, honoured by the burn while they still spell the text). text_line and sub_line are the first and second elements' words, kept for the writer and the agent. A restyle re-burns every slide that has a picture at no cost. A style_prompt change takes effect on the next generate or regenerate. What the TikTok post sounds like is one choice of three: one track for the whole post (the editor previews it under the slides), TikTok's own pick, or no sound. The Instagram carousel has its own sound, an Instagram track or none, because the two platforms' libraries are different.

Host a picture for placement on a slide: copies it to permanent storage and measures it.

Authentication

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

Request

Body: { image_url } — a Pamba-hosted picture (a media-library asset's url, an upload, the avatar's photo, a slide image).

Response

200 { url, width, height, aspect }. 400 for a url off Pamba storage or a file that is not a picture. 404 when the slideshow is not in the workspace.

Notes

Pictures placed on a slide (media_items) paint over the slide image and under the words, in array order, with the video editor's placement units: x, y = the picture's centre as frame fractions, w = its width as a fraction of the frame width (the height follows `aspect`, its width ÷ height), `rotation` in degrees, `opacity` 0-1, and an optional `crop` — the part of the source that shows, as fractions of the source ({ x, y } top-left corner, { w, h } size; the burn and the preview paint only that part, and `aspect` then describes it). Every src must come from POST /slideshows/{slideshowId}/media, which rehosts a picture onto permanent storage and measures it, or from POST /slideshows/{slideshowId}/media/generate, which paints one. Place the returned url as a media item's src (with its aspect) through PATCH /slideshows/{slideshowId}/slides/{slideId}.

Paint a picture to place on a slide, from a description in the slideshow's visual style (15 credits).

Authentication

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

Request

Body: { prompt } — what the picture shows; the slideshow's style_prompt is applied on top and the frame stays free of text.

Response

200 { url, width, height, aspect, asset_id, label }. 400 for an empty prompt or a picture the image model declined (reword it). 402 { error, message, remaining_credits, required_credits } when the workspace cannot afford it. 404 when the slideshow is not in the workspace.

Notes

Blocks for the image model (some seconds). The credits are charged only when a picture comes back. The picture is filed in the media library (asset_id; null when the library could not take it) and hosted like a picked one, so place the returned url as a media item's src through PATCH /slideshows/{slideshowId}/slides/{slideId}. For the slide's own image use background_prompt plus regenerate instead.

File one of the slideshow's own pictures into the workspace media library, so any slideshow or video can use it.

Authentication

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

Request

Body: { image_url, label? } — image_url is a slide's background_image_url or a placed picture's src on this slideshow; label is its name in the library (the slideshow's title and slide number when absent).

Response

200 the asset, shaped as in GET /assets, with source_url set to image_url. 400 for a picture that is not on this slideshow. 404 when the slideshow is not in the workspace. 409 when the media library is full.

Notes

Filing a picture that is already in the library returns the asset it became the first time rather than a copy; an asset's source_url is how to tell.

TikTok's Hot sounds chart — the editor's default sound listing.

Authentication

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

Request

Query (optional, one at most): project_id — the video project the sound is for; slideshow_id — the slideshow it is for. Either fits the chart to the account the post goes out from.

Response

200 { as_of, region, sounds: [{ music_id, title, author, duration, cover_url, preview_url, video_count, commerce_right }], business_only, business_accounts } — in chart order; as_of is the day the chart was read (yyyy-MM-dd), video_count the post count the chart showed. business_only is true when the project's or slideshow's avatar posts from a TikTok Business account (business_accounts names them): only sounds cleared for commercial use are then listed, the ones TikTok lets a Business account post with. 400 with both ids or a malformed one; 404 when the id is not in the workspace.

Notes

Read off TikTok's own picker (US) on one of Pamba's phones once a day; covers and clips live on Pamba storage, so it costs nothing to load and nothing to pick. Rows the search cannot match to one sound (untitled "original sound" uploads among them) are left out, and a day the read fails keeps the previous chart. Pick one the same way as a search result: PATCH /slideshows/{slideshowId} sound.tiktok_music_id, or PUT /projects/{projectId}/sounds/tiktok for a video.

Instagram's Trending music chart — the editor's default listing for a slideshow's or a video's Instagram sound.

Authentication

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

Request

None.

Response

200 { as_of, region, tracks: [{ audio_id, title, artist, duration, cover_url, preview_url, highlight_start, explicit, reel_count }] } — in chart order; as_of is the day the chart was read (yyyy-MM-dd), reel_count the reel count the chart showed (null where it was cut off), duration and highlight_start in seconds.

Notes

Read off Instagram's own picker on a US account on one of Pamba's phones once a day, and the previous chart is kept on a day the read fails; covers and songs live on Pamba storage, so it costs nothing to load and nothing to pick. Users' own uploads ("Original audio" and other clips with no song to find) are left out, and so is a song a Pamba phone did not find in Instagram's music picker when a post went out from an account Pamba knows is not a Business account (a Business account's picker lists only commercial music). Pick one with PATCH /slideshows/{slideshowId} instagram_sound.instagram_audio_id, or PUT /projects/{projectId}/sounds/instagram for a video.

Lines the writer would put on a slide, given what the other slides say — alternatives to offer, nothing stored.

Authentication

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

Request

Body: { position? (0-based slide position the lines are for; the end of the slideshow when omitted), count? (1-6, default 3) }

Response

200 { lines: string[] }. 404 when the slideshow is not in the workspace. 502 { error } when the writer fails.

Notes

Blocks for the writer's model call (a few seconds). An AI assist: no credits, counted against the workspace's daily AI-assist allowance. Lines the slideshow already says are never offered.

Host a picture from the user's computer for a slide, without adding it to the media library.

Authentication

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

Request

Multipart: file (one image).

Response

201 { url } — a Pamba-hosted url to pass as background_image_url (PATCH a slide) or in slide_images (POST /slideshows). 400 when no image part is sent.

Notes

Unlike POST /assets this does not count against the media library's cap and never lists the picture there; it exists for the slide it was dropped on. Over the API, host a picture with POST /assets (or pass the avatar's photo) instead.

Copy a slideshow into a fresh draft with every slide's words, pictures and finished image kept.

Authentication

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

Request

No body.

Response

201 { id, title, topic, avatar_project_id, avatar_name, style_prompt, image_model (gpt-image-2.5 | gemini), status (draft | generating | ready), error_message, slide_count, ready_count, cover_image_url, credits_to_generate, archived, created_at, updated_at, sound ({ src, label, source_duration, media_in, volume, tiktok_music_id, author, cover_url } or null), tiktok_picks_sound (true: TikTok picks the sound when it posts; false with a null sound: the post has no sound), instagram_sound (the Instagram carousel's own sound: { src, label, author, source_duration, media_in, volume, instagram_audio_id, explicit, cover_url } or null for none), user_review_state (not_reviewed | approved), slides: [{ id, position, text_line, sub_line, text_items: [{ id, text, x, y, w, style, style_preset, wrap_lines, rotation }], media_items: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id, crop ({ x, y, w, h } or null) }], background_prompt, background_source (none | generated | avatar_photo | library), background_image_url, background_fit ({ x, y, w } or null for the cover-fit), frame_aspect (the frame's width ÷ height, 0.5625 for 9:16 to 1.78 for 16:9: the picked picture's own shape, or 9:16; the burned image_url is 1080 wide and this tall), image_url, status (pending | generating | ready | failed), error_message, updated_at }], replication (null, or the photo-post replication that built the show: { id, status (building | completed | failed), step (source | analyze | compose | paint | done), step_label, source_tiktok_url, source_author, source_slide_count, source_slides: [{ image_url, role, text, visual }], instructions, error_message, created_at, completed_at }) } — the copy, titled '<title> (copy)', with the same avatar, style prompt and sound, user_review_state not_reviewed. 409 while the slideshow or one of its slides is generating.

Notes

Finished slides stay ready with their burned image, so nothing is repainted and no credits are spent. The copy is listed beside the original on Content → Videos.

Post a finished slideshow as a TikTok photo post, an Instagram carousel or reel, or both at once.

Authentication

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

Request

Body: { platform (tiktok — the default —, instagram, or both: the two booked together in one plan, both or neither), tiktok_username (platform tiktok or both), instagram_username (platform instagram or both; omit when the slideshow's avatar, or the workspace for a slideshow with no avatar, has exactly one Instagram account on a phone), caption (may be empty; an @handle posts as a linked mention on both platforms; TikTok: the post's description — at most 4,000 characters counted in UTF-16 units, at most 5 hashtags, no separate title, at most 5 @handle mentions, each written exactly with @ used only to start one and never the account it posts from; a caption with #ad, #ads, #sponsored, #partnership or #paidpartnership is posted with TikTok's commercial content disclosure on, labeled "Paid partnership"; Instagram: at most 2,200 characters and 5 hashtags), scheduled_at (ISO 8601 UTC, or yyyy-MM-dd to auto-pick a slot that day), as_reel (optional, Instagram only: true posts the slides as one reel Instagram builds from them, each slide 4 seconds with the slideshow's Instagram track, in the full 9:16 frame, the first slide as its cover; false, the default, a carousel; 400 with platform tiktok, and with platform both it applies to the Instagram post) }. Every post is public. One caption rides every post, so with platform both it must pass both platforms' rules. The slideshow must be ready with every slide finished: a TikTok photo post and an Instagram post take a single slide (one slide goes to Instagram as a single photo, two or more as a carousel), a reel built from the slides (as_reel) at least 2; scheduling approves it. The account must be linked to the slideshow's avatar, or be any of the workspace's fleet accounts when the slideshow has no avatar. One live post per slideshow per platform.

Response

201 { post_id, status, scheduled_at, platform, username, tiktok_username (the same account, kept for older clients), privacy, caption, error_message, error_code, completed_at, verification_status, posted_url, as_reel, posts } — the top-level fields are the first platform's post (TikTok first), and posts lists every post it booked in the same shape, each with posting_warning when it has one: posting_warning is a warning, never a refusal: the post makes its account's 2nd post that day (the workspace's day, counting its pending, posting and posted posts) while Pamba created the account less than 10 days before the post, or any account's 4th, and is booked all the same. It is { code: "new_account_posts_per_day" | "posts_per_day", message, day (yyyy-MM-dd), posts_that_day (this post included), posts_per_day }, and null (or absent) otherwise. Only the schedule and post-edit responses fill it: every other response carrying a scheduled post returns it null. 400 when the slideshow is not ready, for platform instagram or both when it has no instagram_sound (a carousel needs its song: without one Instagram does not treat it as a reel and shows it only to followers; a reel times its slides to it), or for as_reel true with platform tiktok; 409 duplicate_pending_post when it already has a live post on that platform, duplicate_content when another account of the workspace has its slides scheduled, posting or posted on that platform (all its slides in another account's slideshow, or all of that one's in it), or when the account is paused or the day is full.

Notes

The phone posts the slides in swipe order: on TikTok with the TikTok sound picked in the editor; on Instagram with the Instagram track picked in the editor (found in Instagram's own music search on the phone), each slide fitted whole into Instagram's square or 3:4 feed frame (square when the slides are square or wider) over a blurred fill of itself so nothing is cropped, and the post confirmed on the profile as the account's newest carousel. With as_reel the phone instead picks the slides in Instagram's New reel picker and Instagram builds one reel from them in the full 9:16 frame, each slide shown 4 seconds with the track put on in the editor, the first slide as its cover. Cancel with DELETE /devices/tiktok/posts/{postId}. Refusals carry a { error, code } envelope: 400 tiktok_account_not_linked | tiktok_no_device_mapping | pending_account_not_allowed, 404 account_not_found, 409 duplicate_pending_post | duplicate_content | schedule_conflict | tiktok_account_paused | instagram_account_paused | instagram_account_off_device | account_suspended | account_banned | account_needs_reconnect (an account the workspace connected was signed out of its phone; reconnect it first). The readiness and caption 400s (not ready, an Instagram carousel under 2 finished slides, caption too long) are plain-text bodies with no code.

Example

curl -X POST https://api.pamba.app/slideshows/<id>/schedule -H 'X-API-Key: <key>' -H 'Content-Type: application/json' -d '{"tiktok_username": "coffee.daily", "caption": "three fixes for bitter coffee", "scheduled_at": "2026-09-20"}'

The slideshow's posts (a TikTok photo post, an Instagram carousel or reel) as the queue sees them.

Authentication

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

Request

No body.

Response

200 { post: null, posts: [] } when never scheduled, else { post, posts } where posts holds one entry per platform the slideshow was scheduled to (TikTok first) and post is the TikTok post when there is one, else the Instagram post: each { post_id, status (pending | in_progress | completed | failed | cancelled), scheduled_at, platform, username, tiktok_username, privacy, caption, error_message, error_code, completed_at, verification_status, posted_url, being_fixed, awaiting_replacement, audio_fallback, as_reel }. as_reel is true on an Instagram post booked as one reel (as_reel on POST /slideshows/{slideshowId}/schedule), false on a carousel and on a TikTok post. error_message and error_code are set only once status is failed, and error_message is one plain sentence, never the phone's own text. audio_fallback (the device queue's shape, see GET /devices/tiktok/posts/scheduled) names the song a completed Instagram post (a carousel or a reel) went out with in place of one US Instagram does not have, null otherwise. being_fixed is true on a failed post Pamba's engineers are fixing: POST /devices/tiktok/posts/{postId}/retry refuses it (409 post_being_fixed), and the slideshow should not be posted again on that platform meanwhile. awaiting_replacement is true on a pending post whose account the platform took out of use (an Instagram account Instagram suspended or asks for identity verification, a TikTok account TikTok banned): it waits for the account's replacement, as on GET /devices/tiktok/posts/scheduled. A public TikTok photo post is verified like a video: from 20 minutes after it lands, its cover slide is matched against the account's newest photo posts, and a match links the post to the TikTok item its analytics come from (posted_url becomes the /photo/ link). An Only you post never shows on the public profile, so it stays verified on the phone's word and carries no analytics. An Instagram carousel is verified on the phone's word too: the phone reads it on the profile as the newest post, a carousel showing the cover slide. The account's hourly analytics refresh then finds the carousel on the profile grid by its posting time and links it (posted_url becomes the /p/ link), with its likes and comments; Instagram publishes no views for a carousel. A slideshow's reel (as_reel) is verified on the phone's word as well, the newest tile on the profile carrying the reel badge and the cover slide; the hourly refresh then finds it among the account's reels by its posting time, never a reel posted before its phone could have shared it or one already linked to another post, and links it (posted_url becomes the /reel/ link), with its views.

Zip the finished slides for download.

Authentication

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

Request

No body.

Response

200 { url, file_name, slide_count } — url is a public, permanent download of a zip holding one folder named after the slideshow with slide-01.png, slide-02.png, … in swipe order. 400 when no slide has its picture yet.

Notes

Slides still without a picture are left out, so slide_count can be below the slideshow's slide count. The editor's ⋯ menu offers the same as Export slides.

Replicate a viral TikTok photo post as a new slideshow for this business (15 credits per slide, at most 12 slides read).

Authentication

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

Request

{ tiktok_url?: string, corpus_video_id?: UUID, idea_id?: UUID, instructions?: string, avatar_project_id?: UUID, image_model?: gpt-image-2.5 | gemini, slide_count?: 2..12, promotional?: boolean }. One of tiktok_url (a TikTok photo post link — /photo/ or /video/ permalink, or a share link), corpus_video_id (a photo row from GET /picker/videos?media=photo or the discover feeds with media=photo) and idea_id (a photo idea from GET /ideas?media=photo). instructions is what should be different from the original, in the user's words (at most 4000 characters). avatar_project_id names the creator posting it; image_model picks the painter (gpt-image-2.5 by default). slide_count is how many slides to write (absent = one per original slide; fewer condenses the original's story, hook and payoff kept; more than the original was read is the original's count). promotional (default true) false makes a non-promotional replica: the original's subject rewritten with no brand or product woven in, and no company profile needed. Every generated picture is a 9:16 frame.

Response

202 Accepted with the new slideshow (no slides yet; replication.status building). 400 { error } for a link that is not a photo post's, a corpus row that is a video, instructions over the cap or a slide_count outside 2..12; 402 { error, message, remaining_credits, required_credits } below 30 credits; 422 { error } when a promotional replica's workspace has no company profile with something to promote; 429 when the day's AI-assist allowance is spent.

Notes

The build runs in the background and the show's `replication` reports it: status building with the step in flight (source = fetching the post and copying its pictures onto Pamba's storage, analyze = reading every slide's role, words and picture, compose = writing the new slides for this business on the original's pattern with the instructions applied, paint = handing them to the generation run), then completed once the slides are written and painting (the show goes through the usual generating status, 15 credits per slide, each painted as a variation of the original slide's picture) — or failed at the step that died, with error_message; POST /slideshows/{slideshowId}/replication/retry runs it again from that step. A completed build whose paint could not start for credits says so in error_message and leaves the drafted slides for POST /slideshows/{slideshowId}/generate. The build survives a server restart: a landed step is never redone. While it builds, every edit of the show answers 409.

Example

curl -X POST https://api.pamba.app/slideshows/replications \
  -H "X-API-Key: $PAMBA_API_KEY" -H "Content-Type: application/json" \
  -d '{"tiktok_url": "https://www.tiktok.com/@creator/photo/7300000000000000000", "instructions": "Feature our 30-day trial instead of their course"}'

Preview a pasted TikTok link before replicating it: is it a photo post, and what does it hold.

Authentication

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

Request

Query tiktok_url (required).

Response

200 { tiktok_url, author_username, description, cover_url, slide_count, view_count, like_count, slide_urls }. 400 { error } when the link is not a TikTok post's or names a video rather than a photo post; 502 { error } when the post could not be read.

Notes

Nothing is stored. The Create card's pasted-link tile shows this, and the grid's tiles page through slide_urls; those are TikTok CDN links signed for a few hours — show them, never persist them (a replication mirrors its own copies). A replication reads at most 12 of slide_count slides.

The replication that built (or is building) a slideshow.

Authentication

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

Request

No body.

Response

200 with the replication object of SLIDESHOW's `replication`. 404 when the slideshow was not replicated from a photo post.

Notes

source_slides carry the original's pictures (on Pamba's storage) with what the analysis read on each; empty until the source step lands, and without role/text/visual until the analyze step does.

Run a failed replication again from the step it died at.

Authentication

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

Request

No body.

Response

202 Accepted with the slideshow (replication.status building). 409 { error } when the show's replication is not failed (or the show was not replicated).

Notes

Landed steps are never redone; the slides paint as usual once the writing lands.

Paint every slide that still needs an image (15 credits per painted image).

Authentication

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

Request

No body.

Response

202 Accepted with the slideshow (status generating). 200 OK with the slideshow when nothing needed generating. 402 { error, message, remaining_credits, required_credits } when the workspace cannot afford the run. 409 { error } while a run is already in flight. 404 when the slideshow (or slide) is not in the workspace.

Notes

Credits are checked up front and charged per image as each one lands; a failed or refused image charges nothing and leaves the slide failed with an error_message. A slide with a picked picture or the avatar's photo only re-burns, free. The run survives a server restart: a picture already bought is burned, never bought again.

Add a slide (at most 20 per slideshow): blank, with your words, a duplicate of another slide, or one the writer drafts.

Authentication

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

Request

Body: { text_line (an empty string adds a blank slide with no text element), sub_line?, background_prompt?, background_image_url? (a Pamba-hosted picture for the slide), position? (0-based; appends when omitted), write_with_ai? (true: the writer drafts the line, sub-line and image description that fit the slideshow at that position — an AI assist, no credits; the words fields are ignored), duplicate_of? (a slide id to copy: same picture, placed pictures and words; lands right after it unless position says otherwise; a ready slide's image carries over), text_items? and media_items? (the new slide's layers, whole — restoring a removed slide; see the PATCH slide notes), background_source? (with background_image_url: generated | avatar_photo | library, the source the slide keeps; library when omitted), frame? (with background_image_url: picture | 9:16, the frame's shape — restoring a removed slide; the picture's own when omitted) }

Response

201 with the slide { id, position, text_line, sub_line, text_items, media_items, background_prompt, background_source, background_image_url, frame_aspect, image_url, status, error_message, updated_at }. 400 for an invalid duplicate_of. 404 when duplicate_of is not a slide of the slideshow. 409 while generating. 502 { error } when the writer fails.

Notes

A slide added without a picture is pending until regenerate paints it (15 credits) or background_image_url gives it one; a duplicate of a ready slide is ready at once and free.

Reorder the slides; the first becomes the cover.

Authentication

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

Request

Body: { slide_ids: string[] } — every slide id exactly once.

Response

200 { id, title, topic, avatar_project_id, avatar_name, style_prompt, image_model (gpt-image-2.5 | gemini), status (draft | generating | ready), error_message, slide_count, ready_count, cover_image_url, credits_to_generate, archived, created_at, updated_at, sound ({ src, label, source_duration, media_in, volume, tiktok_music_id, author, cover_url } or null), tiktok_picks_sound (true: TikTok picks the sound when it posts; false with a null sound: the post has no sound), instagram_sound (the Instagram carousel's own sound: { src, label, author, source_duration, media_in, volume, instagram_audio_id, explicit, cover_url } or null for none), user_review_state (not_reviewed | approved), slides: [{ id, position, text_line, sub_line, text_items: [{ id, text, x, y, w, style, style_preset, wrap_lines, rotation }], media_items: [{ id, src, x, y, w, aspect, rotation, opacity, label, asset_id, crop ({ x, y, w, h } or null) }], background_prompt, background_source (none | generated | avatar_photo | library), background_image_url, background_fit ({ x, y, w } or null for the cover-fit), frame_aspect (the frame's width ÷ height, 0.5625 for 9:16 to 1.78 for 16:9: the picked picture's own shape, or 9:16; the burned image_url is 1080 wide and this tall), image_url, status (pending | generating | ready | failed), error_message, updated_at }], replication (null, or the photo-post replication that built the show: { id, status (building | completed | failed), step (source | analyze | compose | paint | done), step_label, source_tiktok_url, source_author, source_slide_count, source_slides: [{ image_url, role, text, visual }], instructions, error_message, created_at, completed_at }) }. 400 when the list is not a permutation of the slides. 409 while generating.

Edit a slide's text elements, its placed pictures, its words, or its picture.

Authentication

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

Request

Body: { text_items? (the slide's elements, whole, at most 12 — see notes), media_items? (the slide's placed pictures, whole, at most 10 — see notes), text_line? (rewords the first element, adding one when the slide has none), sub_line? (rewords the second; an empty string removes it), background_prompt?, background_image_url? (a Pamba-hosted image — an upload, a media-library asset url or the avatar's photo — becomes the slide's picture, rehosted and burned at once, free), background_source? ('generated' switches a picked-picture slide back to a painted image on its next regenerate), background_fit? ({ x, y, w }: how the slide's image sits in the frame — its centre and width as frame fractions, a placed picture's units; the frame beyond it is black; re-burns a painted slide for free), clear_background_fit? (true returns the image to the cover-fit, which every newly picked picture starts with), frame? (picture: the frame takes the slide picture's own shape, 9:16 to 16:9, the whole picture visible — what a picked picture starts with; 9:16: the picture fills the full screen, its overflow cropped; re-burns a painted slide for free and returns the image to the cover-fit) }

Response

200 with the slide. 400 for a background_image_url off Pamba storage, more than 12 elements, more than 10 pictures, a picture src that was not hosted first, an unknown style_preset, a frame other than picture or 9:16, or frame=picture on a slide with no picture. 409 while that slide is generating.

Notes

A slide's words are text elements (text_items): each has its words, its placement as frame fractions (x, y = the block's centre, w = the wrap box width), its `rotation` (degrees clockwise about the centre, 0 when upright) and the video editor's text style (`style`, px at the 1080-wide frame — the same fields and presets as the editor's text items; `style_preset` names the preset the look came from and is re-resolved on save; `wrap_lines` are the editor's exact line breaks, honoured by the burn while they still spell the text). text_line and sub_line are the first and second elements' words, kept for the writer and the agent. Pictures placed on a slide (media_items) paint over the slide image and under the words, in array order, with the video editor's placement units: x, y = the picture's centre as frame fractions, w = its width as a fraction of the frame width (the height follows `aspect`, its width ÷ height), `rotation` in degrees, `opacity` 0-1, and an optional `crop` — the part of the source that shows, as fractions of the source ({ x, y } top-left corner, { w, h } size; the burn and the preview paint only that part, and `aspect` then describes it). Every src must come from POST /slideshows/{slideshowId}/media, which rehosts a picture onto permanent storage and measures it, or from POST /slideshows/{slideshowId}/media/generate, which paints one. A change to the words, their look or the pictures on a slide that already has an image re-burns it immediately at no cost. A background_prompt change keeps the current image until the slide is regenerated. A slide keeps its picture's shape and loses nothing of it: a 4:5 photo is a 1080×1350 slide and a 16:9 one 1080×608, posted as they are (TikTok letterboxes them; the Instagram carousel fits each whole into a square or 3:4 frame); a picture beyond 9:16–16:9 (a phone screenshot, a panorama) is fitted whole inside the nearer end, and a generated picture is 9:16.

Remove a slide (a slideshow keeps at least one).

Authentication

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

Request

Path: slideshowId, slideId.

Response

200 { success: true }. 400 when it is the last slide. 409 while that slide is generating.

Re-run one slide: a fresh image from its description with the text burned in, or a variation of its current picture.

Authentication

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

Request

Body (optional): { variation? (true keeps the slide's current picture as the reference — same composition and palette, new details) }

Response

202 Accepted with the slideshow (status generating). 200 OK with the slideshow when nothing needed generating. 402 { error, message, remaining_credits, required_credits } when the workspace cannot afford the run. 409 { error } while a run is already in flight. 404 when the slideshow (or slide) is not in the workspace. 400 for a variation on a slide with no picture yet.

Notes

15 credits for a painted slide; a picked-picture or avatar-photo slide only re-applies its text (free). A variation always paints (15 credits) and makes the slide a generated one; the reference is recorded on the slide so a run resumed after a restart still uses it.

Content Knowledge

The vocabulary the writer engine draws from: a playbook per reference account and tiered term lists.

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.

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.

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.

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.

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

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.

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

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

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. A value sent more than once in one request becomes one term: each field takes the last value sent for it, best_views the highest, and risk_flagged stays set if any entry sets it. Each repeat still counts in terms_written.

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" }
    ]
  }'

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.

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.

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 link to one TikTok video (a profile link included) is a 400 saying what to paste. Resolving a short link is a network round trip inside the request.

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.

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.

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.

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.

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.

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.

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 }].

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.

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.

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.

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.

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 assignment's account, for every avatar sharing it. MIX_TARGET_CHANGE, LANE_ASSIGNMENT and AUTONOMY_DIAL are read here but applied from their own routes; accepting one is 400.

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.

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.

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

Candidate, trialing and proven patterns are eligible for regular plan slots; cooling and retired patterns are excluded. Approving a finding makes its pattern available for planning without requiring promotion first.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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. Usage limits: separate from credits, every workspace has daily ceilings on the operations that cost Pamba compute without charging credits. A workspace with no plan and no account seats may start 100 renders a day (5 running at once), upload 50 videos a day and run 200 AI assists a day (edit_video, bullet repositioning, project descriptions, username suggestions, website analysis, the title a concept save writes, the slide copy a slideshow create writes, a draft's scene change, the look description a product's photos write, and the search terms written for an account's or avatar's own warming niche); a workspace with a plan or account seats may start 1,000 renders a day (25 at once), upload 500 videos and run 2,000 AI assists. Limits reset at midnight UTC. A refused call answers 429 with { error, code, retry_at } and a Retry-After header: code rate_limited is the workspace's own cap, free_tier_exhausted means the free tier's shared daily budget is spent (paid workspaces are never affected). Wait for retry_at instead of retrying.

Workspace credit spending and the first page of recorded charges.

Authentication

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

Request

Query period: today | 7d | 30d | 90d | all (default 30d), optional category: video_generation | draft_creation | edits | avatar_creation | frame_generation | voice_audio | other | uncategorized. Optional component: image_generation | video_generation | video_analysis_pass | vision_pass | llm_pass | voice_audio | transcription | other | not_itemized.

Response

200 { period, timezone, from, as_of, query, history_start, total_credits, charge_count, available_credits, unlimited_credits, categories: [{ id, label, credits, count }], first_page: { events, next_cursor, count, credits, component_credits } }. 400 for invalid filters.

Notes

All workspace members can read. Calendar windows include today in the workspace timezone. Totals use persisted positive charges; grants, purchases, holds and zero-credit platform metering are excluded. Category and component filters narrow the log only; overall totals and activity categories stay workspace-wide. Fetch /usage/components separately with this query token for component totals. Unknown history remains Uncategorized. available_credits is null for unlimited workspaces. Refresh starts a new cutoff; late-committing records may affect subsequent pages.

Break down recorded spending by component without delaying the usage summary.

Authentication

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

Request

Query query (token from summary), optional category (same activity values as summary).

Response

200 { query, category, credits, components: [{ id, label, credits, count }] }. 400 for expired, mismatched or invalid queries/categories.

Notes

Uses the summary's workspace, timezone and cutoff. Category scopes both credits and component totals. Components divide original charges using recorded cost proportions, never current prices, and sum exactly to credits. Missing or invalid detail stays not_itemized. Component charge counts overlap and must not be summed. Provider costs and internal model names are not exposed.

Paginate the workspace charge log.

Authentication

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

Request

Query query (token from summary), optional category, component and cursor (keep filters unchanged across pages), limit 1-100 (default 50).

Response

200 { events: [{ id, created_at, credits, category, attribution, activity, actor, entity_name, entity_path, action_id, details: [{ operation, model, units }], components: [{ id, label, credits }], component_attribution }], next_cursor, count, credits, component_credits }. 400 for expired, mismatched or invalid queries/cursors.

Notes

Newest first, stable timestamp/UUID keyset pagination. Query tokens last 24 hours and are workspace/timezone scoped. Activity attribution is exact, inferred, or unknown. component_attribution is recorded (one recorded component), allocated (a bundled charge split proportionally with deterministic rounding), or unavailable (not itemized). Component amounts sum exactly to each charge. When filtered, credits still sums whole matching charges; component_credits sums only the selected component. Only exact video_generation operation details expose model names. Provider costs are not exposed.

Read one charge belonging to the workspace and report period.

Authentication

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

Request

Path charge UUID; query query (token from summary).

Response

200 charge object, with the same fields as a log event. 400 for invalid query or ID; 404 when the charge is outside the workspace or period.

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, when the workspace's plan is still waiting on its first bank payment (a second plan would bill twice), or when the workspace is already on that plan with no change scheduled.

Notes

The Stripe Checkout page of a new plan takes a promotion code, which the user types there. Top-up and account-seat checkouts do not take promotion codes. For a workspace that already subscribes, session_url is Stripe's confirm page and the plan changes in place: a pricier plan starts a new billing period that day with its full credits, priced at the new plan's price less what the current plan's unspent credits cost (on a subscription that has a promotion code those credits stay in the balance instead), and a cheaper plan takes effect when the current period ends. No money is refunded. Asking for the plan the workspace is already on cancels a change scheduled for the period's end, and session_url then returns to the billing settings.

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.

Notes

The portal manages the payment method, invoices and billing details, and cancels a subscription at the end of its period. Plans are not changed there: POST /billing/create-stripe-checkout-session with the new plan's price_id.

Create a Stripe page where the user enters a new card for everything the workspace pays.

Authentication

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

Request

{ return_path (optional: the app path Stripe returns to, query string allowed; /home when absent), subscription_id (optional: only for a workspace whose seats bill to more than one card, the seat subscription whose card the new one replaces, from GET /devices/accounts/seat-billing's cards) }.

Response

200 { checkout_url }. 400 when subscription_id is not one of the workspace's seat subscriptions. 403 when the caller is not a workspace admin.

Notes

The user opens checkout_url and saves a card; Stripe returns them to return_path with card_update=success, or card_update=cancelled when they leave without saving. The saved card goes on every account-seat subscription and the credit plan, and any renewal that was declined is charged to it at once. A card changed in the customer portal does not reach subscriptions that carry their own card, so use this route after a declined renewal (an account row's seat_renewal_declined on GET /devices/accounts/inventory, or a plan whose subscription_status is past_due or unpaid). In a workspace whose seats the Pamba team split across cards, the new card replaces one card only: subscription_id's card, else the declined renewal's card, else the workspace's main card; the other card keeps paying for its seats.

Each TikTok and Instagram account of the workspace with whether it has Enhanced Warming, longer warming around each post (an archived account only while it still has it).

Authentication

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

Request

None.

Response

200 { accounts: [{ platform: "tiktok" | "instagram", username, display_name, avatar_name, status: "off" | "waiting_for_payment" | "on" | "ending", ends_at, eligible, seated, seat_cancelled }], subscription: { status: "active" | "past_due", billed_accounts, current_period_end, cancel_at_period_end, other_workspaces_covered, billed_to_workspace } | null, price_per_account_cents }.

Notes

It is turned on per account on a paid seat and billed per account per month, renewing on the same day as that account's seats. waiting_for_payment is an account bought whose payment has not landed; ending is one turned off, which keeps it until ends_at, the end of the period paid for. eligible is false for an account Pamba doesn't post from or that no paid seat funds (seated false; seat_cancelled true while its seat runs out a cancelled period), which cannot be turned on. subscription status past_due means a renewal is unpaid, and the longer warming waits for the payment. other_workspaces_covered counts the other workspaces with accounts on the same subscription. billed_to_workspace is false when it bills an account of its own, which POST /billing/card-update-checkout doesn't reach; email [email protected] in that case. price_per_account_cents is per account per month, null while the price cannot be read.

Turn Enhanced Warming on for the accounts named, through a Stripe page that charges them.

Authentication

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

Request

{ accounts: [{ platform: "tiktok" | "instagram", username }], return_path (optional: the app path Stripe returns to, query string allowed; /home when absent) }

Response

200 { checkout_url, reinstated }; checkout_url is null when every account named was ending and is back on, reinstated counts those. 400, a sentence for the buyer, when accounts is empty, names another platform, names an account Pamba doesn't post from, or one no paid seat funds. 403 when the caller is not a workspace admin. 409, a sentence for the buyer, when every account named is on already, a seat named is cancelled, the accounts' seats are billed on separate subscriptions (turn them on in groups), a renewal is unpaid, the seats renew within the next couple of hours, or it cannot be bought right now.

Notes

Billed per account, renewing with the accounts' seats. With none on their seats yet, checkout_url is a Checkout for the accounts named, charging them until the seats renew, then per account per month on the seats' renewal day. With some, it is Stripe's confirm page, which charges the accounts added for the time left in the current period and adds them to every month after. An account turned off earlier in the period comes back at no charge. Stripe returns the user to return_path with enhanced_warming=success, or enhanced_warming=cancelled when they leave; the accounts turn on once the payment clears.

Turn Enhanced Warming off for the accounts named, as an account seat is cancelled.

Authentication

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

Request

{ accounts: [{ platform: "tiktok" | "instagram", username }] }

Response

200 with the same body as GET /billing/enhanced-warming. 400 when accounts is empty, names another platform, or none of them has it on. 403 when the caller is not a workspace admin.

Notes

Each account keeps it until the end of the period already paid for (status ending, ends_at); nothing is refunded, and the next invoice bills those accounts no more. An account still waiting for its payment is simply not bought. Cancelling an account's seat (POST /devices/accounts/seat/cancel) turns it off the same way, and keeping the seat turns it back on.

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.

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.

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": 15.6, "bytedance/seedance-2.0/image-to-video": 53.3, "gemini-omni-1.1-flash-preview": 22.1, "gemini-omni-flash-preview": 22.1 }, "video_generation_credits_per_second": 60, "photo_generation_credits_per_image": 15, "scene_generation_credits_per_image": 15, "basic_voice_generation_credits": 100, "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: voices are now free curated roster voices, so no live flow charges it.

Your referral program: link, signups, eligible seats, commission and payouts.

Authentication

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

Request

No body.

Response

200 { programs: [{ code, link, rate_basis_points, status: "active" | "disabled", reward_type: "cash" | "credits", eligible_seats, signups: [{ signed_up_on, window_ends_on, bought_seats, eligible_seats }], totals: { pending_cents, approved_cents, paid_cents, reversed_cents }, months: [{ month, pending_cents, approved_cents, paid_cents, reversed_cents }], payouts: [{ paid_on, total_cents, method }] }] }.

Notes

Read-only, per user. Returns { programs: [] } for anyone who is not a referral partner. rate_basis_points / 100 is the percent of what referred customers pay for seats bought in their first 30 days (window_ends_on), earned for as long as those seats stay. Money is in cents; days are YYYY-MM-DD and months YYYY-MM, in UTC. Commission is pending (on hold) for 60 days in case of refunds, then approved for the next monthly payout; reversed is commission taken back after a refund. A disabled program's link no longer brings new signups. Signups carry no names or emails.

Example

curl https://api.pamba.app/referrals/me -H "Authorization: Bearer $TOKEN"

Fast Start

Create a project batch with an avatar and generate the videos right away.

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.",
      "generate_video": true
    }]
  }'

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; Facebook is a $100/mo add-on to an Instagram seat (facebook_seats with new Instagram seats, or facebook_for_instagram for an existing account), which posts that account's reels.