# Looptake: full API and site reference > Describe a video. Get one built to go viral. Viral short-form video from one sentence. Looptake: describe the video you want in one sentence and get a short-form video built to go viral on TikTok, Instagram Reels, YouTube Shorts. Inputs: a prompt, a website URL (brand-aware videos), a viral video link to remix, a long video to clip into shorts, or a CSV for bulk. Pipeline: hook and script, viral format, visuals (AI clips and images, stock, AI avatars), voiceover, word-timed animated captions, music, 9:16 render, viral score, scene editor, then scheduling and auto-posting. Analytics and trends feed the next batch. Teams and agencies get client workspaces, brand kits, roles, approvals with client review links, white-label reports, seats and an audit log. Generated from the same registry as https://looptake.com/openapi/v1.json and https://looptake.com/developers/reference. Short index: https://looptake.com/llms.txt. Support: support@looptake.com. # Developer surface - REST API: https://looptake.com/api/v1. Header `Authorization: Bearer lt_live_...` (or `X-API-Key`). Test keys start lt_test_ and never post. - CLI: `npx looptake` (binary `looptake`), with --json, --wait and stable exit codes. - MCP, hosted: Streamable HTTP at https://looptake.com/mcp with `Authorization: Bearer `. - MCP, local: `npx -y @looptake/mcp` over stdio with LOOPTAKE_API_KEY in the environment. - OpenAPI 3.1: https://looptake.com/openapi/v1.json. - All of it is on every plan. Generating ideas, scheduling and posting cost no credits; credits pay for AI media. Every response uses one envelope. `pagination` appears only on lists. ```json { "success": true, "data": {}, "pagination": { "total": 120, "page": 1, "limit": 20, "pages": 6 } } ``` ```json { "success": false, "error": { "type": "forbidden_scope", "message": "This key does not carry posts:write." } } ``` Branch on `error.type`, never on `error.message`. ## Formats 18 formats. 3 template formats render from a deck and cost no credits; 15 generate AI media and cost credits. - **walloftext** (Wall of text, turbo, 0 credits): Bold text over a quiet background clip. It reads in half a second. - **slideshow** (Slideshow, turbo, 0 credits): Text cards paced as video. No AI media, so it costs no credits. - **greenscreen** (Green screen meme, turbo, 0 credits): A reaction meme with your product behind the presenter. - **talkinghead** (Talking head, studio, 10 credits): An AI presenter delivers your script to camera, lip-synced, in 44 languages. - **ugc** (AI UGC, studio, 10 credits): A hand-held, creator-style testimonial that looks filmed on a phone. - **characterswap** (Character swap, studio, 10 credits): Keep a video that worked and replace the person in it. - **listicle** (Listicle, studio, 10 credits): Numbered points that land one at a time. - **ranking** (Ranking / tier list, studio, 10 credits): A tier list for your niche. It starts arguments in the comments. - **askmeanything** (Ask me anything, studio, 10 credits): A question card answered to camera. Good for objections. - **splitscreen** (Split screen, studio, 10 credits): Your clip on top, a satisfying clip below. Built for watch time. - **grid2x2** (2x2 grid, studio, 10 credits): Four variations playing at once in one post. - **fadein** (Single fade-in, studio, 10 credits): One image, one line, one slow push. For products that sell themselves. - **hookdemo** (Hook + demo, studio, 10 credits): A three-second hook stitched onto your product footage. - **interview** (Interview clip, studio, 10 credits): A two-shot podcast cut. A claim lands better when overheard. - **productspokesperson** (Product spokesperson, studio, 10 credits): A presenter holding your product, made from one photo of it. - **greenscreenmobile** (App green screen, studio, 10 credits): A presenter in front of your app screen, walking through the flow. - **clonevideo** (Motion clone, studio, 10 credits): Take a video whose movement worked and put your character in it. - **statvideo** (Stat video, studio, 10 credits): One big number, counted up. For the stat that is the whole pitch. ## Plans - **Creator**: $29/month, or $24/month billed yearly (17% off). One creator or brand posting every day. Credits 300/month, seats 1, workspaces 3, connected accounts 10, automations 5, AI avatars 10. - **Pro**: $79/month, or $66/month billed yearly (16% off). A small team running several brands or channels. Credits 1,000/month, seats 3, workspaces 10, connected accounts 30, automations 25, AI avatars 50. - **Agency**: $199/month, or $166/month billed yearly (16% off). An agency with clients, approvals and white label. Credits 3,000/month, seats 10, workspaces unlimited, connected accounts Unlimited, automations Unlimited, AI avatars 200, white-label. Every plan includes: Prompt, website, remix, long-video and CSV inputs; All 18 viral formats; AI clips, images, stock, avatars and voiceover; Word-timed animated captions and music; Viral score and scene editor; Auto-post to TikTok, Instagram and YouTube; Analytics and trend-fed hooks; Brand kits, approvals and client review links; REST API, CLI and MCP. # Endpoints 87 endpoints. 14 are studio renders under /content/. ## Identity What this key can do, which team it belongs to, and how many credits that team has left. ### GET /me Introspect the key. Returns the team the key belongs to, the plan, the credit balance, this period’s plan-quota usage, the default workspace and the scopes the key carries. This is the only endpoint that requires no scope, which makes it the right health check: a key that cannot call it is not a key. - Scope: none required - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The key itself is never returned. Only its public id, its name and its scopes. - usage.metrics carries one row per metered plan line, whether or not it has moved this period, so four meters can be rendered on the first day of the month. limit and remaining are both null on a plan where the line is unlimited: render the word rather than a number that means it. These are the same counters that answer 429 usage_limit_reached, and remaining is measured against the same ceiling that refusal is checked against. Example request: ```bash curl "https://looptake.com/api/v1/me" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "team": { "id": "e7293116-fd57-4456-8da0-032f4976145f", "name": "Northwind Labs", "plan": "creator" }, "credits": { "balance": 268, "grantedThisPeriod": 300, "periodEnd": "2026-09-01T00:00:00Z" }, "usage": { "periodStart": "2026-08-01", "metrics": [ { "metric": "turbo_posts", "used": 291, "limit": 300, "remaining": 9 }, { "metric": "content_pieces", "used": 12, "limit": 60, "remaining": 48 }, { "metric": "automations", "used": 1, "limit": 5, "remaining": 4 }, { "metric": "generations", "used": 140, "limit": 300, "remaining": 160 } ] }, "defaultWorkspace": { "id": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "name": "Northwind", "websiteUrl": "https://northwind.example" }, "key": { "id": "d81ed241-9404-4a1a-8864-242ecc540416", "name": "CI publisher", "scopes": [ "generations:read", "generations:write", "posts:read", "posts:write" ], "createdAt": "2026-07-02T09:14:00Z", "lastUsedAt": "2026-08-12T11:02:41Z" } } } ``` ## Credits The balance and every movement in it, including the automatic refunds for failed generations. ### GET /credits Read the balance and the ledger. The balance is the team’s, not the workspace’s, because credits are billed at the team level. The ledger is every movement in signed units: a grant is positive, a spend is negative, and a refund for a failed generation is a positive entry that names the job it reverses. - Scope: credits:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Notes: - Newest first. The ledger is append-only; an entry is never edited or removed. - `model` is reserved for the model that produced the media. It is null on every row until a media provider reports one, so do not build cost attribution on it yet. Query parameters: - type (enum; optional; one of: recurring | topup | spending | trial | spin | reward | refund | carryover | carry_expired): Filters the ledger to one kind of movement. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. Example request: ```bash curl "https://looptake.com/api/v1/credits?type=refund&limit=2" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "balance": 268, "ledger": [ { "id": "f4451e86-4d4f-409f-aefb-02e1ca406365", "credits": 10, "type": "refund", "spendingType": "studio_render", "model": null, "refId": "c02119cb-ed7a-40ed-87fe-c18edc1fa1a2", "createdAt": "2026-08-11T18:22:07Z" }, { "id": "87d5ca77-433e-4ffc-870a-b33e3c2d4c76", "credits": 30, "type": "refund", "spendingType": "influencer_video_per_second", "model": null, "refId": "08363fcd-f524-4126-b68d-fd64f2f9c09e", "createdAt": "2026-08-04T07:41:55Z" } ] }, "pagination": { "total": 6, "page": 1, "limit": 2, "pages": 3 } } ``` ## Workspaces A workspace is a brand. Everything with content in it is workspace-scoped. ### GET /workspaces List the team’s workspaces. One entry per brand, **archived ones included**, live first. `archivedAt` is null on a workspace that still accepts work and an instant on one that does not. An archived workspace is listed because its id is what `POST /workspaces/{id}/restore` needs. `brandRefreshedAt` is when the site was last crawled and the brand context rebuilt; a null means the workspace has a URL but has not been analysed yet. - Scope: workspaces:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Ordered live first, then the default, then oldest first. A key pinned to one workspace sees only that one, archived or not. Example request: ```bash curl "https://looptake.com/api/v1/workspaces" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "name": "Northwind", "websiteUrl": "https://northwind.example", "logoUrl": "https://cdn.looptake.com/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/logo.png", "isDefault": true, "archivedAt": null, "brandRefreshedAt": "2026-08-09T06:00:12Z", "createdAt": "2026-07-01T13:55:00Z" }, { "id": "d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f", "name": "Northwind Trade", "websiteUrl": null, "logoUrl": null, "isDefault": false, "archivedAt": "2026-08-10T09:12:44Z", "brandRefreshedAt": null, "createdAt": "2026-07-14T08:30:00Z" } ] } ``` ### POST /workspaces Create a workspace. Creating a workspace with a `websiteUrl` queues the first crawl, so the brand context is being built by the time the response arrives. The plan caps how many workspaces a team may hold; the cap is a `429 usage_limit_reached`, not a 400. - Scope: workspaces:write - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The cap counts live workspaces only, so archiving one frees its slot and restoring one needs a free slot. Body fields: - name (string; required): What the brand is called. Shown everywhere the workspace is chosen. - websiteUrl (string; optional; format uri): The site to read the brand context from. Queues a crawl when present. - logoUrl (string; optional; format uri): Overrides the logo found on the site. Example request: ```bash curl -X POST "https://looptake.com/api/v1/workspaces" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Northwind", "websiteUrl": "https://northwind.example" }' ``` Example response (201): ```json { "success": true, "data": { "id": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "name": "Northwind", "websiteUrl": "https://northwind.example", "logoUrl": null, "isDefault": false, "archivedAt": null, "brandRefreshedAt": null, "createdAt": "2026-08-12T11:04:20Z" } } ``` ### GET /workspaces/{id} Read a workspace and its brand context. The brand context is what every generation is written from: identity, audience, tone, value propositions, content angles, keywords, competitors and palette. Each extracted claim carries the source URL it was read from, so nothing in it is unattributable. An archived workspace answers `200` here with `archivedAt` set, because the read is what a restore starts from. - Scope: workspaces:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The workspace id. Example request: ```bash curl "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "name": "Northwind", "websiteUrl": "https://northwind.example", "isDefault": true, "archivedAt": null, "brandContext": { "identity": "Inventory forecasting for independent retailers.", "audience": "Owner-operators running one to five shops.", "tone": "Direct, numerate, no jargon.", "valueProps": [ "Stops dead stock", "Reorders before the shelf empties" ], "contentAngles": [ "The cost of one stockout", "What a good reorder point looks like" ], "keywords": [ "inventory forecasting", "reorder point", "dead stock" ], "competitors": [ "Inventory Planner" ], "palette": [ "#0b3d2e", "#f4f1ea" ], "sourceUrl": "https://northwind.example", "refreshedAt": "2026-08-09T06:00:12Z" } } } ``` ### PATCH /workspaces/{id} Correct a workspace’s name, handle, site, logo or timezone. Every field is optional and a field you leave out is left alone, so renaming a brand does not clear its logo. Send `websiteUrl: null` or `logoUrl: null` to clear one; `name`, `slug` and `timezone` cannot be cleared, because the columns behind them are not nullable. Changing `websiteUrl` does **not** re-read the site. The brand context is derived by `POST /brand/analyze`, and pointing at a new address without asking for a crawl would leave the two disagreeing silently. `timezone` is the one to get right early: it is `UTC` until you set it, and it is what an automation’s posting times are interpreted in. Sending an empty body is `invalid_input` rather than a 200 that changed nothing. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Changing `websiteUrl` leaves the brand context alone. Call POST /brand/analyze afterwards to read the new site. Path parameters: - id (string; required; format id): The workspace id. Body fields: - name (string; optional): The brand’s display name. - slug (string; optional): The URL handle, unique within your team. Slugified before it is stored; a handle another workspace holds is `invalid_input`. - websiteUrl (string; optional; format uri): The brand’s site. Send `null` to clear it. - logoUrl (string; optional; format uri): The brand’s mark. Send `null` to clear it. - timezone (string; optional): IANA zone, for example `Europe/Lisbon`. Everything scheduled in this workspace is interpreted in it. Example request: ```bash curl -X PATCH "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Northwind Supply", "timezone": "Europe/Lisbon" }' ``` Example response (200): ```json { "success": true, "data": { "id": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "name": "Northwind Supply", "websiteUrl": "https://northwind.example", "logoUrl": null, "isDefault": true, "archivedAt": null, "brandRefreshedAt": "2026-08-12T09:00:00Z", "createdAt": "2026-07-01T09:00:00Z" } } ``` ### POST /workspaces/{id}/archive Archive a workspace. Stops a brand without losing it. Nothing inside is cancelled: posts keep their slots, automations keep their status, connected accounts stay connected. What changes is that a scheduled post is **withheld** at its slot rather than published, and a launched automation refuses to schedule its next run. Both are checked at the moment the work would run, so a post already going out is not interrupted. The answer carries the two counts that stops, and restoring puts the withheld work back. See `POST /workspaces/{id}/restore` for how each half resumes. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Archiving the team’s default workspace is `409` while any other workspace is still active: choose the new default with POST /workspaces/{id}/default first, because promoting one for you would silently repoint every request that omits `workspaceId`. - Archiving the last active workspace is allowed. The team then has no default, and every call that omits `workspaceId` answers `400 invalid_input` until one is set or named. - A workspace that is already archived is `409`. Archiving frees the plan’s workspace slot. - While it is archived, every call that would schedule work or spend credits in it answers `409 conflict`: POST /posts, POST /content/{format}, POST /influencers, POST /influencers/{id}/videos, POST /automations and POST /automations/{id}/launch. Reads, cancellations, DELETE /posts/{id}, DELETE /influencers/{id} and suggestion generation are unaffected; a guard that blocked stopping things would trap you in the state you were leaving. Path parameters: - id (string; required; format id): The workspace id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/workspaces/d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f/archive" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f", "name": "Northwind Trade", "isDefault": false, "archivedAt": "2026-08-10T09:12:44Z", "withheldPosts": 12, "withheldAutomations": 1 } } ``` ### POST /workspaces/{id}/restore Restore an archived workspace. Un-archives a brand. Every post that was withheld and whose slot passed while the workspace was archived is put back on the publish queue by this call: a withheld publish is a job that already ran, so clearing the flag alone would leave those posts in the calendar with nothing to publish them, while a post whose slot is still ahead keeps the job it already had. It does **not** become the default again, because the team may have chosen another one in the meantime, so that is a separate call. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - A workspace that is not archived is `409`. - Restoring into a full plan is `429 usage_limit_reached`: archiving freed the slot, so restoring needs one back. - An automation whose launch was refused while the brand was archived is resumed by calling POST /automations/{id}/launch again, which accepts a launched run that scheduled nothing. It is a second call rather than part of this one because a launch carries the sandbox setting of the credential that made it, and nothing records that for a launch that never ran. Path parameters: - id (string; required; format id): The workspace id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/workspaces/d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f/restore" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f", "name": "Northwind Trade", "isDefault": false, "archivedAt": null, "withheldPosts": 0, "withheldAutomations": 0 } } ``` ### POST /workspaces/{id}/default Set the team’s default workspace. Points every request that omits `workspaceId` at this workspace, clearing whatever held it. One default per team, and an archived workspace cannot hold it. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - A key pinned to one workspace is refused with `403 forbidden_scope`, whatever scopes it carries. The default is team-wide, so honouring it would let a key that can read one brand redirect every other credential’s unqualified traffic into it. - An archived workspace is `409`. Restore it first. Path parameters: - id (string; required; format id): The workspace id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/default" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "name": "Northwind", "isDefault": true, "archivedAt": null, "withheldPosts": 0, "withheldAutomations": 0 } } ``` ### GET /workspaces/{id}/brand-kit Read a workspace’s brand kit. The look every render in this workspace gets: caption colours, font and position, the logo, an outro card, a watermark and the voice. A workspace with no kit answers the defaults, with `null` colours and `updatedAt: null`. - Scope: workspaces:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The workspace id. Example request: ```bash curl "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/brand-kit" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "logoAssetId": "a3c1e2d4-5f60-4718-8a9b-0c1d2e3f4a5b", "primaryColor": "#ff0066", "secondaryColor": "#1a1a1a", "textColor": "#ffffff", "font": "body", "captions": { "preset": "karaoke", "fill": "#ffffff", "stroke": "#000000", "highlight": "#ff0066", "position": "bottom" }, "voiceId": "narrator-1", "outro": { "text": "Follow @northwind", "logo": true }, "watermark": true, "updatedAt": "2026-08-12T09:00:00Z" } } ``` ### PATCH /workspaces/{id}/brand-kit Change a workspace’s brand kit. A field you leave out keeps its value and `null` clears it. The kit applies to renders that start after the change. `logoAssetId` must name an image asset (PNG) in this workspace. `font` is `display`, `body` or `mono`. `captions.position` is `top`, `middle` or `bottom`. Colours are `#rrggbb`. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - A colour that is not `#rrggbb`, an unknown font or a logo from another workspace is `400 invalid_input`. Path parameters: - id (string; required; format id): The workspace id. Body fields: - logoAssetId (string; optional; format id): An image asset in this workspace. Used by the outro and the watermark. - primaryColor (string; optional): Brand colour, `#rrggbb`. Fills the outro card. - secondaryColor (string; optional): Second brand colour, `#rrggbb`. - textColor (string; optional): Outro text colour, `#rrggbb`. - font (string; optional): `display`, `body` or `mono`. - captions (object; optional): `preset` (`karaoke` or `block`), `fill`, `stroke`, `highlight` (colours) and `position` (`top`, `middle`, `bottom`). - voiceId (string; optional): The voice used when a request names none. - outro (object; optional): `{ text, logo }`: a 2 second end card. `null` removes it. - watermark (boolean; optional): Draw the logo (or the workspace name) in the corner. Example request: ```bash curl -X PATCH "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/brand-kit" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "primaryColor": "#ff0066", "captions": { "highlight": "#ff0066" }, "watermark": true }' ``` Example response (200): ```json { "success": true, "data": { "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "logoAssetId": "a3c1e2d4-5f60-4718-8a9b-0c1d2e3f4a5b", "primaryColor": "#ff0066", "secondaryColor": "#1a1a1a", "textColor": "#ffffff", "font": "body", "captions": { "preset": "karaoke", "fill": "#ffffff", "stroke": "#000000", "highlight": "#ff0066", "position": "bottom" }, "voiceId": "narrator-1", "outro": { "text": "Follow @northwind", "logo": true }, "watermark": true, "updatedAt": "2026-08-12T09:00:00Z" } } ``` ### DELETE /workspaces/{id}/brand-kit Reset a workspace’s brand kit. Removes the kit, so later renders use the house look. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The workspace id. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/brand-kit" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "removed": true } } ``` ## Social accounts The TikTok, Instagram and YouTube accounts connected over OAuth, and whether their tokens are still good. ### GET /accounts List connected social accounts. The ids returned here are the ids `selectedAccounts` takes on a post or an automation. `status` is worth checking before a scheduled run: an `expired` token fails at publish time, and a post scheduled against one will report a per-target failure rather than being rejected up front. - Scope: accounts:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Query parameters: - platform (enum; optional; one of: tiktok | instagram | youtube): Filters to one platform. - ownerType (enum; optional; one of: brand | influencer): Brand accounts or accounts that belong to an AI influencer. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/accounts?platform=tiktok" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "ff9d429b-07c5-4320-a98c-ac6e1791b828", "platform": "tiktok", "handle": "@northwind", "displayName": "Northwind", "avatarUrl": "https://cdn.looptake.com/ff9d429b-07c5-4320-a98c-ac6e1791b828/avatar.jpg", "ownerType": "brand", "influencerId": null, "scopes": [ "video.publish", "user.info.basic" ], "status": "connected", "expiresAt": "2026-10-02T00:00:00Z" } ] } ``` ## Generations Suggestions: a caption, a post body, hashtags, a rationale and a deck. Accepting one turns it into content. ### POST /generations Generate suggestions. Synchronous: the response carries the finished suggestions, each with its on-screen caption, platform body, hashtags, hook score, the angle it came from and, for a Turbo format, the deck that renders it. Generating costs no credits on any plan, which is why this is the one endpoint with a tight rate limit rather than a price. - Scope: generations:write - Success: 201 - Rate limit: 10 per 300 s (generations) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The deck is omitted from the list shape and returned in full by GET /generations/{id}. - Ten requests per 300 seconds per key. A batch of ten counts as one request. - Every suggestion carries a brand-safety `review`: `pass`, `flagged`, or `withheld`. A withheld suggestion is returned with the phrase that caused it and cannot be accepted. The batch is never shortened, so `count` suggestions come back whatever the review found. Body fields: - count (integer 1-10; optional; default 3): How many suggestions to return. - format (enum; optional; one of: walloftext | slideshow | greenscreen | talkinghead | ugc | characterswap | listicle | ranking | askmeanything | splitscreen | grid2x2 | fadein | hookdemo | interview | productspokesperson | greenscreenmobile | clonevideo | statvideo): Pins every suggestion to one format. Left out, the mix is chosen from the brand context. - prompt (string; optional): A steer for this batch: a launch, a season, an objection to answer. With no brand profile, the prompt is the brief. - influencerId (string; optional; format id): Writes the scripts for one AI influencer, in that influencer’s voice. - workspaceId (string; optional; format id): Which brand to write for. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/generations" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "count": 2, "format": "walloftext" }' ``` Example response (201): ```json { "success": true, "data": [ { "id": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "format": "walloftext", "caption": "One stockout costs more than a year of forecasting", "postCaption": "The maths nobody runs before they run out.", "hashtags": [ "#retail", "#inventory", "#smallbusiness" ], "why": "The pricing objection, answered with the cost of the alternative.", "angle": "The cost of one stockout", "score": 78, "status": "pending", "review": { "verdict": "pass", "findings": [] }, "createdAt": "2026-08-12T11:05:02Z" }, { "id": "be40861b-3bd3-4b0a-b35f-1aa8835e78eb", "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "format": "walloftext", "caption": "Your reorder point is a guess. Here is the number.", "postCaption": "Three inputs, one number, no spreadsheet.", "hashtags": [ "#inventory", "#retailtips" ], "why": "Teaches the concept the product automates.", "angle": "What a good reorder point looks like", "score": 71, "status": "pending", "review": { "verdict": "withheld", "findings": [ { "rule": "unsupported_claim", "severity": "withhold", "origin": "rule", "quote": "Your reorder point is a guess. Here is the number.", "detail": "`guaranteed` asserts something about the product that nothing in the brand context says" } ] }, "createdAt": "2026-08-12T11:05:02Z" } ] } ``` ### POST /videos Make a video from one sentence. No brand profile needed. The agent writes a brief, scores hook candidates, writes the script, picks the format, adds an AI still and voice, times the captions and renders 9:16. Returns at once; poll GET /videos/{id}. Text-only videos are free. A still costs 3 credits and voice 1 credit per 15 seconds; unused credits come back when the video is done, and all of them on failure. - Scope: generations:write - Success: 202 (poll statusUrl) - Rate limit: 10 per 300 s (generations) - Credits: 3 per AI still - Idempotency-Key: honoured - Paginated: no Notes: - Studio formats such as talking heads use POST /content/{kind}. - If the team cannot cover the still and no voice is asked for, the video is made without the still and costs nothing. Body fields: - prompt (string; required; maximum 600): The video, in one sentence. Up to 600 characters. - length (enum; optional; default "30s"; one of: 15s | 30s | 60s): Target length. - format (enum; optional; one of: auto | walloftext | slideshow | greenscreen | listicle | ranking | splitscreen | askmeanything | statvideo | fadein): Left out or `auto`, the agent picks. - language (string; optional; default "en"): Language of the script, voice and captions. - voice (string; optional): A voice id such as `nova`. Left out, no voice-over. - count (integer 1-4; optional; default 1): How many videos to make from the best hooks. - workspaceId (string; optional; format id): Which workspace. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/videos" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "Three mistakes new runners make, and the fix for each", "length": "30s" }' ``` Example response (202): ```json { "success": true, "data": { "id": "3f2b8f4e-6a51-4c55-9d0b-5c0f1b7e2a10", "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "prompt": "Three mistakes new runners make, and the fix for each", "length": "30s", "format": null, "status": "queued", "stage": "hook", "stages": [ "hook", "script", "visuals", "voice", "captions", "music", "render" ], "progress": 0, "viralScore": null, "hooks": [], "outputs": [], "creditsReserved": 3, "creditsSpent": 0, "statusUrl": "https://looptake.com/api/v1/videos/3f2b8f4e-6a51-4c55-9d0b-5c0f1b7e2a10" } } ``` ### POST /remix Remix a viral video onto your topic. Give a TikTok, YouTube or Instagram link. We read its public title, caption and author (oEmbed, or the page tags), the agent extracts the structure (hook pattern, beats, pacing, format, on-screen text) and writes a new video on your topic with the same shape, through the same pipeline as POST /videos. The source video is never downloaded or reused. With no `topic`, the workspace brand is the topic. Poll GET /videos/{id}; `source.structure` shows what was copied. Costs as POST /videos. - Scope: generations:write - Success: 202 (poll statusUrl) - Rate limit: 10 per 300 s (generations) - Credits: 3 per AI still - Idempotency-Key: honoured - Paginated: no Body fields: - url (string; required; format uri): The video to learn from: a TikTok, YouTube or Instagram link, or any page with og tags. - topic (string; optional; maximum 600): What your video is about. Left out, the workspace brand is used; a workspace with no brand needs a topic. - length (enum; optional; default "30s"; one of: 15s | 30s | 60s): Target length. - format (enum; optional; one of: auto | walloftext | slideshow | greenscreen | listicle | ranking | splitscreen | askmeanything | statvideo | fadein): Left out or `auto`, the source structure picks. - language (string; optional; default "en"): Language of the script, voice and captions. - voice (string; optional): A voice id such as `nova`. Left out, no voice-over. - count (integer 1-4; optional; default 1): How many videos to make. - workspaceId (string; optional; format id): Which workspace. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/remix" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "url": "https://www.tiktok.com/@runcoach/video/7412345678901234567", "topic": "Meal prep for night-shift nurses" }' ``` Example response (202): ```json { "success": true, "data": { "id": "5c1d7a0e-1f2b-4c3d-8e9f-0a1b2c3d4e5f", "kind": "remix", "prompt": "Meal prep for night-shift nurses", "source": { "url": "https://www.tiktok.com/@runcoach/video/7412345678901234567", "platform": "tiktok", "topic": "Meal prep for night-shift nurses" }, "status": "queued", "stage": "hook", "progress": 0, "creditsReserved": 3, "statusUrl": "https://looptake.com/api/v1/videos/5c1d7a0e-1f2b-4c3d-8e9f-0a1b2c3d4e5f" } } ``` ### POST /clips Cut a long video into shorts. Send a video asset or a public media URL. The worker fetches it, transcribes it with timestamps, the agent picks the best self-contained moments (strong hook, clear payoff, 15 to 60 seconds), and each one is cut, reframed to 9:16 (centre crop over a blurred fill) and given word-timed captions in your brand kit style. Each clip is a content item with a viral score. Costs 1 credit per started minute of source: the known length is reserved, or up to 60 minutes when it is not known, and the rest comes back. Poll GET /videos/{id}. - Scope: generations:write - Success: 202 (poll statusUrl) - Rate limit: 10 per 300 s (generations) - Credits: 1 per minute of source video - Idempotency-Key: honoured - Paginated: no Notes: - Each output carries `startMs`, `endMs`, `title`, `hook`, `viralScore`, `contentId` and `previewUrl`. - A source longer than the reservation fails and every credit comes back. Body fields: - assetId (string; optional; format id): A video in the asset library. Send this or `mediaUrl`. - mediaUrl (string; optional; format uri): A public URL of the video file, up to 200 MB. Send this or `assetId`. - count (integer 1-10; optional; default 3): How many clips to cut. - length (enum; optional; default "60s"; one of: 15s | 30s | 60s): The longest a clip may run. Clips are at least 15 seconds. - language (string; optional; default "en"): Language of the speech, titles and hooks. - workspaceId (string; optional; format id): Which workspace. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/clips" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "mediaUrl": "https://cdn.example.com/podcast-ep-12.mp4", "count": 5 }' ``` Example response (202): ```json { "success": true, "data": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "kind": "clip", "prompt": "Clips from podcast-ep-12.mp4", "count": 5, "status": "queued", "stage": "ingest", "stages": [ "ingest", "transcribe", "select", "cut" ], "progress": 0, "creditsReserved": 60, "statusUrl": "https://looptake.com/api/v1/videos/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" } } ``` ### POST /videos/bulk Make up to 100 videos from a CSV. Send JSON `{ csv }` or `{ rows }`, a `text/csv` body, or a multipart form with a `file` field. Columns: `prompt` (required), `length`, `format`, `schedule_at`, `accounts`, `voice`, `language`, `made_for_kids`. Every row is checked first; one bad row creates nothing and the 400 names every bad row and line. A row with `schedule_at` and `accounts` (account ids or platform names) is scheduled to post when its video is ready; YouTube needs `made_for_kids`. Poll GET /videos/bulk/{id}. Each row costs as POST /videos. - Scope: generations:write - Success: 202 (poll statusUrl) - Rate limit: 10 per 300 s (generations) - Credits: 3 per AI still - Idempotency-Key: honoured - Paginated: no Body fields: - csv (string; optional): The CSV text, with a header row. Send this or `rows`. - rows (object[]; optional): Rows as objects with the same keys as the CSV columns. Send this or `csv`. - timezone (string; optional; format timezone; default "UTC"): The zone a `schedule_at` without an offset is read in. - validateOnly (boolean; optional; default false): Check the rows and create nothing. - workspaceId (string; optional; format id): Which workspace. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/videos/bulk" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "csv": "prompt,length,schedule_at,accounts\n\"Three tips for new runners\",30s,2026-11-02T09:00:00Z,tiktok\n" }' ``` Example response (202): ```json { "success": true, "data": { "batchId": "2b4c6d8e-0f1a-4b3c-9d5e-7f8a9b0c1d2e", "total": 1, "queued": 1, "running": 0, "ready": 0, "failed": 0, "progress": 0, "done": false, "statusUrl": "https://looptake.com/api/v1/videos/bulk/2b4c6d8e-0f1a-4b3c-9d5e-7f8a9b0c1d2e", "videos": [ { "id": "6e5d4c3b-2a19-4807-b6f5-e4d3c2b1a090", "prompt": "Three tips for new runners", "status": "queued", "stage": "hook", "progress": 0, "scheduleAt": "2026-11-02T09:00:00Z" } ] } } ``` ### GET /videos/bulk/{id} Read a bulk batch’s progress. Counts by status, the average `progress`, `done` once every row is `ready` or `failed`, and each row’s status and outputs. - Scope: generations:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The batch id from POST /videos/bulk. Example request: ```bash curl "https://looptake.com/api/v1/videos/bulk/2b4c6d8e-0f1a-4b3c-9d5e-7f8a9b0c1d2e" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "batchId": "2b4c6d8e-0f1a-4b3c-9d5e-7f8a9b0c1d2e", "total": 2, "queued": 0, "running": 1, "ready": 1, "failed": 0, "progress": 70, "done": false, "videos": [] } } ``` ### GET /generations List suggestions. Suggestions owned by an automation are excluded, because an automation holds hundreds and they belong to its review queue rather than to yours. Read those through GET /automations/{id}/posts instead. - Scope: generations:read - Success: 200 - Rate limit: 10 per 300 s (generations) - Credits: none - Idempotency-Key: ignored - Paginated: yes Query parameters: - status (enum; optional; one of: pending | accepted | scheduled | discarded): Filters by lifecycle status. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/generations?status=pending&limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "format": "walloftext", "caption": "One stockout costs more than a year of forecasting", "hashtags": [ "#retail", "#inventory" ], "score": 78, "status": "pending", "reviewVerdict": "pass", "automationId": null, "createdAt": "2026-08-12T11:05:02Z" } ], "pagination": { "total": 34, "page": 1, "limit": 1, "pages": 34 } } ``` ### GET /generations/{id} Read one suggestion in full. Adds the three things the list shape omits: the rationale, the deck, and the brand-safety review in full. The deck is the render blueprint: normalised geometry, slides, text boxes, transitions and captions. It is editable, and an edited deck sent to POST /posts renders exactly what you sent. The review carries one finding per problem, each with the phrase it is about; the list shape carries only `reviewVerdict`. - Scope: generations:read - Success: 200 - Rate limit: 10 per 300 s (generations) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The suggestion id. Example request: ```bash curl "https://looptake.com/api/v1/generations/6522d1ec-2c11-4d3a-b078-6f6cfe9f217b" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "format": "walloftext", "caption": "One stockout costs more than a year of forecasting", "postCaption": "The maths nobody runs before they run out.", "hashtags": [ "#retail", "#inventory", "#smallbusiness" ], "why": "The pricing objection, answered with the cost of the alternative.", "angle": "The cost of one stockout", "score": 78, "status": "pending", "review": { "verdict": "pass", "findings": [] }, "deck": { "version": 1, "ratio": "9:16", "durationMs": 8000, "fps": 30, "background": { "kind": "clip", "src": "a7273471-d932-4f9a-958e-79abdd1f3fd7" }, "slides": [ { "startMs": 0, "endMs": 8000, "boxes": [ { "id": "hook", "text": "One stockout costs more than a year of forecasting", "rect": { "x": 0.08, "y": 0.18, "w": 0.84, "h": 0.34 } } ] } ], "captions": { "enabled": true, "style": "karaoke", "position": 0.78 } } } } ``` ### POST /generations/{id}/accept Accept a suggestion into content. Turns a suggestion into a renderable content piece and returns its `contentId`. Accepting counts against the plan’s saved-post quota; generating did not. A Turbo format is renderable immediately, a Studio format begins its render, and either way the id is what POST /posts takes as `contentId`. - Scope: generations:write - Success: 201 - Rate limit: 10 per 300 s (generations) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Accepting a suggestion twice returns the same content id rather than a second piece. - A suggestion whose brand-safety review withheld it is 409 `conflict`, and no quota is consumed. POST /posts with a `suggestionId` refuses it the same way. Read GET /generations/{id} for the finding, then edit the copy or generate another. Path parameters: - id (string; required; format id): The suggestion id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/generations/6522d1ec-2c11-4d3a-b078-6f6cfe9f217b/accept" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (201): ```json { "success": true, "data": { "contentId": "c02119cb-ed7a-40ed-87fe-c18edc1fa1a2", "generationId": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "format": "walloftext", "renderStatus": "pending", "creditsSpent": 0 } } ``` ### POST /generations/{id}/variants Create A/B hook variants. Rewrites the hook and keeps everything else, so the only difference between the variants is the line that has to stop the scroll. Angle, deck, format and hashtags are carried over. Each variant is a suggestion in its own right, with its own id and its own hook score. - Scope: generations:write - Success: 201 - Rate limit: 10 per 300 s (generations) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The suggestion to vary. Body fields: - count (integer 2-5; optional; default 3): How many variants to write. Example request: ```bash curl -X POST "https://looptake.com/api/v1/generations/6522d1ec-2c11-4d3a-b078-6f6cfe9f217b/variants" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "count": 2 }' ``` Example response (201): ```json { "success": true, "data": [ { "id": "ba2bfff0-0078-45f8-8352-0967f185cba5", "remixSource": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "caption": "The stockout you did not budget for", "score": 74, "status": "pending", "reviewVerdict": "pass" }, { "id": "e70157dd-5ee2-4667-a594-61f9c20ebaf7", "remixSource": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "caption": "Empty shelf, full warehouse. Both cost the same.", "score": 81, "status": "pending" } ] } ``` ## Posts and calendar Scheduling and publishing, with one result row per platform account and the analytics that came back. ### POST /posts Create and schedule a post. Exactly one content source, resolved in a fixed order: `suggestionId`, then `contentId`, then `deck` with `format`, then `videoUrl` or `images`. Sending two sources is not an error; the first one in that order wins, which keeps the resolution deterministic. A post that has to produce its video before it can publish returns 202 and a `statusUrl`: a deck has to be rendered, and a `videoUrl` or `images` post has to be fetched into our own storage first. - Scope: posts:write - Success: 201 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: honoured - Paginated: no Notes: - `approvalStatus` is on this response because it is the only place the common case sees it: a scheduled post with nothing to render is 201 and terminal, so nothing polls `statusUrl` afterwards. `pending` means a person still has to release it and the scheduled time passing does not publish it or cancel it. `null` means no approval was demanded of this post, which is not the same as approved and is not a block: read a missing value as neither. - A post built from a `deck`, a `videoUrl` or `images` returns 202 rather than 201. Poll `statusUrl` until the status is terminal. - The media behind `videoUrl` and `images` is fetched once, when the post is created, and published from our copy. A URL that redirects to a private address, answers with something that is not a video or an image, or is larger than 200 MB is refused, and `renderStatus` becomes `failed` with the reason on the post. - A video is measured after it is fetched and checked against each selected platform: a video longer than YouTube Shorts accepts, or in an aspect ratio Instagram will not publish, fails the post before anything reaches a platform. - A post that names a YouTube account and does not send `declarations.madeForKids` is refused here, before anything is rendered or charged. YouTube requires that answer on every upload and it is the account holder’s to give: we send what you state, and nothing when you state nothing. - A field this endpoint does not have is a 400, not a field we ignore. A misspelled key and a capability this build does not carry are both refused at the door, so `altText` and `hashtagPlacement` are refusals rather than fields we store and never send. `firstComment` was on that list until 2026-08-19 and is not any more: it is a real field now, inside `perPlatform`, on the two networks whose API can post a comment. At the TOP level it is still a 400, and deliberately so, a first comment is per network because one of the three cannot post one at all. The same rule already applied inside `perPlatform`; since 2026-08-19 it applies to the whole body. - Honours `Idempotency-Key`. The same key with the same body replays the stored response; the same key with a different body is a 409. - Sixty requests per 300 seconds per key. Body fields: - suggestionId (string; optional; format id): Source 1. Accepts the suggestion and posts it in one call. - contentId (string; optional; format id): Source 2. An already-accepted content piece. - deck (object; optional): Source 3. A render blueprint. Requires `format`, and returns 202. - format (enum; optional; one of: walloftext | slideshow | greenscreen | talkinghead | ugc | characterswap | listicle | ranking | askmeanything | splitscreen | grid2x2 | fadein | hookdemo | interview | productspokesperson | greenscreenmobile | clonevideo | statvideo): Required with `deck`, ignored otherwise. - videoUrl (string; optional; format uri): Source 4. Media you already have. Fetched into our own storage before the post publishes, so the post does not depend on your URL still answering at its scheduled time. Returns 202. - images (string[]; optional): Source 4. Image URLs, fetched into our own storage the same way as `videoUrl`. Every entry is stored, and only the FIRST becomes the post media, which is the one asset every selected platform receives. There is no photo carousel in this build: all three clients publish through their video endpoints, so an image post sends a single still on a video call and the entries after the first reach no platform at all. `POST /posts/preview` names them in `notSent`. Returns 202. - thumbnailUrl (string; optional; format uri): Cover frame. Only read alongside `videoUrl`. - selectedAccounts (object; required): Platform to account ids, for example {"tiktok":["ff9d429b-07c5-4320-a98c-ac6e1791b828"]}. At least one list must be non-empty. - schedule (enum; optional; default "asap"; one of: asap | scheduled | draft): When this post goes out, and whether it goes out at all yet. `asap` publishes as soon as the render finishes and the post reads `queued`. `scheduled` publishes at `scheduledTime`. `draft` saves it unfinished: the post reads `draft`, no publisher is queued for it, and **nothing sends it until you call POST /posts/{id}/publish**. A draft may carry a `scheduledTime` as well, and releasing it then sends it at that instant rather than at once, which is what lets you pencil in a slot for a post you have not finished writing. - scheduledTime (string; optional; format date-time): ISO 8601, in the future. Required when `schedule` is `scheduled`, and optional when it is `draft`. - timezone (string; optional; format timezone; default "UTC"): IANA zone the schedule is read in, for example Europe/Lisbon. - caption (string; optional): Overrides the platform body. Validated before the post is accepted against the limit of each platform that receives it, which is every selected platform unless `perPlatform` gives one its own words. - hashtags (string[]; optional): Appended to the caption, per platform convention. - perPlatform (object; optional): The words one platform gets instead of `caption` and `hashtags`, keyed by platform: `{"tiktok":{"caption":"…","hashtags":["…"]},"youtube":{"title":"…"}}`. Every field inside is optional and leaving one out means that platform inherits the post’s. A platform you do not name here publishes exactly what it always did, so a body without this field behaves identically to one written before it existed. `title` is only accepted for a platform that has a title field, which is YouTube; a title for TikTok or Instagram is a 400 rather than a value we store and never send. Naming a platform this post does not publish to is a 400 for the same reason. This is what makes a caption legal that one platform would refuse: the limit is checked against the text each account actually receives, so a 3,000-unit YouTube caption with a shorter TikTok one beside it is accepted where the single caption was not. It is keyed by platform and not by account: two TikTok accounts in one workspace get one TikTok caption. `firstComment` is the fourth field inside a block: the comment we post underneath the moment that network has the post, which is where hashtags go if you want the caption to read as a sentence. It is accepted for Instagram and YouTube and is a 400 for TikTok naming the platform, because TikTok publishes no comment-write endpoint in any public API, not a limit of this build, and no release of it will change the answer. It is also a 400 on a target set to `draft` or `inbox`, because a comment goes under a live post and neither of those produces one. Read `firstComment` on each target of `GET /posts/{id}` to see whether it landed: it is a SECOND call to the network, made after the post is live, so it can fail on its own, and when it does the post stays `posted` and that field carries the reason. - ownerType (enum; optional; default "brand"; one of: brand | influencer): Whose accounts this publishes from. - influencerId (string; optional; format id): Required when `ownerType` is `influencer`. - publishMode (enum; optional; default "publish"; one of: publish | draft | inbox): How far to take the publish. `publish` goes live. `draft` uploads the video and leaves it unpublished for you to finish, which only YouTube can do: it becomes a private upload in YouTube Studio. `inbox` delivers the video to the creator’s app as a notification, which only TikTok can do. One value applies to the whole post, so a post that names a YouTube and a TikTok account and asks for `draft` is a 400 naming TikTok, not a post that holds one back and publishes the other. Set `tiktokSendToInbox` alongside `draft` to hold the YouTube upload back and route the TikTok copy to the inbox, which is the closest TikTok gets to not publishing. - tiktokSendToInbox (boolean; optional; default false): The TikTok-scoped spelling of `publishMode: "inbox"`, and it applies only to this post’s TikTok target. The video arrives in your TikTok notifications rather than the Drafts folder, and it carries no caption: TikTok’s inbox endpoint has no caption field, so the generated caption and hashtags stay on this post and you paste them in before publishing. This is not the same thing as `publishMode: "draft"`, which is an unpublished upload and which TikTok cannot do at all. - tiktokOptions (object; optional): TikTok publishing options for this post. `disableComment`, `disableDuet` and `disableStitch`, or the permissive spellings `allowComments`, `allowDuet` and `allowStitch`. Anything you do not name is left at the account default. - declarations (object; optional): Statements the platforms require from the account holder, not publishing options. `madeForKids` is YouTube’s audience declaration and is required whenever this post names a YouTube account: we will not answer it on your behalf, so a post that targets YouTube without it is a 400. `brandedContent` discloses a paid partnership to TikTok and `brandOrganic` discloses that you are promoting your own business; leave either out and nothing is sent, which is where TikTok’s own composer starts. `aiGenerated` is TikTok’s `is_aigc` and `containsSyntheticMedia` is YouTube’s `status.containsSyntheticMedia`: two fields rather than one, because the two platforms word the question differently and we never send one platform the answer you gave the other. Neither is required by its platform and neither is required here, so a post that states nothing about how its video was made publishes with the key absent. Almost everything scheduled from the Looptake composer is generated, so the composer and the automation planner will not submit without an answer, on the paths that carry one. A TikTok post routed to the creator’s inbox is asked nothing, because that endpoint takes the video and no fields at all, and an Instagram-only post is asked nothing because Instagram carries no declaration field. Neither refusal exists at this API, which accepts any of these stated or unstated, and Instagram receives none of these on any path. An unrecognised key in this object is a 400 rather than a field we ignore. - workspaceId (string; optional; format id): Which brand this post belongs to. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "suggestionId": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "selectedAccounts": { "tiktok": [ "ff9d429b-07c5-4320-a98c-ac6e1791b828" ], "youtube": [ "1c74c860-000b-4136-88a7-a17f31019346" ] }, "schedule": "scheduled", "scheduledTime": "2026-09-01T15:00:00Z", "timezone": "Europe/Lisbon", "declarations": { "madeForKids": false, "brandedContent": true, "aiGenerated": true, "containsSyntheticMedia": true } }' ``` Example response (201): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "scheduled", "renderStatus": "pending", "scheduledTime": "2026-09-01T15:00:00Z", "timezone": "Europe/Lisbon", "approvalStatus": "pending", "statusUrl": "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef", "targets": [ { "accountId": "ff9d429b-07c5-4320-a98c-ac6e1791b828", "platform": "tiktok", "status": "scheduled" }, { "accountId": "1c74c860-000b-4136-88a7-a17f31019346", "platform": "youtube", "status": "scheduled" } ] } } ``` ### POST /posts/preview See what each platform would receive, before the post exists. Takes the body of `POST /posts` and answers with the publish request each selected account would be sent: the exact caption-and-hashtag text, the derived YouTube title, the visibility, the TikTok switches and the declarations, built by the same function the publisher builds it with. Nothing is written, nothing is queued and nothing is charged. The two refusals that are per platform arrive per target rather than as a status: a caption too long for one of your accounts and an unstated `declarations.madeForKids` are `refusal` on that target and `200` overall, so you see every account’s answer at once instead of the first one. Everything else refuses exactly as `POST /posts` does. - Scope: posts:write - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - `bodyText` is the string the platform receives, caption and hashtags joined. It is `null` where the platform receives no text at all, which today is a TikTok target in `inbox` mode: that endpoint takes the video and nothing else, and the creator writes the caption in the app. - `title` is set only for a platform that has a title field, which is YouTube. Write one with `perPlatform.youtube.title` and it is what you see here and what is uploaded; write nothing and it is the first line of the caption cut to YouTube’s 100 units, which is what this product did before a title could be written. Either way this is where you see which line that is. - `firstComment` is the text that network receives as a comment under the post, or `null` where it receives none. It is the one field here that is not part of the publish request: no network carries a comment on the call that creates the post, because a comment needs the id that call returns, so this is always a second call made after the post is live. `null` on a TikTok target is the whole reason to preview it, TikTok has no comment endpoint, `POST /posts` refuses a first comment for it outright, and this is where you see that before you schedule. - `privacy` is `public` on every target. Nothing on the request, the post row or the job payload can set it in this build, so the preview reports the value rather than offering a choice. - `notSent` lists request fields this build accepts and sends to no platform. `thumbnailUrl` is one: it is fetched, stored and shown in the dashboard, and no platform receives it, so each of them picks its own frame. `images[1:]` is the other: every image you send is fetched and stored, and only the first becomes the post media, because the publish plan carries one asset and no client here has a photo-carousel call. - `measured` is false unless you send a `contentId` whose render has finished. A video that has not been fetched or rendered yet has no length and no shape, so the two checks that read them have not run. They run at ingest, and again in the publisher. - `refusal` carries the platform’s own sentence, produced by the publisher’s own pre-flight. A target with a refusal will not publish; the rest of the post still will. - A field this endpoint does not have is a 400, not a field we ignore. A misspelled key and a capability this build does not carry are both refused at the door, so `altText` and `hashtagPlacement` are refusals rather than fields we store and never send. `firstComment` was on that list until 2026-08-19 and is not any more: it is a real field now, inside `perPlatform`, on the two networks whose API can post a comment. At the TOP level it is still a 400, and deliberately so, a first comment is per network because one of the three cannot post one at all. The same rule already applied inside `perPlatform`; since 2026-08-19 it applies to the whole body. - Sixty requests per 300 seconds per key, shared with the rest of `/posts`. Body fields: - selectedAccounts (object; required): Platform to account ids, exactly as on `POST /posts`. One preview target comes back per account. - contentId (string; optional; format id): A piece that has already rendered. Its measured length and aspect ratio are checked against each platform, which is the only way this endpoint can answer for the video. See `measured` in the response. - caption (string; optional): The caption body. Previewed exactly as it would be stored: trimmed, and empty when you send none, because nothing fills it in from the source. - hashtags (string[]; optional): Appended to the caption in `bodyText`, each normalised to a single leading #. - perPlatform (object; optional): Exactly as on `POST /posts`. Worth previewing above everything else here, because it is the one field whose whole purpose is that two of your accounts receive different things: each target comes back with its own `bodyText` and its own `title`, built by the same function the publisher builds them with. - publishMode (enum; optional; default "publish"; one of: publish | draft | inbox): How far to take the publish. A mode a named platform cannot honour is a 400 here, exactly as on `POST /posts`. - tiktokSendToInbox (boolean; optional; default false): Route the TikTok target to the creator’s inbox. Worth previewing: the inbox endpoint carries no caption, no switches and no disclosures at all, so that target comes back with a null `bodyText` and an empty `declarations`. That includes the AI one, because `is_aigc` rides on the `post_info` block the inbox endpoint has no room for. - tiktokOptions (object; optional): The same object `POST /posts` takes, including the permissive spellings. What comes back is the value the publisher would send, not the value you wrote. - declarations (object; optional): The same object `POST /posts` takes. Each target lists only the declarations its own platform carries, with the value you stated or null where you stated nothing. This is where you see that `aiGenerated` reached TikTok and `containsSyntheticMedia` reached YouTube, and that neither reached the other. - workspaceId (string; optional; format id): Which brand the accounts belong to. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts/preview" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "selectedAccounts": { "tiktok": [ "ff9d429b-07c5-4320-a98c-ac6e1791b828" ], "youtube": [ "1c74c860-000b-4136-88a7-a17f31019346" ] }, "caption": "One stockout costs more than a year of forecasting", "hashtags": [ "retail", "ops" ], "perPlatform": { "tiktok": { "caption": "Your stockroom is lying to you", "hashtags": [ "fyp" ] } }, "declarations": { "brandedContent": true, "aiGenerated": true, "containsSyntheticMedia": false } }' ``` Example response (200): ```json { "success": true, "data": { "measured": false, "durationSeconds": null, "ratio": null, "notSent": [ "thumbnailUrl", "images[1:]" ], "targets": [ { "accountId": "ff9d429b-07c5-4320-a98c-ac6e1791b828", "platform": "tiktok", "publishMode": "publish", "uploadSource": "url", "bodyText": "Your stockroom is lying to you #fyp", "title": null, "privacy": "public", "interaction": { "disableComment": false, "disableDuet": false, "disableStitch": false }, "firstComment": null, "declarations": [ { "field": "declarations.brandedContent", "stated": true }, { "field": "declarations.brandOrganic", "stated": null }, { "field": "declarations.aiGenerated", "stated": true } ], "refusal": null }, { "accountId": "1c74c860-000b-4136-88a7-a17f31019346", "platform": "youtube", "publishMode": "publish", "uploadSource": "bytes", "bodyText": "One stockout costs more than a year of forecasting #retail #ops", "title": "One stockout costs more than a year of forecasting", "privacy": "public", "interaction": null, "firstComment": null, "declarations": [ { "field": "declarations.madeForKids", "stated": null }, { "field": "declarations.containsSyntheticMedia", "stated": false } ], "refusal": { "message": "YouTube requires `declarations.madeForKids` on every upload and this post does not state it" } } ] } } ``` ### POST /posts/import Turn a spreadsheet into a calendar, all of it or none of it. One CSV, up to 100 rows, in one transaction. The columns are `caption` and `videoUrl`, which every row must fill, plus the optional `scheduledTime` and `hashtags`; column order is free and a heading this endpoint does not know is a 400 rather than a column nobody reads. Every row is checked before anything is written, the caption against each platform that receives it, the video URL against the same rules `POST /posts` applies, the time against the calendar, and a single bad row imports nothing and comes back naming **every** row that is wrong, not the first. Imported posts are **held as drafts** unless you ask for `schedule: "scheduled"`, so uploading a file never publishes anything on its own. - Scope: posts:write - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: honoured - Paginated: no Notes: - All of the file or none of it. The rows are written in one transaction, so there is no state where some of your file is in the calendar and the rest is in your spreadsheet. The alternative, import the 99 and report the 1, was weighed and rejected on the remedy rather than on the principle: a half-filled calendar is repaired by working out which rows landed and deleting them one at a time, which is the work this endpoint exists to remove. This one is repaired by fixing the line and uploading again. - A rejected file answers `400 invalid_input` whose `message` is a header line followed by one line per bad row, each spelled `line {n}: {reason}`, in file order, with `{n}` counting physical lines from 1 so it is the number your editor shows. Every reason is a fixed sentence: no byte of your file is echoed back. The list is never truncated, because the row ceiling already bounds it. - The columns are `caption`, `videoUrl`, `scheduledTime` and `hashtags`. `caption` and `videoUrl` are required in the header and in every row; the other two are optional columns and a row may leave either cell empty. A duplicated heading is a 400, because which cell a row means stops being decidable. `hashtags` may be separated by spaces or by commas, whichever your sheet already uses. - Every row names its video by URL, and there is no way to import a post without one: this build cannot attach media to a post after it exists, so a caption-only row would sit in the calendar for ever unable to publish. Each URL is fetched into our own storage exactly as `POST /posts` fetches one, through the same guard and the same 200 MB limit, after the transaction commits. A URL that turns out not to be a video fails that post’s render and leaves the rest of the file alone. - At most 100 rows, and a file with more is a 400 naming the ceiling. The number is the work behind a row rather than the size of anybody’s spreadsheet: each row is a content insert, a post insert, up to three target inserts and a fetch of up to 200 MB, all while you wait. A quarter of daily content is about 90 rows. - A file whose header names no data rows under it is a 400, as is an empty `csv`. Neither is treated as an import of nothing. - Honours `Idempotency-Key`, and this is the endpoint where it matters most: it is the longest call this API serves, so a client timeout during the commit is the likeliest retry in the product, and a retry that repeated would be a second hundred posts. - Sixty requests per 300 seconds per key, shared with the rest of `/posts`. One call here is up to a hundred posts where `POST /posts` is one, so this bucket is a hundredfold louder than the rest of the group. Body fields: - csv (string; required): The file itself, as text, with its header row. Sent inside JSON rather than as a `text/csv` body because the accounts, the zone and the declarations travel with it and an object does not fit in a query string. The ceiling is 512 KB and a larger body is a 400 that tells you to split the file, never a file silently read to a cap. - selectedAccounts (object; required): Platform to account ids, exactly as on `POST /posts`. Every row in the file publishes to the same accounts: a file names captions, dates and videos, and there is no account column because that would be this product’s own UUIDs typed into a spreadsheet. Two sets of accounts are two uploads. - schedule (enum; optional; default "draft"; one of: draft | scheduled): Whether the file goes live. `draft`, the default, and what you get by saying nothing, holds every post: they read `draft`, no publisher is queued for any of them, and each one goes out when you call `POST /posts/{id}/publish`. `scheduled` publishes each row at its own `scheduledTime`, and then every row must have one, a file with no `scheduledTime` column at all is refused once, naming the missing column, rather than once per row. There is no `asap`: a hundred posts sent at once is a pattern every platform penalises, and the one post you want out now is `POST /posts`. - timezone (string; optional; format timezone): The IANA zone the file’s times are read in, and the zone the posts are stored in. **Required whenever the file has a `scheduledTime` column**, with no default in either direction: `2026-09-01 15:00` in a spreadsheet is a wall clock until somebody names the zone, and guessing one moves every row in the file by the same offset without saying so. A cell written as a full ISO 8601 instant carries its own offset and is read as itself. A file with no `scheduledTime` column needs no zone and the posts are stored in the workspace’s. - declarations (object; optional): The same object `POST /posts` takes, judged by the same function, and one answer for the whole file: a declaration is about the account holder and how the video was made, and a file is one customer’s one kind of content. A file that names a YouTube account without `madeForKids` is a 400 at the door rather than a hundred refusals in a worker. - validateOnly (boolean; optional; default false): Read the file, answer with what it would create, and write nothing. Not a convenience: a file can be wholly valid and wholly wrong, and the commonest way to mis-schedule a quarter is a correct file read in the wrong zone, which passes every check this endpoint has. So every entry carries the **resolved UTC instant** of its row beside the line it came from, which is the one number a zone mistake shows up in before a hundred posts exist. `imported` is 0 and no entry carries an `id`, which is what makes a validation and a real import impossible to confuse. - workspaceId (string; optional; format id): Which brand these posts belong to. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts/import" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "csv": "caption,videoUrl,scheduledTime,hashtags\nOne stockout costs more than a year of forecasting,https://cdn.example/stockout.mp4,2026-09-01 15:00,retail ops\nYour stockroom is lying to you,https://cdn.example/stockroom.mp4,2026-09-02 15:00,retail\n", "selectedAccounts": { "tiktok": [ "ff9d429b-07c5-4320-a98c-ac6e1791b828" ] }, "schedule": "draft", "timezone": "Europe/Lisbon", "declarations": { "aiGenerated": true } }' ``` Example response (200): ```json { "success": true, "data": { "validateOnly": false, "rows": 2, "imported": 2, "schedule": "draft", "timezone": "Europe/Lisbon", "posts": [ { "line": 2, "scheduledTime": "2026-09-01T14:00:00Z", "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "draft", "statusUrl": "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef" }, { "line": 3, "scheduledTime": "2026-09-02T14:00:00Z", "id": "0f8c4b6f-4d0b-4a09-9a51-b6d0f2a6a2f3", "status": "draft", "statusUrl": "https://looptake.com/api/v1/posts/0f8c4b6f-4d0b-4a09-9a51-b6d0f2a6a2f3" } ] } } ``` ### GET /posts List posts. Everything scheduled, publishing, published or failed, newest scheduled time first. `from` and `to` filter on the scheduled time rather than the creation time, which is what a calendar sync wants. Every row names the presenter it was published as, or null when it was published for the brand. - Scope: posts:read - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: yes Notes: - `approvalStatus` carries the verdict alone on this shape; `GET /posts/{id}` carries the whole record, including who decided and why. `null` means no approval was demanded of this post and it publishes at its time like any other. - An `approval` value that is not one of the three is 400 `invalid_input` rather than an ignored parameter. A filter that quietly returned everything would answer a review queue with a whole workspace, and the page would look like it worked. Query parameters: - status (enum; optional; one of: draft | queued | scheduled | publishing | posted | partial | failed | cancelled): Filters by post status. `draft` is a post saved unfinished that nothing will publish; `queued` is one going out as soon as its render finishes. Until 2026-08-19 there was one value called `draft` and it meant the second of those. - approval (enum; optional; one of: pending | rejected | none): The review queue, as a filter. `pending` is what is waiting on a person, `rejected` is what was refused and can be re-opened, and `none` is posts nobody was asked about, which is not the same as approved-yet-unpublished and does not include the pending ones. A separate axis from `status`: a post is `scheduled` and `pending` at the same time. There is no `approved` value, because approved posts are already reachable by every other filter here and a queue is what is still open. - automationId (string; optional; format id): Only posts produced by one automation. - influencerId (string; optional; format id): Only posts published as one presenter. This is how you list what went out under a likeness whose subject has withdrawn consent: a withdrawal stops the next render and leaves live posts alone, and this filter is what tells you which posts those are. Delete the presenter and the filter answers nothing for it, because deleting a presenter clears the link on every post it published, so take the list first. - from (string; optional; format date-time): Inclusive lower bound on the scheduled time. - to (string; optional; format date-time): Exclusive upper bound on the scheduled time. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/posts?approval=pending&limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "contentId": "c02119cb-ed7a-40ed-87fe-c18edc1fa1a2", "status": "scheduled", "renderStatus": "ready", "schedule": "scheduled", "scheduledTime": "2026-09-01T15:00:00Z", "timezone": "Europe/Lisbon", "ownerType": "brand", "influencerId": null, "automationId": null, "approvalStatus": "pending", "targets": [ { "platform": "tiktok", "accountId": "ff9d429b-07c5-4320-a98c-ac6e1791b828", "status": "scheduled" } ] } ], "pagination": { "total": 18, "page": 1, "limit": 1, "pages": 18 } } ``` ### GET /posts/{id} Read a post with its per-platform results. One target row per connected account, each with its own status, platform post id, public URL, error and analytics snapshot. A post is `partial` when some targets published and others did not, which is a state a single status field cannot express and a per-target row can. - Scope: posts:read - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - One short link is minted per platform the post goes to, so `utm_source` answers "how many came from TikTok" rather than only "how many came from this post". Following one is a 302, never a 301: browsers cache a 301, which would stop the second click from ever being counted. - Terminal for polling: `posted`, `partial`, `failed` and `cancelled`. A `renderStatus` of `failed` is terminal too. There is no `completed`: a post is done when every target it named has settled, and `partial` is the word for a mixed result. - `firstComment` appears on a target only when one was written for that network, so a post composed without one reads exactly as it did before the field existed. Its `state` is `pending`, `posted`, `failed` or `unresolved`, and `needsAttention` is `true` for the last two, both mean the same thing to you: **the post is live and the comment is not**, so somebody has to add it by hand. The post is still `posted` in that case, deliberately: the video is on the account and re-publishing it would put it out twice, so the verdict on the comment sits beside the post rather than changing it. `unresolved` means one attempt was made and we never learned how it went; it is not retried, because a duplicate comment under your own video is worse than a missing one and neither network offers a way to ask. `text` carries what was meant to go under the post, so it can be copied straight across. - Analytics are refreshed on a schedule, so `fetchedAt` is part of the reading. Path parameters: - id (string; required; format id): The post id. Example request: ```bash curl "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "partial", "renderStatus": "ready", "caption": "The maths nobody runs before they run out.", "hashtags": [ "#retail", "#inventory" ], "scheduledTime": "2026-09-01T15:00:00Z", "declarations": { "madeForKids": false, "brandedContent": true, "brandOrganic": null, "aiGenerated": true, "containsSyntheticMedia": true }, "targets": [ { "accountId": "ff9d429b-07c5-4320-a98c-ac6e1791b828", "platform": "tiktok", "status": "posted", "platformPostId": "7412998812", "postUrl": "https://www.tiktok.com/@northwind/video/7412998812", "error": null, "analytics": { "views": 4820, "likes": 311, "comments": 24, "shares": 61, "saves": 88, "fetchedAt": "2026-09-02T09:00:00Z" } }, { "accountId": "1c74c860-000b-4136-88a7-a17f31019346", "platform": "youtube", "status": "failed", "platformPostId": null, "postUrl": null, "error": "The account’s token expired. Reconnect and retry.", "firstComment": { "state": "failed", "text": "#retail #inventory #ops", "commentId": null, "error": "the post published, but its first comment did not: youtube: this account was connected without the `https://www.googleapis.com/auth/youtube.force-ssl` permission, so it cannot post a first comment. Reconnect it and accept every permission, or remove the first comment from this post.", "needsAttention": true }, "analytics": null } ], "attributionLinks": [ { "id": "5e1e2ad1-9b56-4a0d-92b6-04f8dc3d19b6", "slug": "k3Bq7Zra", "url": "https://looptake.com/go/k3Bq7Zra", "targetUrl": "https://northwind.example", "utmSource": "tiktok", "utmMedium": "social", "utmCampaign": "looptake", "clicks": 138, "lastClickAt": "2026-09-02T08:41:03Z", "createdAt": "2026-09-01T14:59:58Z" } ] } } ``` ### PATCH /posts/{id} Fix what a post says, and what you have declared about it, while it has not gone out. Changes what a post says and what its account holder has declared about it, keeping the post. Without it a typo costs the id: DELETE and a second POST /posts mint a new one, and the approval, the short links already printed in the caption, the webhook deliveries your own system stored and the suggestion it was accepted from all end up pointing at a post that no longer exists. Accepted while the post is `draft`, `queued` or `scheduled` and no account has started; once a platform call is in flight the answer is 409, because the words are already on their way. Declarations were write-once until 2026-08-19: they were set at creation and no statement changed them, so a wrong AI disclosure cost the id that everything else here exists to keep. - Scope: posts:write - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - A body naming none of the four fields is 400 `invalid_input`. All four are replacements rather than merges: `hashtags` sets the whole list, `perPlatform` sets the whole set of overrides, `declarations` sets the whole object, and the way to add one is to send the collection you want. - Editing an `approved` post puts it back to `pending`, and it is withheld until somebody agrees again. An approval is a judgement about specific words, so replacing the words replaces the thing that was agreed to, the same reason POST /posts/{id}/reschedule re-opens a decision, applied to what the post says rather than to when it says it. Re-sending the caption a post already has changes nothing and leaves the decision standing. - A `pending` post keeps waiting, a `rejected` post keeps its refusal, rewriting is exactly what a refused post needs, and a post nobody was asked about is unaffected. - The publisher checks, as it claims each account, that the post still says what it is about to send. A post being uploaded right now refuses the edit; an edit that lands a moment earlier makes that attempt stand down and re-read, so the words that reach the platform are always the words that were stored. - It does not move the slot and it does not change which accounts a post goes to. POST /posts/{id}/reschedule is the first; the second is not an operation this API has. It does not replace the video either: no statement in this build writes a post’s media, so footage that is wrong is still a cancel and a new post. - Editing a declaration on a LAUNCHED automation is still not possible: PATCH /automations/{id} answers 409 once a run has generated, and the posts it already scheduled keep the answers they were created with. Each of those posts is editable here, one at a time, which is the repair. There is no operation that restates a declaration across a whole run. - A post that is `publishing`, `posted`, `partial`, `failed` or `cancelled` answers 409 `conflict`. A post you cannot read, no such id, or another team’s, is 404 `not_found`. - A field this endpoint does not have is a 400, not a field we ignore. A misspelled key and a capability this build does not carry are both refused at the door, so `altText` and `hashtagPlacement` are refusals rather than fields we store and never send. `firstComment` was on that list until 2026-08-19 and is not any more: it is a real field now, inside `perPlatform`, on the two networks whose API can post a comment. At the TOP level it is still a 400, and deliberately so, a first comment is per network because one of the three cannot post one at all. The same rule already applied inside `perPlatform`; since 2026-08-19 it applies to the whole body. Path parameters: - id (string; required; format id): The post id. Body fields: - caption (string; optional): The new caption. Omitting it leaves the caption alone; sending an empty string clears it. Checked against every platform this post targets, joined with the hashtags exactly as the publisher joins them, so a caption that fits Instagram and not TikTok fails the request rather than one account. - hashtags (string[]; optional): The new hashtags, replacing the whole list. Omitting the field leaves them alone; sending `[]` clears them. They count towards the platform caption limit, because the platform receives one string. - perPlatform (object; optional): The per-platform words, as on `POST /posts`. Different rule from the two fields above, and it is the only way clearing an override can be expressed: sending this field **replaces the whole set**. A platform named in it is written as named, a platform left out of it goes back to publishing the post’s own caption, and `{}` clears every platform at once. Omitting the field entirely leaves every platform’s words alone. The text each account will receive is re-checked against that account’s own limits, and changing any of it re-opens an approved post for exactly the reason changing the caption does. That includes `firstComment`: because this field replaces the whole set, a client that reads a post, edits its caption and sends back a `perPlatform` without the `firstComment` it read has DELETED that comment. Send back what you read. - declarations (object; optional): The five statements the platforms hold the account holder to, as on `POST /posts`. **Sending this field replaces the whole object**: a key you leave out is written as *not stated*, not left alone, which is the same rule `PATCH /automations/{id}` has always applied to the same object. That is forced rather than chosen, because not stated is one of three answers and the one the wire spells as an absent key: a merge would leave you no way to withdraw a `brandedContent` you ticked by mistake, and withdrawing a wrong answer is the only thing editing a declaration is ever for. Omitting `declarations` entirely leaves all five alone. `madeForKids` is still required for a post that names a YouTube account, judged against the accounts this post already has, so an edit cannot withdraw the one declaration a platform refuses an upload without. Changing any stored answer re-opens an approved post, for a sharper version of the reason changing the caption does: a reviewer agreed to a post that disclosed something. Available since 2026-08-19. Before that a declaration was write-once, and a wrong one could only be fixed by cancelling the post and creating another with a new id. Example request: ```bash curl -X PATCH "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "caption": "The maths nobody runs before they run out of stock." }' ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "scheduled", "caption": "The maths nobody runs before they run out of stock.", "hashtags": [ "#retail", "#inventory" ], "perPlatform": { "tiktok": { "caption": "Your stockroom is lying to you", "hashtags": null, "title": null } }, "declarations": { "madeForKids": null, "brandedContent": null, "brandOrganic": null, "aiGenerated": true, "containsSyntheticMedia": null }, "approval": null, "updatedAt": "2026-08-18T14:22:09Z" } } ``` ### DELETE /posts/{id} Cancel a scheduled post. Only while the post is `draft`, `queued` or `scheduled`, **and** no account has started: the guard is a `not exists` over the target rows, not a check on the post's own status, because `posts.status` still reads `scheduled` for the whole of a platform round trip. Once any target is in flight the call is already out and the answer is 409. - Scope: posts:write - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Any other status returns 409 `conflict`. The content piece is untouched and can be scheduled again. Path parameters: - id (string; required; format id): The post id. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "cancelled", "cancelledAt": "2026-08-12T11:20:44Z" } } ``` ### POST /posts/{id}/approve Approve a post that is waiting for review. Releases a post whose `approval.status` is `pending`, and queues its publish immediately, so a slot approved after its scheduled time still goes out rather than being lost. Behind `posts:approve`, which `posts:write` deliberately does not imply: the credential that schedules a post is not always the one that may release it. There is no un-approving as an operation, because that would be a lie once the publisher holds the post, and `DELETE /posts/{id}` is the one that honestly stops one. Two things re-open an approved decision and neither is a decision: `POST /posts/{id}/reschedule` puts the post back to `pending` because a reviewer decides in front of a slot, and `PATCH /posts/{id}` does the same when it changes what the post says, because an approval is a judgement about specific words. - Scope: posts:approve - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - An API key cannot decide, whatever scopes it holds: the call answers 403 `forbidden_scope`. A decision has to name a person and a key acts for a team, so a null decider would put an approval on the record nobody can be asked about. Decisions come from a signed-in member holding `posts:approve`, or from an external reviewer link. - A post nobody was asked about, whose `approval` is `null` rather than `pending`, is 409 `conflict` rather than 404: the post exists, you can read it, and it is the state that refuses. - A post that is already publishing or published is 409. So is approving a `rejected` post: re-open it with `POST /posts/{id}/reject` first, which puts it back to `pending`. Path parameters: - id (string; required; format id): The post id. Body fields: - reason (string; optional): A note kept on the decision and shown to whoever scheduled the post. Up to 500 characters. Omitting it on an approval leaves no note: the field is not carried over from an earlier decision, because a note written to explain a refusal would then be reported as the approver’s own words. Sending `""` clears the stored note explicitly. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef/approve" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Checked the claim in line two against the pricing page." }' ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "scheduled", "approval": { "status": "approved", "decidedAt": "2026-08-13T09:12:44Z", "decidedBy": "b4a0d51c-6f7e-42a8-9c31-8d5f0a7be213", "note": "Checked the claim in line two against the pricing page.", "reviewLinkId": null } } } ``` ### POST /posts/{id}/reject Reject a pending post, or re-open a rejected one. Refuses a `pending` post, which keeps its slot and its row and simply never publishes. Sent against a post that is already `rejected`, it re-opens it: the decision goes back to `pending` and through the queue again. That is the only route back, because there is no transition from `rejected` straight to `approved`. Overturning a refusal in one write would leave no trace that a refusal ever happened. - Scope: posts:approve - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Same credential rule as approve: a key is 403 `forbidden_scope`, because a decision has to name a person. - The second call on a rejected post answers `"status": "pending"`. It is the same endpoint on purpose: a separate re-open route would be a second way to spell one transition. - Nothing is cancelled or deleted. A rejected post keeps its calendar slot, and the publisher skips it with the reason attached. Path parameters: - id (string; required; format id): The post id. Body fields: - reason (string; optional): Why it was refused. This is the sentence the author reads, so it is worth writing. Up to 500 characters. On a re-open (this endpoint sent against an already-rejected post) omitting it keeps the refusal’s reason rather than erasing it, so the post goes back into the queue with what was said about it still attached. Sending `""` clears the stored note explicitly, which is how a note that should not have been written is retracted. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef/reject" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "The claim in line two is not one we can make." }' ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "scheduled", "approval": { "status": "rejected", "decidedAt": "2026-08-13T09:14:02Z", "decidedBy": "b4a0d51c-6f7e-42a8-9c31-8d5f0a7be213", "note": "The claim in line two is not one we can make.", "reviewLinkId": null } } } ``` ### POST /posts/{id}/reschedule Move a scheduled post to a different time. Changes when a post goes out, keeping the post. Without this the only way to change a slot is DELETE and a second POST /posts, which mints a new id and drops everything pointing at the old one, the short links already printed in a caption, the approval a reviewer gave, the suggestion it was accepted from. Accepted while the post is `draft`, `queued` or `scheduled`; once it is `publishing` the platform call is in flight and the answer is 409. The publisher queued for the old instant is moved with the row, in the same transaction, so a post pulled forward goes out at the new time rather than the old one. - Scope: posts:write - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Moving a post whose `approval.status` is `approved` puts it back to `pending`, and the post is withheld until somebody agrees again. A reviewer decides in front of the slot, so a yes is a yes to this post at this time; the alternative, cancel and recreate, needs a fresh approval too and loses the post’s id as well. The decision fields are cleared and `note` records the move. - A `pending` post keeps waiting and a `rejected` post keeps its refusal. A post nobody was asked about, whose `approval` is `null`, is unaffected. - An `asap` post, one created without a `scheduledTime`, which reads as `queued`, becomes `scheduled` at the instant you name. - A `draft` is moved and **stays a draft**. Giving a post a date is something you do while still writing it, so this call writes the instant and leaves the hold in place; `POST /posts/{id}/publish` is what releases it, and it then goes out at the instant this call set. - A post that is `publishing`, `posted`, `partial`, `failed` or `cancelled` answers 409 `conflict`: the post exists and you can read it, and it is the state that refuses. A post you cannot read, no such id, or another team’s, is 404 `not_found`, the same answer every other read of it gives. Path parameters: - id (string; required; format id): The post id. Body fields: - scheduledTime (string; required; format date-time): The new instant, ISO 8601. Must be in the future: a post moved into the past publishes immediately, which is not what you asked for and is not something you can undo. - timezone (string; optional): The IANA zone the new time was chosen in, for example `Europe/Lisbon`. Omitting it keeps the zone the post already carries, which is not the same as sending the workspace default, because the zone is what makes “9am” still mean 9am after the clocks change. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef/reschedule" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduledTime": "2026-09-04T09:00:00Z", "timezone": "Europe/Lisbon" }' ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "scheduled", "scheduledTime": "2026-09-04T09:00:00Z", "timezone": "Europe/Lisbon", "approval": null, "updatedAt": "2026-08-18T10:41:07Z" } } ``` ### POST /posts/{id}/publish Release a draft. Stops holding a post you saved unfinished, so it goes out at the time it already carries. This is the only thing that releases a draft: no sweep, no worker and no other endpoint moves a post off `draft`, which is what makes `schedule: "draft"` on POST /posts a saved post rather than one that quietly publishes itself. It takes no body and it does not choose a time. The draft already answered that question when it was created, a draft carrying a `scheduledTime` becomes `scheduled` and publishes at that instant, and one carrying none becomes `queued` and publishes as soon as its render finishes. POST /posts/{id}/reschedule is the operation that sets a slot, and it deliberately leaves a draft held, so the two never write the same column for the same click. - Scope: posts:write - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - `posts:write` and not `posts:approve`. Deciding a draft is finished is the author’s act, and it does not bypass a reviewer: a draft released in a workspace that requires approval lands `pending` and waits, exactly as a scheduled post does. Read `approval` on the response before telling anybody it is going out. - A post that is not a draft answers 409 `conflict`, including a draft you have already released, which is what makes a double press safe. A post you cannot read is 404 `not_found`, the same answer every other read of it gives. - The publisher is queued here only when there is one to queue: a draft still rendering has a publish job waiting on its render already, and it picks the post up when the video lands. Path parameters: - id (string; required; format id): The post id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef/publish" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` Example response (200): ```json { "success": true, "data": { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "status": "queued", "scheduledTime": null, "timezone": "Europe/Lisbon", "approval": null, "updatedAt": "2026-08-19T10:41:07Z" } } ``` ### GET /calendar Read the schedule as days. The same posts as GET /posts, grouped by local day in the workspace timezone rather than returned as a flat list. This is the shape a calendar view needs, and it is a separate endpoint so a month view is one request instead of a paginated crawl. - Scope: posts:read - Success: 200 - Rate limit: 60 per 300 s (posts) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - `from` and `to` are both required. An unbounded calendar request is rejected as `invalid_input`. - Each entry carries `approvalStatus` beside `status`, because they are different axes and a month drawn from `status` alone shows `scheduled` for posts that will not publish. `null` means no approval was demanded of that post. Four fields per entry and no more: open `GET /posts/{id}` for the caption, the targets and who decided. Query parameters: - from (string; required; format date): First day, inclusive. ISO date. - to (string; required; format date): Last day, inclusive. ISO date. - status (enum; optional; one of: draft | queued | scheduled | publishing | posted | partial | failed | cancelled): Filters the posts inside each day. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/calendar?from=2026-09-01&to=2026-09-02" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "timezone": "Europe/Lisbon", "days": [ { "date": "2026-09-01", "posts": [ { "id": "973821cf-5cc7-44f7-8795-e68da31c59ef", "scheduledTime": "2026-09-01T15:00:00Z", "status": "scheduled", "approvalStatus": "pending" } ] }, { "date": "2026-09-02", "posts": [] } ] } } ``` ## Automations A cadence that fills a calendar. Draft, generate, review, launch, and the posts it produced. ### POST /automations Create an automation. An automation is a cadence plus a target set. Creating one leaves it in `draft`: nothing is generated and nothing is scheduled until you call generate, and nothing publishes until you call launch. That three-step shape is what makes a month of posts reviewable rather than a thing that started while you were reading about it. - Scope: automations:write - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Body fields: - name (string; required): What this run is called in the app and in the ledger. - cadence (object; optional): postsPerDay, days, startDate, times[] and timezone. Defaults to one post a day for seven days from tomorrow. - selectedAccounts (object; optional): Platform to account ids, the same shape POST /posts takes. - settings (object; optional): Format mix, prompt and caption preferences applied to every post in the run. - ownerType (enum; optional; default "brand"; one of: brand | influencer): Whose accounts the run publishes from. - influencerId (string; optional; format id): Required when `ownerType` is `influencer`. - tiktokMode (enum; optional; default "direct"; one of: direct | inbox): Publish to TikTok directly, or send every post to drafts. The automation-wide form of `tiktokSendToInbox`, and it inherits the same caveat: an inbox draft reaches TikTok without its caption, so a month of inbox posts is a month of captions to paste in by hand. - tiktokOptions (object; optional): Per-run TikTok publishing options. `disableComment`, `disableDuet` and `disableStitch`, or the permissive spellings `allowComments`, `allowDuet` and `allowStitch`. Anything you do not name is left at the account default. - declarations (object; optional): The platform declarations every post this run schedules is created with: `madeForKids`, `brandedContent`, `brandOrganic`, `aiGenerated` and `containsSyntheticMedia`, the same five `POST /posts` takes. Stated here because the posts are created weeks later with nobody present to ask, and a plan can only be edited while the run is a draft. A run launched without the two synthetic-media answers publishes a month of video that declares nothing about how it was made, and there is no later moment to add them *to the run*: `PATCH /automations/{id}` answers 409 once a run has generated. Since 2026-08-19 there is a later moment for each POST it scheduled, one at a time, through `PATCH /posts/{id}`, which is a repair rather than a plan and is per slot. A run whose `selectedAccounts` include YouTube and whose `madeForKids` is unstated is refused at launch rather than at each slot’s own publish time; the other four are never a refusal. - workspaceId (string; optional; format id): Which brand the run belongs to. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "September daily", "cadence": { "postsPerDay": 1, "days": 30, "startDate": "2026-09-01", "times": [ "15:00" ], "timezone": "Europe/Lisbon" }, "selectedAccounts": { "tiktok": [ "ff9d429b-07c5-4320-a98c-ac6e1791b828" ] } }' ``` Example response (201): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "name": "September daily", "status": "draft", "cadence": { "postsPerDay": 1, "days": 30, "startDate": "2026-09-01", "times": [ "15:00" ], "timezone": "Europe/Lisbon" }, "ownerType": "brand", "createdAt": "2026-08-12T11:25:00Z" } } ``` ### GET /automations List automations. Every automation the workspace holds, with its status and its cadence. The status tells you which call is legal next: `draft` takes generate, `review` takes launch, `launched` takes extend, pause or cancel, and `paused` takes resume or cancel. - Scope: automations:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Query parameters: - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/automations?limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "name": "September daily", "status": "launched", "cadence": { "postsPerDay": 1, "days": 30, "startDate": "2026-09-01", "times": [ "15:00" ], "timezone": "Europe/Lisbon" }, "postCount": 30, "createdAt": "2026-08-12T11:25:00Z" } ], "pagination": { "total": 3, "page": 1, "limit": 1, "pages": 3 } } ``` ### GET /automations/{id} Read one automation. Adds the settings, the selected accounts and the counts the list shape leaves out: how many posts were generated, how many are still awaiting review and how many have published. It also returns all five of the run’s `declarations`, which you need before you PATCH: that call replaces the object as a whole, so an editor that cannot read the stored answer would have to invent one, and an unstated `madeForKids` reads back as `null` rather than as `false`. - Scope: automations:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The automation id. Example request: ```bash curl "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "name": "September daily", "status": "launched", "cadence": { "postsPerDay": 1, "days": 30, "startDate": "2026-09-01", "times": [ "15:00" ], "timezone": "Europe/Lisbon" }, "selectedAccounts": { "tiktok": [ "ff9d429b-07c5-4320-a98c-ac6e1791b828" ] }, "settings": { "formats": [ "walloftext", "slideshow" ], "mentionBusiness": true }, "ownerType": "brand", "influencerId": null, "tiktokMode": "direct", "declarations": { "madeForKids": null, "brandedContent": true, "brandOrganic": null, "aiGenerated": true, "containsSyntheticMedia": null }, "counts": { "generated": 30, "scheduled": 28, "posted": 2, "failed": 0 } } } ``` ### PATCH /automations/{id} Edit a draft automation. Only while the automation is in `draft`. Once it has generated posts, editing the cadence would mean rewriting a schedule a person has already reviewed, so the call is a 409 and the correct move is to cancel and create a new one. - Scope: automations:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Any status other than `draft` returns 409 `conflict`. Path parameters: - id (string; required; format id): The automation id. Body fields: - name (string; optional): Rename the run. - cadence (object; optional): Replaces the cadence object wholesale. - selectedAccounts (object; optional): Replaces the target set. - settings (object; optional): Replaces the settings object. - tiktokMode (enum; optional; one of: direct | inbox): Switches the run between publishing and drafts. - tiktokOptions (object; optional): Replaces the TikTok options object. - declarations (object; optional): Replaces the declarations object as a whole, including any field you set back to null. Omit it and this patch leaves the run’s declarations alone. Example request: ```bash curl -X PATCH "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "cadence": { "postsPerDay": 2, "days": 30, "startDate": "2026-09-01", "times": [ "09:00", "18:00" ], "timezone": "Europe/Lisbon" } }' ``` Example response (200): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "draft", "cadence": { "postsPerDay": 2, "days": 30, "startDate": "2026-09-01", "times": [ "09:00", "18:00" ], "timezone": "Europe/Lisbon" } } } ``` ### POST /automations/{id}/generate Fill the automation with posts. Moves the automation to `generating`, writes one suggestion per slot in the cadence, and lands it in `review`. Nothing is published by this call. Poll GET /automations/{id} until the status leaves `generating`. - Scope: automations:write - Success: 202 (poll statusUrl) - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Terminal statuses for this poll: `review`, or `cancelled` if the run was cancelled mid-generation. Path parameters: - id (string; required; format id): The automation id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/generate" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (202): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "generating", "slots": 30, "statusUrl": "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f" } } ``` ### POST /automations/{id}/extend Add days to a running automation. Extends the cadence past its original end and generates the new slots at the same rhythm. This is how a run becomes continuous without a second automation and without a gap in the calendar. - Scope: automations:write - Success: 202 (poll statusUrl) - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The automation id. Body fields: - days (integer; required; minimum 1): How many days to add after the current last day. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/extend" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "days": 14 }' ``` Example response (202): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "generating", "days": 44, "addedSlots": 14 } } ``` ### POST /automations/{id}/launch Launch a reviewed automation. Schedules every post the run generated and moves it to `launched`. This is the call that publishes, so it honours `Idempotency-Key`: a retried launch after a timeout replays the stored response rather than scheduling the month twice. - Scope: automations:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: honoured - Paginated: no Notes: - Only from `review`. Launching a draft returns 409 `conflict`. - A run whose `selectedAccounts` include YouTube must carry `declarations.madeForKids` before it can launch, and a 400 here names the field. The check is at launch because this is the last request before a month of posts exists: after it, every slot is created by a background job with nobody left to ask. Path parameters: - id (string; required; format id): The automation id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/launch" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "launched", "scheduled": 30, "firstPostAt": "2026-09-01T15:00:00Z" } } ``` ### POST /automations/{id}/pause Hold a launched automation. Stops a running cadence without throwing its calendar away. Nothing is cancelled: every post keeps its row, its slot and the publisher already queued for it, and each one is withheld at the moment it would go out rather than being deleted now. Resuming moves the rest of the calendar forward by however long the hold lasted, so a run held for a week comes back a week later in the same rhythm. - Scope: automations:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Only from `launched`. A run that has not launched has no calendar to hold, and a 409 says so. - Never refused because the workspace is archived. Stopping is never blocked: a guard that would not let you hold a run inside an archived brand would trap you in the state you were leaving. Path parameters: - id (string; required; format id): The automation id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/pause" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "paused", "heldPosts": 22, "pausedAt": "2026-09-08T11:04:00Z" } } ``` ### POST /automations/{id}/resume Give a held automation back. Moves every post the run still had waiting forward by exactly the length of the hold and puts the run back to `launched`. The spacing is preserved, so two posts a day is still two a day. Resuming onto the original instants would publish every slot that passed during the hold at once, which is the thing a hold is pressed to prevent. `heldForSeconds` is what the calendar moved by. - Scope: automations:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Only from `paused`. Anything else is a 409, including a second resume: the move is a compare-and-set, so two resumes in flight shift the calendar once. - Refused with a 409 while the workspace is archived, because a resumed post in an archived brand is withheld again at its own slot. Restore the brand first. - Refused with a 409 when the run belongs to an influencer whose consent has been withdrawn since it launched. The videos were rendered before the hold, so this is the last check between a withdrawn likeness and an audience. Path parameters: - id (string; required; format id): The automation id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/resume" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "launched", "resumedPosts": 22, "heldForSeconds": 604800, "firstPostAt": "2026-09-16T15:00:00Z" } } ``` ### POST /automations/{id}/cancel Cancel an automation. Cancels the run and every post it scheduled that has not published yet. Posts that already went out are left alone, because unpublishing is not something an API can promise on three platforms. - Scope: automations:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The automation id. Example request: ```bash curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/cancel" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "status": "cancelled", "cancelledPosts": 28, "keptPosts": 2 } } ``` ### GET /automations/{id}/posts List the posts an automation produced. The review queue while the run is in `review`, and the run’s history after it launches. These posts are deliberately excluded from GET /generations, so this is the only way to read them. - Scope: automations:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Path parameters: - id (string; required; format id): The automation id. Query parameters: - status (enum; optional; one of: draft | queued | scheduled | publishing | posted | partial | failed | cancelled): Filters by post status. `draft` is a post saved unfinished that nothing will publish; `queued` is one going out as soon as its render finishes. Until 2026-08-19 there was one value called `draft` and it meant the second of those. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. Example request: ```bash curl "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/posts?limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "98653bae-8565-4cac-a3b8-7fb296ca356d", "status": "scheduled", "format": "slideshow", "scheduledTime": "2026-09-03T15:00:00Z", "caption": "Three numbers that decide your reorder point." } ], "pagination": { "total": 30, "page": 1, "limit": 1, "pages": 30 } } ``` ## Influencers AI presenters you create once and reuse, and the talking-head videos rendered from them. ### GET /influencers List AI influencers. The influencers this workspace has created, with the attributes that describe them and the voice each one speaks with. The plan caps how many a team may hold. - Scope: influencers:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Query parameters: - search (string; optional): Matches name, description and tags. - niche (string; optional): Filters to one niche. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/influencers?niche=retail&limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "name": "Mara", "imageUrl": "https://cdn.looptake.com/d076f17e-4529-48a3-b5fe-860bc329c5b4/portrait.jpg", "description": "Shop owner turned operations nerd.", "niche": "retail", "gender": "female", "age": 34, "ethnicity": "mixed", "tags": [ "retail", "operations" ], "voiceId": "voice_warm_en_3", "status": "ready", "consentStatus": "attested", "consentPermitsRender": true } ], "pagination": { "total": 4, "page": 1, "limit": 1, "pages": 4 } } ``` ### POST /influencers Create an AI influencer. Renders a preview of the character from the reference image and the attributes, and costs 10 credits. The influencer is reusable after that: every video you render from it costs per second, not per character. `subject` is optional in the schema and decisive in effect: a body that does not say what the image is gets treated as `real_person`, which is the strict branch, and a `real_person` with no `consent` block is refused with 400 before anything is charged. Send `subject: "synthetic"` for a wholly invented character and nothing else is needed; send `real_person` and attest, with the release lodged through POST /influencers/consent-artefacts first. The permissive default was the alternative, and it would have made the whole control opt-in. - Scope: influencers:write - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: 10 per influencer - Idempotency-Key: honoured - Paginated: no Notes: - Costs 10 credits. A failed preview is refunded automatically by the worker that detected it. - A body with no `subject` is read as `real_person` and refused with 400 `invalid_input` when it carries no `consent`. This is a change: a script written before consent tooling shipped, which sent only `name` and `imageUrl`, now fails. Nothing is charged by the refusal. - `consent.artefact` names an upload that must already exist. The object is `head`ed and read once to record its SHA-256, so an id whose upload never happened is refused rather than attached to an attestation. - `consentStatus` is `attested` for an attested real person, `not_required` for a synthetic one, and `null` only on rows created before this control existed. Recording an attestation is not verification of the subject: nothing here contacts the person in the photograph. Body fields: - name (string; required): What the character is called. - imageUrl (string; required; format uri): A reference portrait. The face every later render is built from. - subject (enum; optional; one of: synthetic | real_person): What the portrait is: a wholly invented character, or a likeness of a real human being. Not required, and that is the control rather than a gap in it: absence means `real_person`, so omitting the field does not skip the question, it answers it the strict way and then demands a `consent` block. Publishing it as required would have made a validating client refuse to send the one body that answers most strictly. A near miss such as `realperson` is 400 rather than being folded into either branch. - consent (object; optional): Required when `subject` is `real_person`. Send `{ statement, subjectName?, artefact: { id, filename, kind } }`: your own words for what the subject agreed to, and the release you lodged with POST /influencers/consent-artefacts. Cannot be sent with a `synthetic` subject, because an invented character has nobody to hold a release from. - description (string; optional): Who they are. Feeds the scripts written in their voice. - niche (string; optional): The subject they speak about. - gender (string; optional): Presented gender, used for voice selection. - age (integer; optional): Apparent age in years. - ethnicity (string; optional): Apparent ethnicity, used for character consistency across renders. - tags (string[]; optional): Your own labels, searchable through `search`. - workspaceId (string; optional; format id): Which brand the character belongs to. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/influencers" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Mara", "imageUrl": "https://cdn.example.com/mara.jpg", "subject": "real_person", "consent": { "subjectName": "Mara Okonkwo", "statement": "Signed likeness release, worldwide, 12 months, on file.", "artefact": { "id": "3f8a6c21-0d94-4f7a-9c62-51a0b7e4d8f3", "filename": "release.pdf", "kind": "written_release" } }, "description": "Shop owner turned operations nerd.", "niche": "retail", "tags": [ "retail", "operations" ] }' ``` Example response (201): ```json { "success": true, "data": { "id": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "name": "Mara", "status": "ready", "voiceId": "voice_warm_en_3", "subject": "real_person", "consentStatus": "attested", "creditsSpent": 10, "createdAt": "2026-08-12T11:31:12Z" } } ``` ### POST /influencers/consent-artefacts Get an upload URL for a release or a recorded statement. The first call of the real-person flow: it returns a presigned `PUT` URL, you upload the document to it, and then you name the returned `artefactId` in the `consent` block of POST /influencers or POST /influencers/{id}/consent. Nothing is recorded here and no credits are charged, because an artefact is not evidence of anything until an attestation points at it, and charging for the upload would put a price on the compliant path. The declared `bytes` is signed into the URL, so the bucket itself refuses a body of any other length. - Scope: influencers:write - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The URL is good for 30 minutes. One left in a browser tab overnight is dead, because a URL that outlives the sitting it was minted in is a standing write grant to an evidentiary prefix. - Up to 32 MiB (33,554,432 bytes). The cap is what this endpoint will read back to digest, not what a bucket can hold. - The object is stored under a retention class that never expires and that the storage layer refuses to delete, and it has no public URL. Evidence that expires with the media is not evidence. Body fields: - kind (enum; required; one of: written_release | recorded_statement): What the document is. A `written_release` must be a pdf, png, jpg, jpeg or webp; a `recorded_statement` must be an mp4, webm, mov, m4a, mp3 or wav. - filename (string; required): A plain name with its extension, using letters, digits, `.`, `_` and `-` only. The extension has to match `kind`. - bytes (integer 1-33554432; required): The exact size of the file. Signed into the upload URL, so a body of a different length is refused by the bucket rather than discovered afterwards. - workspaceId (string; optional; format id): Which brand the likeness belongs to. Defaults to the key’s default workspace, and has to be the same workspace as the influencer the artefact is attached to. Example request: ```bash curl -X POST "https://looptake.com/api/v1/influencers/consent-artefacts" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "written_release", "filename": "release.pdf", "bytes": 184320 }' ``` Example response (201): ```json { "success": true, "data": { "artefactId": "3f8a6c21-0d94-4f7a-9c62-51a0b7e4d8f3", "filename": "release.pdf", "kind": "written_release", "bytes": 184320, "uploadUrl": "https://uploads.looptake.com/invoice/e7293116-fd57-4456-8da0-032f4976145f/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/consent/3f8a6c21-0d94-4f7a-9c62-51a0b7e4d8f3/release.pdf?sig=…", "contentType": "application/pdf", "expiresAt": "2026-08-12T12:01:12Z" } } ``` ### POST /influencers/{id}/consent Attest, withdraw or refuse consent for a likeness. Records one decision about an influencer that already exists, append-only: `attest` needs a statement and a lodged artefact, while `withdraw` and `refuse` need a statement and no document at all, because the stricter direction has to be the easier one to take. They may still carry one, and it is stored: a revocation letter is real evidence and belongs on the record rather than in an inbox. From a withdrawal or a refusal, every path that would spend credits on the likeness answers 409: a new video, a studio render carrying `influencerId`, a post published as that influencer, creating an automation for it, and launching one. Consent can be given again later with a fresh artefact, and the history keeps both records. - Scope: influencers:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - `artefact` is required only when `action` is `attest`. A withdrawal or a refusal may carry one and it is stored exactly as an attestation’s is: verified against the bucket, measured, digested, and filed under the prefix that refuses deletion. A revocation letter or an email saying no is real evidence of why consent ended, and an earlier release accepted the field on those two actions and silently discarded it. - Demanding paperwork before we will stop using somebody’s likeness would make the stricter direction the harder one to take, so `artefact` stays optional on `withdraw` and `refuse` and always will be. Sending one that cannot be verified is a 400 and writes nothing at all, rather than half-recording the decision; the message says so, and re-sending the same request without `artefact` records the withdrawal immediately. - `runningAutomations` is what a withdrawal deliberately does not stop. Launched runs keep publishing this likeness on their own timetable until you cancel each one with POST /automations/{id}/cancel. Cancelling a month of your calendar as a side effect of a status change is a decision that belongs to you, and it is one you cannot undo. - Media already published stays live, keeps its analytics and keeps naming the influencer, so the set that needs a decision is listable. - The whole history is on GET /influencers/{id} as `consentRecords`, and each decision is also written to the audit log, which the application can never delete from. Path parameters: - id (string; required; format id): The influencer id. Body fields: - action (enum; required; one of: attest | withdraw | refuse): What is being recorded. `attest` says a release is held and lodges it; `withdraw` takes back a consent that was given; `refuse` records that the subject said no. - statement (string; required): What was agreed, withdrawn or refused, in your own words: a licence period, a territory, a named campaign. Up to 2,000 characters, because a customer who has to compress that into a tweet writes something useless instead. - subjectName (string; optional): Who the subject is. Up to 120 characters. - artefact (object; optional): Required when `action` is `attest`, optional on `withdraw` and `refuse`, and stored on the record whichever action carried it. `{ id, filename, kind }` from POST /influencers/consent-artefacts, after the upload has completed. Sent with a withdrawal it is verified first, so an id whose upload never arrived is a 400 and nothing is recorded; omit the field to withdraw immediately, which always works and is never blocked by a document. Example request: ```bash curl -X POST "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4/consent" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "withdraw", "subjectName": "Mara Okonkwo", "statement": "The subject withdrew their consent by email on 12 August." }' ``` Example response (200): ```json { "success": true, "data": { "influencerId": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "consentStatus": "withdrawn", "consentPermitsRender": false, "record": { "id": "7c1d3a90-2f5b-4e08-badc-9a6f24e17c55", "action": "withdraw", "subject": "real_person", "subjectName": "Mara Okonkwo", "statement": "The subject withdrew their consent by email on 12 August.", "artefact": null, "attestedBy": { "type": "api_key", "id": "d81ed241-9404-4a1a-8864-242ecc540416", "label": "CI publisher" }, "recordedAt": "2026-08-13T10:02:19Z" }, "runningAutomations": [ { "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f", "name": "September daily", "status": "launched" } ] } } ``` ### GET /influencers/{id} Read one influencer. The full character record: the voice it speaks with, the accounts it publishes from, and the whole consent history rather than only the current answer. A withdrawal that hid the attestation it withdrew would be a record of the present, and what anyone asks this endpoint is what happened. - Scope: influencers:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - `consentPermitsRender` is published beside `consentStatus` because the null case is the surprising one. `null` means unrecorded (the character was created before this control existed), and unrecorded still renders. Only a recorded `withdrawn` or `refused` stops it, so a reader who has to re-derive that from the status alone gets the grandfather rule wrong. - `consentRecords` is append-only and ordered newest first, so a likeness attested, withdrawn and attested again carries all three and `consentRecords[0]` is the decision in force. The artefact is published as a key, a size and a digest and never as a URL: it is a signed release naming a private individual, and an endpoint that handed out a link to it would turn a compliance record into a disclosure. The digest is what makes it checkable: the document in the bucket is the document that was lodged. - The listing does not carry `consentRecords`. Read this endpoint for the history, and note that recording an attestation is not verification of the subject: nothing here contacts the person in the photograph. Path parameters: - id (string; required; format id): The influencer id. Example request: ```bash curl "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "name": "Mara", "imageUrl": "https://cdn.looptake.com/d076f17e-4529-48a3-b5fe-860bc329c5b4/portrait.jpg", "description": "Shop owner turned operations nerd.", "niche": "retail", "gender": "female", "age": 34, "ethnicity": "mixed", "tags": [ "retail", "operations" ], "voiceId": "voice_warm_en_3", "status": "ready", "consentStatus": "attested", "consentPermitsRender": true, "previewVideoUrl": "https://cdn.looptake.com/d076f17e-4529-48a3-b5fe-860bc329c5b4/preview.mp4", "creditsSpent": 10, "error": null, "createdAt": "2026-08-12T11:31:12Z", "consentRecords": [ { "id": "7c1d3a90-2f5b-4e08-badc-9a6f24e17c55", "action": "attest", "subject": "real_person", "subjectName": "Mara Okonkwo", "statement": "Signed likeness release, worldwide, 12 months, on file.", "artefact": { "key": "invoice/e7293116-fd57-4456-8da0-032f4976145f/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/consent/3f8a6c21-0d94-4f7a-9c62-51a0b7e4d8f3/release.pdf", "kind": "written_release", "bytes": 184320, "sha256": "9f2c4d7b1a83e60c5518d9f4a7b2e0c36d81f5a94e73b02c6f1d8a45b93e7c20" }, "attestedBy": { "type": "api_key", "id": "d81ed241-9404-4a1a-8864-242ecc540416", "label": "CI publisher" }, "recordedAt": "2026-08-12T11:31:12Z" } ], "accounts": [ { "id": "1c561cb8-4ea0-4fed-84af-bf13a6efe8e9", "platform": "tiktok", "handle": "@mara.stock" } ] } } ``` ### DELETE /influencers/{id} Delete a presenter and free its slot. Removes the presenter and gives back the plan slot it was holding. The cap counts presenters you hold rather than presenters you are using, so a character you have finished with, or one whose subject has withdrawn consent, occupies a paid slot until you delete it. Every video rendered from the presenter is deleted with it. Posts already published are not: they keep their media, their analytics and their place in the calendar. What they lose is the presenter: the link is cleared on every one of them, so `GET /posts?influencerId=` answers nothing for a presenter you have deleted. List first, delete second. - Scope: influencers:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Nothing is refunded. The credits bought a preview that rendered, and a delete that gave them back would make create-and-delete a free loop. A preview or a video still rendering when you delete it is the one case that does return credits, and it returns them through the ordinary failure path rather than through this call: the render finds its row gone, fails terminally, and the automatic refund runs. - `videosDeleted` and `consentRecordsDeleted` are counted inside the transaction that removed them, so they are the number of rows that actually went rather than an estimate taken beforehand. - `postsUnlinked` is spelled differently because it is a different thing: those posts are not deleted, and every one of them is still live. What they lose is the name of the presenter they went out as, and this call is the last moment anything can count them. If you need the list rather than the number, take `GET /posts?influencerId=` before you delete. - Deleting a real-person presenter deletes its consent history too, which is how a subject asking to be erased is erased. What survives is an audit entry naming the presenter id, who deleted it, the consent status at the time and these two counts, plus the earlier entry recording that a release was attested and the digest of the document. Neither carries the subject name. The release itself stays in storage under a retention class that refuses deletion. - Works in an archived workspace. Calls that spend credits are refused there; a call that gives capacity back is not, because a guard on that would trap you in the state you were leaving. Path parameters: - id (string; required; format id): The influencer id. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "deleted": true, "videosDeleted": 4, "consentRecordsDeleted": 2, "postsUnlinked": 12 } } ``` ### POST /influencers/{id}/videos Render a talking-head video. Queues a lip-synced video of the influencer delivering your script, and returns immediately. The cost is 5 credits per second of finished video, so a 20-second read is 100 credits. Poll GET /videos/{id} until the status is terminal. - Scope: influencers:write - Success: 202 (poll statusUrl) - Rate limit: 600 per 300 s (default) - Credits: 5 per second of video - Idempotency-Key: honoured - Paginated: no Notes: - Terminal statuses: `ready` and `failed`. A failure refunds the reserved credits automatically. - Honours `Idempotency-Key`, because the call spends credits. Path parameters: - id (string; required; format id): The influencer id. Body fields: - script (string; required): What they say, word for word. Paced to `duration` rather than cut to it. - imageUrl (string; optional; format uri): Overrides the influencer’s portrait for this one render. - language (enum; optional; default "en"; one of: ar | bg | bn | cs | da | de | el | en | en-GB | es-419 | es-ES | et | fi | fil | fr-CA | fr-FR | he | hi | hr | hu | id | it | ja | ko | lt | lv | ms | nl | no | pl | pt-BR | pt-PT | ro | ru | sk | sl | sv | ta | th | tr | uk | ur | vi | zh-Hans): BCP 47 language tag for the voice and the captions. One of the 44 languages Looptake generates; a tag outside the list is 400 invalid_input rather than a silent fall back to English. - duration (integer; optional): Target length in seconds. Drives the credit cost at 5 per second. - mode (string; optional): Render mode to produce the video in. Defaults to the influencer’s usual mode. Example request: ```bash curl -X POST "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4/videos" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "script": "A stockout does not cost you one sale. It costs you the customer who came for it.", "language": "en", "duration": 12 }' ``` Example response (202): ```json { "success": true, "data": { "id": "08363fcd-f524-4126-b68d-fd64f2f9c09e", "influencerId": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "status": "queued", "durationS": 12, "creditsReserved": 60, "statusUrl": "https://looptake.com/api/v1/videos/08363fcd-f524-4126-b68d-fd64f2f9c09e" } } ``` ### GET /influencers/{id}/videos List an influencer’s videos. Every video rendered for this character, newest first, with its status and what it cost. - Scope: influencers:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Path parameters: - id (string; required; format id): The influencer id. Query parameters: - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. Example request: ```bash curl "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4/videos?limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "08363fcd-f524-4126-b68d-fd64f2f9c09e", "status": "ready", "language": "en", "durationS": 12, "videoUrl": "https://cdn.looptake.com/08363fcd-f524-4126-b68d-fd64f2f9c09e/video.mp4", "thumbnailUrl": "https://cdn.looptake.com/08363fcd-f524-4126-b68d-fd64f2f9c09e/thumb.jpg", "creditsSpent": 60, "createdAt": "2026-08-12T11:33:40Z" } ], "pagination": { "total": 9, "page": 1, "limit": 1, "pages": 9 } } ``` ### GET /videos/{id} Read one video. Poll this after POST /videos, POST /remix, POST /clips or POST /influencers/{id}/videos. A prompt-first video reports `status` (`queued`, `running`, `ready`, `failed`), `stage`, `progress`, `viralScore` (0-100), `hooks` and `outputs` (content ids and preview URLs); it needs `generations:read`. An influencer video reports `queued`, `processing`, `ready` or `failed` and needs `influencers:read`. - Scope: influencers:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The video id. Example request: ```bash curl "https://looptake.com/api/v1/videos/08363fcd-f524-4126-b68d-fd64f2f9c09e" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "08363fcd-f524-4126-b68d-fd64f2f9c09e", "influencerId": "d076f17e-4529-48a3-b5fe-860bc329c5b4", "script": "A stockout does not cost you one sale. It costs you the customer who came for it.", "language": "en", "durationS": 12, "mode": "studio", "status": "ready", "videoUrl": "https://cdn.looptake.com/08363fcd-f524-4126-b68d-fd64f2f9c09e/video.mp4", "thumbnailUrl": "https://cdn.looptake.com/08363fcd-f524-4126-b68d-fd64f2f9c09e/thumb.jpg", "creditsSpent": 60 } } ``` ## Assets The workspace library: clips, images, audio and fonts a deck can draw on. ### GET /assets List the asset library. `turboEnabled` marks the clips and images you have offered to the Turbo formats. It is a label on your own library and a filter on this call; nothing in this build draws on the pool automatically yet. `url` is ours once the file has been stored: a short-lived signed link that this call re-signs for you when it has expired, so a URL you read here always works. For an asset we could not store it stays the address you gave. - Scope: assets:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Query parameters: - kind (enum; optional; one of: image | video | audio | font): Filters by media kind. - category (string; optional): Your own grouping, for example `b-roll` or `product`. - turboEnabled (boolean; optional): Only assets you have offered to the Turbo formats. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/assets?kind=video&turboEnabled=true&limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "a7273471-d932-4f9a-958e-79abdd1f3fd7", "kind": "video", "category": "b-roll", "name": "Warehouse aisle, slow pan", "url": "https://cdn.looptake.com/a7273471-d932-4f9a-958e-79abdd1f3fd7/clip.mp4", "bytes": 4812004, "width": 1080, "height": 1920, "durationMs": 9200, "turboEnabled": true } ], "pagination": { "total": 62, "page": 1, "limit": 1, "pages": 62 } } ``` ### POST /assets Add an asset by URL. Fetches the file, stores it in our own bucket, and reads its real dimensions and duration out of the file’s own header rather than trusting what you declared. All of that is asynchronous: the 201 carries the row with the URL you gave, and `url`, `width`, `height`, `durationMs` and `bytes` are replaced moments later, so poll `GET /assets` if you need them. Measurements stay `null` for a container this build cannot parse; `null` means "not established", never zero. Images and video are stored (MP4, MOV, WebM, PNG, JPEG, GIF, WebP); an `audio` or `font` asset, and any URL that does not answer, keeps the address you gave and is never measured. - Scope: assets:write - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Body fields: - url (string; required; format uri): Where to fetch the file from. Must be publicly reachable at the time of the call. - kind (enum; optional; one of: image | video | audio | font): Declared kind. Detected from the response when omitted. - name (string; optional): Label in the library. Defaults to the filename. - category (string; optional): Your own grouping. - turboEnabled (boolean; optional): Offer this file to the Turbo formats. Off by default, so nothing you upload for one campaign appears in a post you did not choose it for. Changeable afterwards with `PATCH /assets/{id}`. Example request: ```bash curl -X POST "https://looptake.com/api/v1/assets" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://cdn.example.com/aisle.mp4", "kind": "video", "name": "Warehouse aisle, slow pan", "category": "b-roll", "turboEnabled": true }' ``` Example response (201): ```json { "success": true, "data": { "id": "a7273471-d932-4f9a-958e-79abdd1f3fd7", "kind": "video", "name": "Warehouse aisle, slow pan", "category": "b-roll", "url": "https://cdn.looptake.com/a7273471-d932-4f9a-958e-79abdd1f3fd7/clip.mp4", "bytes": null, "width": null, "height": null, "durationMs": null, "turboEnabled": true } } ``` ### PATCH /assets/{id} Rename an asset, refile it, or change what Turbo may use. The three things you can correct after the upload. Every field is optional and a field you leave out is left alone, so renaming a clip does not clear the grouping it is filed under. Send `category: null` to clear the grouping; `name` cannot be cleared, because an asset always has one. Sending an empty body is `invalid_input` rather than a 200 that changed nothing. - Scope: assets:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The asset id. Body fields: - name (string; optional): The label shown in the library. - category (string; optional): Your own grouping. Send `null` to clear it. - turboEnabled (boolean; optional): Whether this file is offered to the Turbo formats. Example request: ```bash curl -X PATCH "https://looptake.com/api/v1/assets/a7273471-d932-4f9a-958e-79abdd1f3fd7" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Warehouse aisle, slow pan", "turboEnabled": false }' ``` Example response (200): ```json { "success": true, "data": { "id": "a7273471-d932-4f9a-958e-79abdd1f3fd7", "kind": "video", "category": "b-roll", "name": "Warehouse aisle, slow pan", "url": "https://cdn.looptake.com/a7273471-d932-4f9a-958e-79abdd1f3fd7/clip.mp4", "bytes": 4812004, "width": 1080, "height": 1920, "durationMs": 9200, "turboEnabled": false } } ``` ### DELETE /assets/{id} Delete an asset and the file behind it. Removes the library row and the stored file together. `objectsRemoved` is how many objects went with it: `1` for an asset we stored, `0` for one we never could store (an audio file, a font, or a URL that never answered), where the row was only ever a pointer at your own address. Decks that referenced the asset are not rewritten, so a scheduled post naming a file you have deleted will render without it. Deleting twice is a 404. - Scope: assets:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - This is the one call in this API that removes stored bytes. Everything else keeps what it has written. Path parameters: - id (string; required; format id): The asset id. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/assets/a7273471-d932-4f9a-958e-79abdd1f3fd7" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "a7273471-d932-4f9a-958e-79abdd1f3fd7", "deleted": true, "objectsRemoved": 1 } } ``` ## Content Render status and the finished media for an accepted suggestion or a studio job. ### GET /content/{id} Read render status. The endpoint every content studio job is polled on. `pending` and `rendering` mean keep polling; `ready` and `failed` are terminal. A failed render refunds its credits without a ticket, and `creditsSpent` returns to zero when it does. - Scope: content:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The content id. Example request: ```bash curl "https://looptake.com/api/v1/content/c02119cb-ed7a-40ed-87fe-c18edc1fa1a2" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "c02119cb-ed7a-40ed-87fe-c18edc1fa1a2", "generationId": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "format": "walloftext", "renderStatus": "ready", "creditsSpent": 0, "media": [ { "kind": "video", "url": "https://cdn.looptake.com/c02119cb-ed7a-40ed-87fe-c18edc1fa1a2/video.mp4", "width": 1080, "height": 1920, "durationMs": 8000 } ] } } ``` ### GET /content/{id}/download Get a download URL for the finished media. Returns a short-lived signed URL when the media exists. When it does not, the call starts the render and answers 409 rather than blocking, so a download is never a request that hangs for two minutes. Poll the same URL until it answers 200. - Scope: content:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - A 409 on a piece that has not started means the render has just been started by this call; on one already rendering it means a worker has it. Retry after a few seconds either way. A 409 on a render that failed says so and is not retried: the credits for it were already returned. - Generated media is retained indefinitely and nothing expires it on a timer. Archiving a workspace keeps every piece in it. The one operation here that removes a piece is DELETE /influencers/{id}, which takes the videos rendered from that presenter with it; a content piece, a deck and a post render have no delete on this surface. Path parameters: - id (string; required; format id): The content id. Example request: ```bash curl "https://looptake.com/api/v1/content/c02119cb-ed7a-40ed-87fe-c18edc1fa1a2/download" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "url": "https://cdn.looptake.com/c02119cb-ed7a-40ed-87fe-c18edc1fa1a2/video.mp4?sig=…", "expiresAt": "2026-08-12T12:35:00Z", "bytes": 3120044, "contentType": "video/mp4" } } ``` ## Content studio The content studio renders, one endpoint per named render. All accept a job and return 202; none blocks. ### POST /content/character-swap Replace the person in a video. Keeps a video that already worked and swaps the presenter, so a proven hook can be re-cut for a new audience without a reshoot. The source video’s timing and framing are preserved exactly. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - videoUrl (string; required; format uri): The source video whose motion and timing are kept. - characterImageUrl (string; required; format uri): The face that replaces the one in the source. - scene (string; optional): Describes the setting to place the new character in. - keepSourceAudio (boolean; optional; default true): Keeps the original audio track instead of generating a new voice. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/character-swap" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "videoUrl": "https://cdn.example.com/hook.mp4", "characterImageUrl": "https://cdn.example.com/mara.jpg", "keepSourceAudio": true }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "a358a75e-6678-42fe-b595-5415fcea62d0", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/a358a75e-6678-42fe-b595-5415fcea62d0" } } ``` ### POST /content/green-screen-mobile Present in front of an app screen. Puts a presenter beside your app screenshot and walks through the flow. This is the format that explains an interaction a screenshot cannot, which is most of what a mobile product has to sell. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - appScreenshot (string; required; format uri): The screen behind the presenter. - presenterImage (string; required; format uri): The person in front of it. - script (string; required): What the presenter says while the screen is on show. - duration (integer; optional): Target length in seconds. The script is paced to fit rather than truncated. - language (enum; optional; default "en"; one of: ar | bg | bn | cs | da | de | el | en | en-GB | es-419 | es-ES | et | fi | fil | fr-CA | fr-FR | he | hi | hr | hu | id | it | ja | ko | lt | lv | ms | nl | no | pl | pt-BR | pt-PT | ro | ru | sk | sl | sv | ta | th | tr | uk | ur | vi | zh-Hans): BCP 47 language tag for the voice and the captions. One of the 44 languages Looptake generates; a tag outside the list is 400 invalid_input rather than a silent fall back to English. - captionOverlay (boolean; optional; default true): Burns word-level captions into the video. Leave it on: most short-form is watched muted. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/green-screen-mobile" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "appScreenshot": "https://cdn.example.com/screen.png", "presenterImage": "https://cdn.example.com/mara.jpg", "script": "This is the reorder screen. Three inputs, and it tells you the day to order.", "duration": 18 }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "b1c33352-9456-4902-9c50-20e1f363d387", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/b1c33352-9456-4902-9c50-20e1f363d387" } } ``` ### POST /content/clone-video Put your character inside a video that worked. Takes a video whose movement earned attention and renders your character performing it, keeping the timing that made it work. The output is a new video, not a face pasted onto an old one. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - imageUrl (string; required; format uri): The character to render. - videoUrl (string; required; format uri): The video whose motion is cloned. - videoDuration (integer; optional): Trims the clone to this many seconds from the start. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/clone-video" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "imageUrl": "https://cdn.example.com/mara.jpg", "videoUrl": "https://cdn.example.com/trend.mp4", "videoDuration": 9 }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "27afc4b5-41d2-488f-85cf-9f7e9d7a1094", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/27afc4b5-41d2-488f-85cf-9f7e9d7a1094" } } ``` ### POST /content/talking-head One presenter, one script, to camera. A UGC character delivering your script, lip-synced. Leave the appearance fields out and one is chosen from the brand context; fill them in and the same character can be reproduced across a whole campaign. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - script (string; required): What they say, word for word. - avatarImageUrl (string; optional; format uri): A specific face to use. Omit and the presenter is built from the appearance fields below, or from your brand context when those are empty. - duration (integer; optional): Target length in seconds. The script is paced to fit rather than truncated. - language (enum; optional; default "en"; one of: ar | bg | bn | cs | da | de | el | en | en-GB | es-419 | es-ES | et | fi | fil | fr-CA | fr-FR | he | hi | hr | hu | id | it | ja | ko | lt | lv | ms | nl | no | pl | pt-BR | pt-PT | ro | ru | sk | sl | sv | ta | th | tr | uk | ur | vi | zh-Hans): BCP 47 language tag for the voice and the captions. One of the 44 languages Looptake generates; a tag outside the list is 400 invalid_input rather than a silent fall back to English. - mode (string; optional): Render mode to produce the video in. - gender (string; optional): Presented gender of the character to choose. - age (integer; optional): Apparent age of the character to choose. - ethnicity (string; optional): Apparent ethnicity of the character to choose. - appearance (string; optional): Free text: clothing, setting, framing. - referenceImages (string[]; optional): Extra references that keep the character consistent between renders. - captionOverlay (boolean; optional; default true): Burns word-level captions into the video. Leave it on: most short-form is watched muted. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/talking-head" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "script": "Your reorder point is a guess until you know three numbers. Here they are.", "duration": 20, "language": "en", "captionOverlay": true }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "218dc08b-83e1-4f26-a5e9-b84ce8a0bed0", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/218dc08b-83e1-4f26-a5e9-b84ce8a0bed0" } } ``` ### POST /content/talking-head-green-screen Presenter over your demo footage. The presenter sits in a corner of the frame and your demo plays behind them, which is the layout that lets a claim and the proof of it share one screen. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - script (string; required): What the presenter says over the demo. - avatarImageUrl (string; required; format uri): The presenter’s face. - demoVideoUrl (string; required; format uri): The footage behind them. - demoThumb (string; optional; format uri): Cover frame for the demo layer. - avatarPosition (string; optional): Where the presenter sits in the frame, for example `bottom-left`. - duration (integer; optional): Target length in seconds. The script is paced to fit rather than truncated. - language (enum; optional; default "en"; one of: ar | bg | bn | cs | da | de | el | en | en-GB | es-419 | es-ES | et | fi | fil | fr-CA | fr-FR | he | hi | hr | hu | id | it | ja | ko | lt | lv | ms | nl | no | pl | pt-BR | pt-PT | ro | ru | sk | sl | sv | ta | th | tr | uk | ur | vi | zh-Hans): BCP 47 language tag for the voice and the captions. One of the 44 languages Looptake generates; a tag outside the list is 400 invalid_input rather than a silent fall back to English. - captionOverlay (boolean; optional; default true): Burns word-level captions into the video. Leave it on: most short-form is watched muted. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/talking-head-green-screen" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "script": "Watch what happens when stock drops below the reorder point.", "avatarImageUrl": "https://cdn.example.com/mara.jpg", "demoVideoUrl": "https://cdn.example.com/demo.mp4", "avatarPosition": "bottom-left" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "7f0be271-ff4a-4c16-8810-a8910590c564", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/7f0be271-ff4a-4c16-8810-a8910590c564" } } ``` ### POST /content/interview A two-shot podcast cut. The claim arrives as overheard rather than announced, which is why an interview cut carries further than a piece to camera saying the same words. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - script (string; required): The exchange, as it should be spoken. - avatarImageUrl (string; optional; format uri): The interviewee’s face. - duration (integer; optional): Target length in seconds. The script is paced to fit rather than truncated. - language (enum; optional; default "en"; one of: ar | bg | bn | cs | da | de | el | en | en-GB | es-419 | es-ES | et | fi | fil | fr-CA | fr-FR | he | hi | hr | hu | id | it | ja | ko | lt | lv | ms | nl | no | pl | pt-BR | pt-PT | ro | ru | sk | sl | sv | ta | th | tr | uk | ur | vi | zh-Hans): BCP 47 language tag for the voice and the captions. One of the 44 languages Looptake generates; a tag outside the list is 400 invalid_input rather than a silent fall back to English. - gender (string; optional): Presented gender of the character to choose. - age (integer; optional): Apparent age of the character to choose. - ethnicity (string; optional): Apparent ethnicity of the character to choose. - appearance (string; optional): Free text: clothing, setting, framing. - captionOverlay (boolean; optional; default true): Burns word-level captions into the video. Leave it on: most short-form is watched muted. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/interview" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "script": "So what actually goes wrong? You order on a Monday because you always have.", "duration": 22 }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "20979bf3-5cb8-49c3-a9d9-da3e185bf0bd", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/20979bf3-5cb8-49c3-a9d9-da3e185bf0bd" } } ``` ### POST /content/product-spokesperson A presenter holding your product. Generated from one photograph of the product, so the thing in their hands is yours rather than a stand-in that looks like it. The instruction field is where you say what they should do with it. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - script (string; required): What the spokesperson says. - spokespersonImage (string; optional; format uri): The presenter’s face. - avatarImage (string; optional; format uri): An alternative character reference. - productImage (string; optional; format uri): One photograph of the product, ideally on a plain ground. - instruction (string; optional): What they do with the product: hold it, open it, point at a detail. - duration (integer; optional): Target length in seconds. The script is paced to fit rather than truncated. - language (enum; optional; default "en"; one of: ar | bg | bn | cs | da | de | el | en | en-GB | es-419 | es-ES | et | fi | fil | fr-CA | fr-FR | he | hi | hr | hu | id | it | ja | ko | lt | lv | ms | nl | no | pl | pt-BR | pt-PT | ro | ru | sk | sl | sv | ta | th | tr | uk | ur | vi | zh-Hans): BCP 47 language tag for the voice and the captions. One of the 44 languages Looptake generates; a tag outside the list is 400 invalid_input rather than a silent fall back to English. - captionOverlay (boolean; optional; default true): Burns word-level captions into the video. Leave it on: most short-form is watched muted. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/product-spokesperson" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "script": "It fits in a shirt pocket and it holds a week of stock counts.", "productImage": "https://cdn.example.com/scanner.jpg", "instruction": "Hold it up, then turn it to show the screen." }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "a3aa7e95-02af-4bd1-89d4-1fe1e7f0c2a2", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/a3aa7e95-02af-4bd1-89d4-1fe1e7f0c2a2" } } ``` ### POST /content/grid-video Four variations at once. A 2x2 grid does the work of four posts in one, and the retention curve tells you which quarter people actually watched. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - portrait (boolean; optional; default true): Renders 9:16. Turn it off for a square grid. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/grid-video" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "Four ways a shelf goes empty", "portrait": true }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "2a3524c5-42fb-493a-bcd9-fabae9e37944", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/2a3524c5-42fb-493a-bcd9-fabae9e37944" } } ``` ### POST /content/listicle Numbered points, one at a time. Paced so each line is read before the next arrives, which is the difference between a list that is watched and a list that is scrolled. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - layout (string; optional): How the list is arranged on the frame, for example `stacked` or `card`. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/listicle" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "Five signs you are about to run out of your best seller", "layout": "stacked" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "2a862e75-3794-4283-b142-01d05c2722d2", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/2a862e75-3794-4283-b142-01d05c2722d2" } } ``` ### POST /content/ask-me-anything A question card, answered to camera. Turns the objection you hear on every sales call into a video that handles it before it is asked. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/ask-me-anything" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "Why not just use a spreadsheet?" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "1f30e99a-fa58-44c3-babe-7d109e9f21df", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/1f30e99a-fa58-44c3-babe-7d109e9f21df" } } ``` ### POST /content/ranking A tier list from your category. Invites the disagreement in the comments that every ranking format earns, and puts your category in front of people who came to argue about it. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/ranking" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "Stock-control methods, worst to best" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "5b4c7516-18a4-445e-b3e0-be00fc03de0c", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/5b4c7516-18a4-445e-b3e0-be00fc03de0c" } } ``` ### POST /content/split-screen Demo above, retention clip below. The device that keeps a thumb still long enough for the demo to land. The lower half is not the message; it is the reason the upper half gets watched. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - caption (string; optional): On-screen text over the demo half. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/split-screen" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "Reorder point, explained in nine seconds", "caption": "The number you are guessing" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "7853b5b4-c9ce-46ad-b46b-c4a28f3e3a69", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/7853b5b4-c9ce-46ad-b46b-c4a28f3e3a69" } } ``` ### POST /content/fade-in-video One image, one line, one slow push. The quietest format in the set, and the one that works when the product is the whole argument. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - imageUrl (string; optional; format uri): The single image. Generated from the brand context when omitted. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/fade-in-video" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "prompt": "The shelf that never empties", "imageUrl": "https://cdn.example.com/shelf.jpg" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "b10ab1bb-64b7-40c4-9173-609bf09fd7a8", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/b10ab1bb-64b7-40c4-9173-609bf09fd7a8" } } ``` ### POST /content/hook-demo A three-second hook stitched onto real footage. The thing that stops the scroll and the thing that sells are rarely the same clip. This endpoint keeps them separate and joins them at the cut. - Scope: content:write - Success: 202 (poll statusUrl) - Rate limit: 30 per 300 s (studio) - Credits: 10 per render - Idempotency-Key: honoured - Paginated: no Body fields: - hookVideoUrl (string; required; format uri): The opening clip. Three seconds is usually enough. - demoVideoUrl (string; required; format uri): Your real product footage, played after the hook. - caption (string; optional): On-screen text over the hook. - prompt (string; optional): Steers the script. Left out, the brand context decides what the piece is about. - hookThumb (string; optional; format uri): Cover frame for the hook layer. - mentionBusiness (boolean; optional; default true): Names the brand in the script. Turn it off for a piece meant to read as editorial. - influencerId (string; optional; format id): Attributes the render to an AI influencer, so it can be published from that influencer’s accounts. - name (string; optional): Label for the piece in your library. Defaults to the format name and the creation time. Example request: ```bash curl -X POST "https://looptake.com/api/v1/content/hook-demo" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "hookVideoUrl": "https://cdn.example.com/hook.mp4", "demoVideoUrl": "https://cdn.example.com/demo.mp4", "caption": "I found out the expensive way" }' ``` Example response (202): ```json { "success": true, "data": { "contentId": "6e519ee1-e4a1-4d87-b91b-4a8adc32473f", "renderStatus": "pending", "creditsReserved": 10, "statusUrl": "https://looptake.com/api/v1/content/6e519ee1-e4a1-4d87-b91b-4a8adc32473f" } } ``` ## Analytics Performance by window and by post, from the platforms’ own reporting APIs. ### GET /analytics/overview Read performance for a period. Views, likes, followers and click-through for a period, per platform and in total, from each platform’s own reporting API. The previous period of equal length is returned alongside so a change is a subtraction rather than a second request. Name the period either as one of the three windows or as an arbitrary `from`/`to` range, never both: `?from=2026-01-01&to=2026-03-31` is how you ask "how did Q1 go". - Scope: analytics:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - `window` and `from`/`to` are two ways of naming one period and sending both is `400 invalid_input`. There is no precedence rule, deliberately: a rule would mean one of the two values you sent did nothing, and the response would confirm the request either way. - A range needs both ends. `from` alone is a filter, not a period, and this endpoint answers with the period *before* the one you asked for, which has no length unless yours does. - A range may span at most 366 days, both ends counted, so a whole leap year is expressible. Longer is `400`; ask twice. - `to` may not be after today. A period that has not finished would be compared against a whole one before it, and every unfinished range would look like a collapse. - `range` is the period as it was actually resolved and queried, as two instants and a day count, so nothing has to re-derive `28d` into dates to draw a chart under these numbers. Both ends are **inclusive**, at microsecond resolution, so `range.from <= publishedAt <= range.to` selects exactly the posts these totals counted. That is why the last microsecond is spelled out rather than rounded: an upper bound of `23:59:59Z` would be about a second narrower than the interval that was queried, and a post published inside that second would be in `totals` and outside the period the same response says it used. - `coverage.since` is when this workspace started existing and `coverage.complete` says whether the period you asked for is entirely inside that. Nothing deletes performance data, so there is no retention horizon to fall off: a period reaching back further than the workspace answers zero because Looptake was not there, which is a different fact from "nothing was published" and is the one this field exists to tell you. - `window` is `null` when a range was given. It is never a fourth value: a client switching on it meets an absence rather than a word it has not seen. Query parameters: - window (enum; optional; default "28d"; one of: 7d | 28d | 90d): How far back to read. Mutually exclusive with `from`/`to`. - from (string; optional; format date): First day of an arbitrary range, inclusive. Send with `to`, and without `window`. - to (string; optional; format date): Last day of an arbitrary range, inclusive. May not be after today. - platform (enum; optional; one of: tiktok | instagram | youtube): Restricts the figures to one platform. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/analytics/overview?from=2026-01-01&to=2026-03-31" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "window": null, "range": { "from": "2026-01-01T00:00:00.000000Z", "to": "2026-03-31T23:59:59.999999Z", "days": 90 }, "coverage": { "since": "2025-11-04T10:12:00Z", "complete": true }, "totals": { "views": 184220, "likes": 9104, "comments": 612, "shares": 1488, "saves": 2210, "followers": 1342, "clicks": 806 }, "previous": { "views": 141980, "likes": 7220, "comments": 501, "shares": 1102, "saves": 1740, "followers": 1109, "clicks": 615 }, "byPlatform": [ { "platform": "tiktok", "views": 121400, "followers": 902 }, { "platform": "instagram", "views": 41220, "followers": 288 }, { "platform": "youtube", "views": 21600, "followers": 152 } ] } } ``` ### GET /analytics/posts Read per-post performance. One row per post per platform, sortable, so the question "which angle worked" has an answer that is a query rather than an export. `clicks` is populated for posts that carried an attribution link. `signups` is always `null`: a signup happens on your site, and nothing in this schema can see it. Null rather than 0, because "not measured" is not "none". - Scope: analytics:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: yes Query parameters: - sort (enum; optional; default "views"; one of: views | likes | comments | shares | saves | clicks | publishedAt): Column to order by. - order (enum; optional; default "desc"; one of: asc | desc): Sort direction. - from (string; optional; format date): Inclusive lower bound on the publish date. - to (string; optional; format date): Inclusive upper bound on the publish date. - platform (enum; optional; one of: tiktok | instagram | youtube): Restricts to one platform. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/analytics/posts?sort=views&order=desc&limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "postId": "973821cf-5cc7-44f7-8795-e68da31c59ef", "platform": "tiktok", "format": "walloftext", "angle": "The cost of one stockout", "publishedAt": "2026-09-01T15:00:11Z", "views": 48210, "likes": 3110, "comments": 240, "shares": 610, "saves": 880, "clicks": 214, "signups": null } ], "pagination": { "total": 120, "page": 1, "limit": 1, "pages": 120 } } ``` ### GET /analytics/export Export per-post performance as CSV. The same rows `GET /analytics/posts` returns for the same filters, as a `text/csv` file with a `Content-Disposition` attachment. One row per post per platform, which is the grain the data has: nothing in Looptake stores a daily history per post, so a per-day column would repeat one cumulative figure or interpolate one, and a per-period file is a single row nobody can pivot. Fields are quoted per RFC 4180 and a value that would begin a spreadsheet formula is prefixed with an apostrophe, so a caption containing a comma, a quote, a newline or a leading `=` arrives intact and inert. - Scope: analytics:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The file opens as UTF-8 in Excel: it begins with a byte-order mark, so captions in any language render rather than becoming mojibake. - Records end with CRLF and a caption’s own newline stays a bare LF inside quotes, so splitting on CRLF never cuts a caption in half. - `caption` is here and not in `GET /analytics/posts`: a JSON row hands you `postId` to follow and a spreadsheet row can follow nothing. - `signups` is in `GET /analytics/posts` and not here. It is always `null` there, and a CSV has no null: an empty cell under that heading would read as zero signups. - A filter matching more rows than one export may hold is `400 invalid_input` naming the way out, rather than a truncated file. A CSV has nowhere to put a warning. Query parameters: - sort (enum; optional; default "views"; one of: views | likes | comments | shares | saves | clicks | publishedAt): Column to order by. - order (enum; optional; default "desc"; one of: asc | desc): Sort direction. - from (string; optional; format date): Inclusive lower bound on the publish date. - to (string; optional; format date): Inclusive upper bound on the publish date. - platform (enum; optional; one of: tiktok | instagram | youtube): Restricts to one platform. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl "https://looptake.com/api/v1/analytics/export?from=2026-08-01&to=2026-08-31" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```csv "postId","platform","publishedAt","format","angle","caption","views","likes","comments","shares","saves","clicks" "973821cf-5cc7-44f7-8795-e68da31c59ef","tiktok","2026-08-01T15:00:11Z","walloftext","The cost of one stockout","The maths nobody runs, before they run out.","48210","3110","240","610","880","214" ``` ### POST /analytics/reports Share a performance report by link. Mints a signed URL that shows one workspace’s numbers for one period to somebody with no Looptake account. The period is resolved at mint time and frozen as two instants: a link shared in August still shows August in October rather than quietly accumulating a quarter of data nobody agreed to send. Name it as a `window` or as a `from`/`to` range, on exactly the rules `GET /analytics/overview` applies, so a period that screen can show is a period this can share. The URL is returned once and cannot be read back, because the token is derived from a secret this deployment stores only sealed. - Scope: analytics:read - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The report shows totals, a per-platform breakdown and the ten most-watched posts. It carries no ids of any kind, and no captions from unpublished drafts: every row comes from the published-targets query, so there is no draft in the result set to take one from. - The brand `angle` is deliberately absent from the shared report. It is the internal reason a piece exists, and it is in the CSV export, which goes to the account holder. - `analytics:read` rather than a scope of its own: the link shows a strict subset of what that scope already reads, so minting one grants nothing a caller did not have. - The period is read by exactly the function `GET /analytics/overview` reads it with, so every refusal is the same refusal with the same sentence. A period one endpoint took and the other did not would be a `400` a customer meets after choosing what to share. - `window` in the response echoes what was frozen: `28d` for a named window, `2026-04-01..2026-06-30` for a range. It names the period, not the request: a recipient opening the link in October reads the period it was minted for. - `periodStart` and `periodEnd` are the frozen interval in the same **inclusive** form as `range` on GET /analytics/overview, at microsecond resolution. For a range they are the two dates you sent: `from=2026-04-01` comes back as `2026-04-01T00:00:00.000000Z` and not as the last instant of 31 March. Body fields: - recipientLabel (string; required): Who the link is for. Recorded at mint time; it is the only record of who received the numbers. - window (enum; optional; default "28d"; one of: 7d | 28d | 90d): How far back the frozen period reaches. Mutually exclusive with `from`/`to`. - from (string; optional; format date): First day of an arbitrary frozen period, inclusive. Send with `to`, and without `window`. - to (string; optional; format date): Last day of an arbitrary frozen period, inclusive. May not be after today. - expiresInDays (integer 1-30; optional; default 7): How long the link lives. There is no “never”. - workspaceId (string; optional; format id): Scopes the request to one workspace. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/analytics/reports" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipientLabel": "Dana at Acme", "window": "28d", "expiresInDays": 14 }' ``` Example response (201): ```json { "success": true, "data": { "id": "b1f0c2d4-8f3a-4a2e-9c17-6b0d5e8a4412", "recipientLabel": "Dana at Acme", "window": "28d", "periodStart": "2026-08-05T09:00:00.000001Z", "periodEnd": "2026-09-02T09:00:00.000000Z", "expiresAt": "2026-09-16T09:00:00Z", "url": "https://looptake.com/report/b1f0c2d4-8f3a-4a2e-9c17-6b0d5e8a4412.1789347600.4f1c9a0e2b7d8351c6a4f0e9b2d7315a8c0e4f61a9d3b7250c8e1f4a6d039b2c" } } ``` ### DELETE /analytics/reports/{id} Revoke a shared report link. Kills the link. Revocation clears the signing secret rather than only stamping a flag, so the copy of the URL already sitting in somebody’s inbox stops resolving immediately. Revoking twice keeps the first instant: the answer to when a report was cut off must not move every time the button is pressed. - Scope: analytics:read - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - An unknown id, or one belonging to another team, is `404 not_found`. Path parameters: - id (string; required; format id): The report id returned when the link was minted. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/analytics/reports/b1f0c2d4-8f3a-4a2e-9c17-6b0d5e8a4412" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "b1f0c2d4-8f3a-4a2e-9c17-6b0d5e8a4412", "revokedAt": "2026-09-03T11:20:04Z" } } ``` ## Trends The daily trend library: sounds, formats, topics and hashtags, per platform. ### GET /trends Read the daily trend library. Sounds, formats, topics and hashtags observed per platform, refreshed daily by the worker. `observedAt` is when the entry was recorded, and it matters: a trend read a week late is a trend you are following rather than riding. - Scope: generations:read - Success: 200 - Rate limit: 10 per 300 s (generations) - Credits: none - Idempotency-Key: ignored - Paginated: yes Query parameters: - platform (enum; optional; one of: tiktok | instagram | youtube): Restricts to one platform. - kind (enum; optional; one of: sound | format | topic | hashtag): Restricts to one kind of trend. - page (integer; optional; default 1; minimum 1): One-based page number. - limit (integer 1-100; optional; default 20): Items per page. Example request: ```bash curl "https://looptake.com/api/v1/trends?platform=tiktok&kind=sound&limit=1" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "282e5c8b-5e73-4646-b03c-75bc8e0b034c", "platform": "tiktok", "kind": "sound", "title": "Slowed piano loop, 92 bpm", "metric": "Used on 41,000 videos in the last 24 hours", "observedAt": "2026-08-12T05:00:00Z" } ], "pagination": { "total": 80, "page": 1, "limit": 1, "pages": 80 } } ``` ## Webhooks Four events pushed to a URL you own, so nothing has to poll a schedule that runs for a month. ### GET /webhooks List webhook endpoints and their recent deliveries. Each endpoint carries its last five delivery outcomes with every attempt behind them: the status your server returned, how long it took, and the transport error when it did not answer at all. This is where to look when events stop arriving. `status` is three-valued: `active`, `paused` (you switched it off) and `disabled` (we did, after a run of consecutive failures, with `disabledReason` saying so). The signing secret is shown once, by the create, and is not readable here or anywhere else. - Scope: webhooks:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Delivery is at-least-once. Deduplicate on the `Looptake-Delivery` header, which is the delivery `id` and is stable across every attempt at one event. Example request: ```bash curl "https://looptake.com/api/v1/webhooks" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": [ { "id": "ae7ede5e-9f5a-42a3-8901-40b74b2130af", "url": "https://northwind.example/hooks/looptake", "events": [ "post.published", "post.failed" ], "workspaceId": null, "secretLast4": null, "status": "active", "failureCount": 0, "disabledAt": null, "disabledReason": null, "lastDeliveryAt": "2026-08-13T09:00:12Z", "createdAt": "2026-07-20T08:00:00Z", "deliveries": [ { "id": "5b0d6f0a-6e0a-4a3e-8f2f-0d7a2d0e9b11", "event": "post.published", "status": "delivered", "attempts": 2, "responseStatus": 200, "error": null, "nextAttemptAt": null, "deliveredAt": "2026-08-13T09:00:12Z", "createdAt": "2026-08-13T09:00:00Z", "attemptLog": [ { "at": "2026-08-13T09:00:00Z", "status": 503, "latencyMs": 87, "error": "the endpoint answered 503" }, { "at": "2026-08-13T09:00:12Z", "status": 200, "latencyMs": 41, "error": null } ] } ] } ] } ``` ### POST /webhooks Create a webhook endpoint. Four events are available: `post.published`, `post.failed`, `content.ready` and `credits.low`. The response carries the signing secret once. Every delivery is signed with it as `Looptake-Signature: t=,v1=`, an HMAC-SHA-256 over `.`. The timestamp is inside the signed message, so a captured delivery cannot be replayed with a fresh one. The verification recipe is on /developers/docs/webhooks. - Scope: webhooks:write - Success: 201 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The `secret` field appears in this response only. It cannot be read back afterwards. - A second subscription for the same URL is a 409, not a duplicate: a customer who clicks create twice gets one endpoint rather than two copies of every event. Body fields: - url (string; required; format uri): Where deliveries are posted. Must be HTTPS. - events (enum; required; one of: post.published | post.failed | content.ready | credits.low): Which events to receive. At least one. - workspaceId (string; optional; format id): Restricts deliveries to one workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/webhooks" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://northwind.example/hooks/looptake", "events": [ "post.published", "post.failed" ] }' ``` Example response (201): ```json { "success": true, "data": { "id": "ae7ede5e-9f5a-42a3-8901-40b74b2130af", "url": "https://northwind.example/hooks/looptake", "events": [ "post.published", "post.failed" ], "secret": "whsec_9f13c0a8b24e7d5162af38cc90b1e774", "status": "active" } } ``` ### PATCH /webhooks/{id} Correct an endpoint, or switch it back on. Every field is optional and a field you leave out is left alone. `events` replaces the list rather than merging into it, because “stop sending me post.failed” has no other spelling; an empty array is `invalid_input`, not “every event”. `active: false` pauses deliveries and keeps everything else. `active: true` resumes, and it is the only way back from `disabled`, which we set ourselves after a run of consecutive failures: a disabled endpoint is sent nothing, so it can never succeed its way out. Resuming clears `disabledAt`, `disabledReason` and `failureCount` together. There is no `workspaceId` here: which workspace an endpoint receives is what its delivery history is a history of, so that is a different subscription rather than an edit. - Scope: webhooks:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - The signing secret is not re-minted by an edit, and is still not readable: the endpoint keeps the secret it was created with, so nothing at your end has to be redeployed. - Sending an empty body is `invalid_input` rather than a 200 that changed nothing. - Moving an endpoint onto a URL the team already subscribes is a `409`, the same answer the create gives. Path parameters: - id (string; required; format id): The webhook id. Body fields: - url (string; optional; format uri): Where deliveries are posted. Must be HTTPS. - events (enum; optional; one of: post.published | post.failed | content.ready | credits.low): The complete new event list. At least one; replaces what is there. - active (boolean; optional): Whether to deliver to this endpoint. `true` also clears a disablement and its failure count. Example request: ```bash curl -X PATCH "https://looptake.com/api/v1/webhooks/ae7ede5e-9f5a-42a3-8901-40b74b2130af" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": [ "post.published" ], "active": true }' ``` Example response (200): ```json { "success": true, "data": { "id": "ae7ede5e-9f5a-42a3-8901-40b74b2130af", "url": "https://northwind.example/hooks/looptake", "events": [ "post.published" ], "workspaceId": null, "secretLast4": null, "status": "active", "failureCount": 0, "disabledAt": null, "disabledReason": null, "lastDeliveryAt": "2026-08-13T09:00:12Z", "createdAt": "2026-07-20T08:00:00Z", "deliveries": [] } } ``` ### DELETE /webhooks/{id} Delete a webhook endpoint. Stops deliveries immediately. Anything already in flight is not recalled. The delivery history goes with the row, and the signing secret cannot be recovered, so if you only want deliveries to stop, `PATCH` with `active: false` instead. - Scope: webhooks:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Path parameters: - id (string; required; format id): The webhook id. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/webhooks/ae7ede5e-9f5a-42a3-8901-40b74b2130af" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "id": "ae7ede5e-9f5a-42a3-8901-40b74b2130af", "deleted": true } } ``` ## Brand context Re-read the website and rebuild the brand context every generation is written from. ### POST /brand/analyze Re-derive the brand context. Crawls the site again and rebuilds the identity, audience, tone, value propositions, angles, keywords, competitors and palette that every generation is written from. Run it after a repositioning or a pricing change; every batch generated afterwards is written from the new reading. - Scope: workspaces:write - Success: 202 (poll statusUrl) - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Poll GET /workspaces/{id} until `brandContext.refreshedAt` moves past the time of this call. Body fields: - url (string; optional; format uri): The site to read. Defaults to the workspace’s own `websiteUrl`. - workspaceId (string; optional; format id): Which workspace to rebuild. Defaults to the key’s default workspace. Example request: ```bash curl -X POST "https://looptake.com/api/v1/brand/analyze" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://northwind.example" }' ``` Example response (202): ```json { "success": true, "data": { "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "status": "analyzing", "statusUrl": "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c" } } ``` ### DELETE /brand Throw the derived brand context away. The undo for a first crawl that read the wrong site. Re-analysing already replaces a context, so this is for the case re-analysing cannot reach: a crawl that fails (a client-rendered page, a login wall, a domain that has moved) writes nothing, and the wrong reading stays and goes on being what every suggestion, automation batch and caption is written from. A context that is wrong is worse than none; with none, the dashboard asks for a site again. Deleting twice is a 404. - Scope: workspaces:write - Success: 200 - Rate limit: 600 per 300 s (default) - Credits: none - Idempotency-Key: ignored - Paginated: no Notes: - Nothing else is removed: the workspace, its accounts, its posts and everything it has published are untouched. Query parameters: - workspaceId (string; optional; format id): Which workspace to clear. Defaults to the key’s default workspace. Example request: ```bash curl -X DELETE "https://looptake.com/api/v1/brand?workspaceId=91e14cbf-2c80-4b3b-a6bf-776bbde6b54c" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" ``` Example response (200): ```json { "success": true, "data": { "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c", "deleted": true } } ``` # Guides ## Getting started From API key to a scheduled short-form video in five requests. Source: https://looptake.com/developers/docs/getting-started Create a key in the dashboard (https://looptake.com/app/settings/developers) with generations:write, posts:write and accounts:read. Export it as LOOPTAKE_API_KEY. GET /me needs no scope: use it as the health check. GET /accounts gives the account ids to post to. ```bash curl -X POST "https://looptake.com/api/v1/generations" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "count": 2, "format": "walloftext" }' ``` Pass suggestionId to POST /posts to accept and schedule in one call: ```bash curl -X POST "https://looptake.com/api/v1/posts" \ -H "Authorization: Bearer $LOOPTAKE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "suggestionId": "6522d1ec-2c11-4d3a-b078-6f6cfe9f217b", "selectedAccounts": { "tiktok": [ "ff9d429b-07c5-4320-a98c-ac6e1791b828" ], "youtube": [ "1c74c860-000b-4136-88a7-a17f31019346" ] }, "schedule": "scheduled", "scheduledTime": "2026-09-01T15:00:00Z", "timezone": "Europe/Lisbon", "declarations": { "madeForKids": false, "brandedContent": true, "aiGenerated": true, "containsSyntheticMedia": true } }' ``` Poll GET /posts/{id} until posted, partial, failed or cancelled. Also stop on draft, and on renderStatus failed. ## Authentication and scopes Keys belong to a team and carry a fixed set of scopes. Source: https://looptake.com/developers/docs/authentication Keys belong to a team, carry fixed scopes, are shown once and stored hashed. Live keys start lt_live_; lt_test_ keys never post. 18 scopes: - generations:read: List suggestions, read one in full, and read the trend library. - generations:write: Generate suggestions, accept one into content, and create A/B hook variants. - posts:read: List posts, read one with its per-platform results, and read the calendar. - posts:write: Create posts, schedule them, and cancel one that has not published yet. - posts:approve: Approve or reject a post that is waiting for review. Deliberately not implied by posts:write, and held only by a signed-in reviewer: an API key cannot be minted with it, because an approval has to name the person who made it. - automations:read: List automations, read one, and list the posts an automation produced. - automations:write: Create, edit, generate, extend, launch, pause, resume and cancel automations. - influencers:read: List AI influencers, read one, and read their rendered videos. - influencers:write: Create AI influencers and render talking-head videos for them. Both spend credits. - assets:read: List the workspace asset library: clips, images, audio and fonts. - assets:write: Add an asset to the library by URL. - content:write: Start a content studio render, read its status, and download the finished media. - accounts:read: List connected TikTok, Instagram and YouTube accounts and their connection status. - credits:read: Read the credit balance and the ledger, including refunds. - workspaces:read: List workspaces and read one with its brand context. - workspaces:write: Create a workspace and re-derive its brand context from a URL. - analytics:read: Read the performance overview and per-post analytics. - webhooks:write: List, create, edit and delete webhook endpoints. There is no separate read scope. 401 unauthorized covers every auth failure. 403 forbidden_scope names the missing scope. Another team's resource is 404. ## Generations A generation is a video idea: hook, caption, hashtags, hook score and, for template formats, the deck that renders it. Source: https://looptake.com/developers/docs/generations POST /generations is synchronous and free: caption (hook), postCaption, hashtags, why, angle, score (0 to 100) and, for template formats, a deck. count 1 to 10; prompt steers; format pins; influencerId writes for an AI avatar. Accept with POST /generations/{id}/accept (counts against quota). POST /generations/{id}/variants rewrites only the hook. Automation ideas are read via GET /automations/{id}/posts. ## Posts and scheduling One endpoint creates a post for any mix of connected accounts and reports one result per account. Source: https://looptake.com/developers/docs/posts-and-scheduling POST /posts takes one source, first wins: suggestionId, contentId, deck with format (returns 202), videoUrl or images. selectedAccounts maps platform to account ids. schedule is asap, scheduled or draft; only POST /posts/{id}/publish releases a draft. perPlatform sets per-platform caption, hashtags, YouTube title or first comment. declarations.madeForKids is required for YouTube targets. POST /posts/preview shows what each account would receive and creates nothing. POST /posts/import takes CSV (caption, videoUrl, optional scheduledTime and hashtags), all or nothing, drafts by default. PATCH edits words, /reschedule moves, DELETE cancels before publishing. GET /calendar needs from and to. ## Automations An automation is a cadence plus target accounts. It generates a whole run and publishes nothing until you launch it. Source: https://looptake.com/developers/docs/automations An automation is a cadence plus target accounts. draft takes PATCH and generate; generating is a wait; review takes launch; launched takes extend, pause or cancel; paused takes resume or cancel. Other calls return 409. generate publishes nothing. launch honours Idempotency-Key. tiktokMode inbox sends every TikTok post to the inbox without a caption. ## AI avatars and influencers Create a presenter once and render talking-head videos from it. Renders are asynchronous and priced per second. Source: https://looptake.com/developers/docs/influencers POST /influencers creates a reusable AI avatar (10 credits). Use subject "synthetic" for invented characters; a real person needs a consent block. POST /influencers/{id}/videos renders a talking head (5 credits per second) and returns 202; poll GET /videos/{id} until ready or failed. Failures refund. ## Credits and plan limits Credits pay for AI media only. Scripts, editing, scheduling and posting are free. Source: https://looptake.com/developers/docs/credits-and-limits Free: hooks and scripts, text and slideshow formats, editing and captions, scheduling and auto-posting, analytics and reports. - AI image: 2 credits per image - Premium AI video: 10 credits per generation - Influencer talking-head video: 3 credits per second of video - AI influencer preview: 10 credits per preview - Studio render (swap, clone, green screen): 10 credits per render - Voiceover: 1 credit per 15 seconds of speech - Long video to clips, per minute: 1 credit per minute of source video Failed renders refund automatically. Top-up credits never expire. GET /credits returns the balance and an append-only ledger. 402 insufficient_credits charges nothing. 429 usage_limit_reached is a plan quota; 429 rate_limited clears after Retry-After. ## Errors and rate limits Every failure is one of 9 types. Branch on the type, never on the message. Source: https://looptake.com/developers/docs/errors-and-rate-limits - **invalid_input** (400): The request body or query failed validation. Read `error.message`, fix the field it names, and send the request again. Retrying unchanged will fail identically. - **unauthorized** (401): No key, an unreadable key, or a key that is no longer valid. Check the Authorization header. Every authentication failure collapses to this one type on purpose, so the message will not say which part was wrong. - **forbidden_scope** (403): The key is valid but does not carry the scope this endpoint requires. Issue a key with the scope named in the message. Scopes cannot be added to an existing key. - **not_found** (404): No resource with that id inside the team the key belongs to. Check the id and the workspace. A resource in another team is a 404, not a 403. - **conflict** (409): The resource is not in a state that allows this call, or an idempotency key was replayed with a different body. Read the current state first. Cancelling a published post and editing a launched automation both land here. - **insufficient_credits** (402): The team does not have enough credits for the media this call would generate. Top up, or use a Turbo format, which costs nothing. Nothing is charged and nothing is queued. - **usage_limit_reached** (429): A plan quota is exhausted: saved posts, automations, content pieces, connected accounts or workspaces. Wait for the period to roll over or move to a plan with a higher limit. Retrying sooner returns the same error. - **rate_limited** (429): Too many requests in the sliding window for this endpoint group. Wait for the number of seconds in `Retry-After`, then retry. `X-RateLimit-Reset` carries the same moment as a timestamp. - **internal_error** (500): Something failed on our side. Retry with backoff. If the call spends credits, send the same `Idempotency-Key` so a partial success is not repeated. - Generations: 10 per 300 s. Every endpoint under /generations. - Posts: 60 per 300 s. Every endpoint under /posts. - Content studio: 30 per 300 s. Every POST /content/* render endpoint. - Everything else: 600 per 300 s. Reads, workspaces, accounts, automations, influencers, assets, analytics and webhooks. - X-RateLimit-Limit: Requests allowed in the window for this endpoint group. - X-RateLimit-Remaining: Requests left in the current window. - X-RateLimit-Reset: Unix seconds at which the window rolls over. - Retry-After: Seconds to wait. Sent on a 429 only. ## Webhooks Signed events sent to a URL you own. Delivery is at least once. Source: https://looptake.com/developers/docs/webhooks - post.published: A post reached at least one platform, including a partial where one of three accounts failed. - post.failed: Every target failed. The post is terminal and nothing is retrying. - content.ready: A render finished and the video exists: a Turbo deck or any of the 15 Studio formats. - credits.low: A spend took the balance under a fifth of the plan’s monthly credit allowance. Fires on the crossing, once. Header Looptake-Signature is t=,v1=."> keyed with the endpoint secret. Deduplicate on Looptake-Delivery. Delivery is at least once. ## The Looptake CLI One npm package, one binary, a command for every endpoint. Source: https://looptake.com/developers/docs/cli ```bash npx looptake login npx looptake generate --count 3 --prompt "September launch" --json npx looptake posts create --suggestion-id --tiktok --at 2026-09-01T15:00:00Z --wait ``` One command per endpoint. --json prints data only on stdout. --wait polls 202 jobs. --dry-run sends nothing. Idempotency keys are automatic. LOOPTAKE_API_KEY beats the stored login. Exit codes: 0 ok, 1 failure, 2 usage, 3 auth, 4 not found, 5 conflict, 6 credits, 7 plan quota, 8 rate limited, 9 --wait timeout (the job is still running; do not re-run). ## MCP server The Looptake API as MCP tools. Hosted over HTTP or local over stdio, with the same scopes as your key. Source: https://looptake.com/developers/docs/mcp Hosted: Streamable HTTP at https://looptake.com/mcp, header Authorization: Bearer . Local: npx -y @looptake/mcp over stdio with LOOPTAKE_API_KEY (optional LOOPTAKE_WORKSPACE, LOOPTAKE_READ_ONLY). ```json { "mcpServers": { "looptake": { "url": "https://looptake.com/mcp", "headers": { "Authorization": "Bearer lt_live_..." } } } } ``` ```json { "mcpServers": { "looptake": { "command": "npx", "args": ["-y", "@looptake/mcp"], "env": { "LOOPTAKE_API_KEY": "lt_live_..." } } } } ``` 62 tools, each listed only when the key has its scope. Tools that publish, spend or delete need confirm: true. - looptake_get_me (any): Team, plan, credit balance and the scopes this key carries. - looptake_get_credits (credits:read): Balance and ledger, so an agent can check before it spends. - looptake_list_workspaces (workspaces:read): Every brand this key can reach, and when each was last analysed. - looptake_create_workspace (workspaces:write): Add a brand and queue the first crawl of its website. - looptake_get_workspace (workspaces:read): One workspace with the brand context every generation is written from. - looptake_archive_workspace (workspaces:write) [confirm: destructive]: Stop a brand. Its scheduled posts are withheld rather than cancelled, and restoring puts them back. - looptake_restore_workspace (workspaces:write): Resume an archived brand, and everything that was withheld with it. - looptake_set_default_workspace (workspaces:write) [confirm: destructive]: Choose which brand a call that omits workspaceId lands in, for every credential on the team. - looptake_get_brand_kit (workspaces:read): The look every render in a workspace gets: captions, font, logo, outro, watermark and voice. - looptake_update_brand_kit (workspaces:write): Change a workspace brand kit. Omitted fields keep their value; null clears. - looptake_reset_brand_kit (workspaces:write) [confirm: destructive]: Remove a workspace brand kit so renders use the house look. - looptake_list_accounts (accounts:read): Connected accounts and their ids, which every publish call needs. - looptake_create_video (generations:write): Make a finished video from one sentence. No brand profile needed. Poll looptake_get_video. - looptake_remix_video (generations:write): Remix a viral video link onto your topic: same structure, new video. Poll looptake_get_video. - looptake_clip_video (generations:write) [confirm: spend]: Cut a long video into scored 9:16 clips. Costs 1 credit per minute of source. Poll looptake_get_video. - looptake_bulk_videos (generations:write) [confirm: spend]: Make up to 100 videos from CSV or rows, optionally scheduled. Poll looptake_get_bulk. - looptake_get_bulk (generations:read): Progress of a bulk batch. - looptake_generate_suggestions (generations:write): Generate suggestions from the brand context, or from a prompt. Costs no credits. - looptake_list_generations (generations:read): Read the pending suggestion queue. - looptake_get_generation (generations:read): One suggestion in full, including its deck. - looptake_accept_generation (generations:write): Turn a suggestion into a content piece. - looptake_create_variants (generations:write): Write A/B hook variants of a suggestion. - looptake_create_post (posts:write) [confirm: publish]: Schedule or publish. The tool that needs a human in front of it. - looptake_preview_post (posts:write): What each named account would actually receive, without creating anything: the exact text that account gets, its YouTube title written or derived, the declarations, and the refusals each platform would raise. - looptake_import_posts (posts:write) [confirm: publish]: Turn a CSV of captions, video URLs and times into a calendar in one call, all of it or none of it. Held as drafts unless you ask for schedule: "scheduled". Send validateOnly first: it answers the resolved UTC instant of every row, which is where a file read in the wrong timezone shows itself before a hundred posts exist. - looptake_list_posts (posts:read): Posts by status, automation or date range. - looptake_get_post (posts:read): One post with per-platform results and analytics. - looptake_edit_post (posts:write) [confirm: publish]: Fix the caption, the hashtags, one platform’s own words, or the five platform declarations on a post that has not gone out, keeping its id. `declarations` replaces the whole object, so send every answer you want to stand. Re-opens an approved post for review, because the reviewer agreed to the post as it was. - looptake_cancel_post (posts:write) [confirm: destructive]: Cancel a post that has not published yet. - looptake_reschedule_post (posts:write) [confirm: publish]: Move a post that has not published yet to a different time. Re-opens an approved post for review, because the reviewer agreed to a slot. - looptake_publish_post (posts:write) [confirm: publish]: Release a draft so it goes out at the time it already carries. The only thing that sends a post saved with schedule: "draft"; it takes no body and chooses no time. - looptake_get_calendar (posts:read): The schedule grouped by local day. - looptake_create_automation (automations:write): Create a draft run with a cadence and a target set. - looptake_list_automations (automations:read): Every run, with the status that says which call is legal next. - looptake_get_automation (automations:read): One run with its settings, targets and counts. - looptake_update_automation (automations:write): Edit a run while it is still a draft. - looptake_generate_automation (automations:write): Fill every slot in a run. Publishes nothing. - looptake_extend_automation (automations:write) [confirm: publish]: Add days to a running automation, at the same rhythm. - looptake_launch_automation (automations:write) [confirm: publish]: Schedule a reviewed run. The second tool that needs a human. - looptake_cancel_automation (automations:write) [confirm: destructive]: Cancel a run and every post it has not published yet. - looptake_list_automation_posts (automations:read): The review queue of a run, and its history after launch. - looptake_list_influencers (influencers:read): The AI presenters this workspace has created. - looptake_create_influencer (influencers:write) [confirm: spend]: Create a reusable AI presenter. Spends 10 credits. - looptake_get_influencer (influencers:read): One presenter, with the voice and accounts it publishes from. - looptake_delete_influencer (influencers:write) [confirm: destructive]: Delete a presenter, its videos and its consent history, and give back the plan slot. Refunds nothing. - looptake_create_influencer_video (influencers:write) [confirm: spend]: Render a talking head. Spends 5 credits per second. - looptake_list_influencer_videos (influencers:read): Every video rendered for one presenter. - looptake_get_video (influencers:read): Poll one video, prompt-first or influencer, until it is ready or failed. - looptake_list_assets (assets:read): The workspace library of clips, images, audio and fonts. - looptake_create_asset (assets:write): Add an asset to the library by URL. - looptake_get_content (content:write): Poll a render and read the finished media. - looptake_download_content (content:write): A short-lived signed URL for finished media. - looptake_create_studio_render (content:write) [confirm: spend]: Start any studio render. Spends 10 credits. - looptake_get_analytics (analytics:read): Performance for a window, per platform and in total. - looptake_list_analytics_posts (analytics:read): Per-post performance, sortable, with attribution-link clicks. - looptake_get_trends (generations:read): The daily trend library. - looptake_list_webhooks (webhooks:write): The endpoints events are delivered to. - looptake_create_webhook (webhooks:write): Subscribe a URL to post, content and credit events. - looptake_delete_webhook (webhooks:write) [confirm: destructive]: Stop deliveries to an endpoint. - looptake_analyze_brand (workspaces:write): Re-read the website and rebuild the brand context. - looptake_clear_brand (workspaces:write) [confirm: destructive]: Throw away a workspace’s derived brand context. - looptake_update_workspace (workspaces:write): Correct a workspace’s name, handle, site, logo or timezone. Agent tips: - Call GET /me (or looptake_get_me) first: plan, credits and the scopes on the key. - Read the workspace brand before generating. - Generate wide and keep one. Ideas are free and rate limited. - Preview with POST /posts/preview before creating a post. - Ask a person before publishing. Offer tiktokSendToInbox for the first post. - Send Idempotency-Key on every POST that publishes or spends credits. - Use an lt_test_ key while building. ## Reference conventions Rules that hold for every endpoint. Learn them once, then read the reference as tables. Source: https://looptake.com/developers/docs/reference Ids are lower-case UUIDs. Timestamps are ISO 8601 UTC; schedules also take an IANA timezone. Pagination: page and limit (max 100). Most calls take workspaceId, else the key's default workspace. 202 responses carry statusUrl; poll with backoff. # Site content 134 pages. ## Industries Short-form video for one industry each. Hub: https://looptake.com/for - [AI Video Generator for SaaS: Daily Shorts | Looptake](https://looptake.com/for/saas): Turn your SaaS site into daily TikToks, Reels and Shorts. Describe the video and Looptake writes the hook, builds the demo around your screen recording and posts it. - [AI Product Videos for Ecommerce Brands | Looptake](https://looptake.com/for/ecommerce): Add your store and get AI UGC, spokesperson and slideshow videos for every product, made for TikTok, Reels and Shorts, then scheduled and posted for you. No shoot. - [App Promo Videos for TikTok and Reels | Looptake](https://looptake.com/for/mobile-apps): Make app promo videos from one sentence. Looptake builds green screen, split screen and demo shorts around your screen recordings and posts them to TikTok daily. - [AI Shorts Generator for Content Creators | Looptake](https://looptake.com/for/content-creators): Post daily without burning out. Describe the video and Looptake writes the hook, voice and captions, then posts to TikTok, Reels and Shorts. Clips long videos too. - [AI Videos for Beauty and Skincare Brands | Looptake](https://looptake.com/for/beauty-skincare): Routines, ingredient explainers and AI UGC for beauty brands. Describe the video and Looptake makes it for TikTok, Reels and Shorts, then posts it on your schedule. - [AI Fitness Videos for Gyms and Coaches | Looptake](https://looptake.com/for/fitness-gyms): Workout tips, myth-busting and class promos for gyms and coaches. Describe the video and Looptake writes, voices and captions it, then posts to TikTok and Reels. - [AI Real Estate Videos for TikTok and Reels | Looptake](https://looptake.com/for/real-estate): Listing tours, market explainers and buyer tips for agents. Add listing photos or describe the video, and Looptake makes the short and posts it to TikTok and Reels. - [AI Food Videos for Restaurants and Cafes | Looptake](https://looptake.com/for/restaurants-food): Menu drops, 30-second recipes and food rankings. Describe the video, add dish photos, and Looptake makes and posts the short to TikTok, Reels and Shorts for you. - [AI Videos for Course Creators and Coaches | Looptake](https://looptake.com/for/courses-coaches): Turn your lessons into daily shorts. Describe one idea and Looptake makes the explainer with an AI presenter or text, then posts it to TikTok, Reels and Shorts. - [AI Music Promo Videos for TikTok and Reels | Looptake](https://looptake.com/for/music-artists): Keep your profile alive between releases. Describe the video, add your track, and Looptake builds lyric, story and teaser shorts for TikTok, Reels and Shorts. - [Podcast Clips for TikTok, Reels and Shorts | Looptake](https://looptake.com/for/podcasters): Paste an episode and get captioned shorts of the best moments, plus text and interview-style promos. Looptake schedules and posts them to TikTok, Reels and Shorts. ## Use cases One job each, shown in the product. Hub: https://looptake.com/use-cases - [Faceless Video Generator for TikTok and Shorts | Looptake](https://looptake.com/use-cases/faceless-videos): Run a faceless channel from one sentence a day. Looptake writes the script, adds AI visuals, voiceover and captions, and posts to TikTok, Reels and YouTube Shorts. - [AI UGC Ads for TikTok and Meta | Looptake](https://looptake.com/use-cases/ai-ugc-ads): Make AI UGC ads in minutes. Describe the product and the angle, and Looptake writes the script and renders an AI creator video for TikTok and Meta ads. No shoot. - [Create an AI Influencer for Your Brand | Looptake](https://looptake.com/use-cases/ai-influencer-for-your-brand): Create a consistent AI influencer that presents your brand on TikTok, Reels and Shorts. Same face, same voice, new videos every day from a one-sentence prompt. - [AI Product Demo Videos for Social Media | Looptake](https://looptake.com/use-cases/product-demo-videos): Turn a raw screen recording into a short product demo with a hook, voiceover and captions. Looptake cuts it to 9:16 and posts it to TikTok, Reels and Shorts for you. - [AI TikTok Slideshow Generator | Looptake](https://looptake.com/use-cases/tiktok-slideshows): Make TikTok and Instagram slideshows from one sentence. Looptake writes the slides, picks images and music, and posts them on a schedule. Slideshows use no credits. - [Reddit Story Video Maker with AI Voice | Looptake](https://looptake.com/use-cases/reddit-story-videos): Turn a story into a narrated short with gameplay or satisfying background, word-timed captions and an AI voice. Looptake renders it 9:16 and posts it for you daily. - [Green Screen Meme Video Maker | Looptake](https://looptake.com/use-cases/green-screen-memes): Make green screen meme videos from a sentence: a presenter reacts in front of your screenshot, product or headline. Free to render, then posted to TikTok and Reels. - [Tier List and Ranking Video Maker | Looptake](https://looptake.com/use-cases/ranking-videos): Make tier list and top 5 ranking videos from one prompt. Looptake writes the takes, builds the ranks with visuals and voice, and posts to TikTok, Reels and Shorts. - [AI Explainer Video Generator for Shorts | Looptake](https://looptake.com/use-cases/explainer-videos): Explain any idea in under a minute. Describe the topic and Looptake writes the script, adds visuals, an AI presenter or text, voice and captions, then posts it. - [Blog to Video: Turn Articles into Shorts | Looptake](https://looptake.com/use-cases/repurpose-blog-to-shorts): Paste a blog post URL and get short videos for TikTok, Reels and Shorts: hook, script, visuals, voice and captions from your own article, then scheduled for you. - [Product Launch Videos for TikTok and Reels | Looptake](https://looptake.com/use-cases/product-launch-content): Plan a launch week of shorts in one sitting: teasers, a countdown, the reveal and follow-ups. Looptake makes every video and posts each one on the right day for you. - [Recruiting Videos for TikTok and Reels | Looptake](https://looptake.com/use-cases/recruiting-videos): Show candidates what the job is really like. Describe the role and Looptake makes short recruiting videos for TikTok, Reels and Shorts, then posts them on schedule. ## Comparisons Looptake against one named tool. Hub: https://looptake.com/compare - [Looptake vs Revid: prompt-to-video for short-form](https://looptake.com/compare/looptake-vs-revid): Looptake vs Revid: both turn a prompt into faceless shorts. See where they differ on viral score, auto-posting, agency tools and API access, and which fits you. - [Looptake vs OpusClip: AI clipping vs prompt-to-video](https://looptake.com/compare/looptake-vs-opusclip): Looptake vs OpusClip: OpusClip cuts long videos into clips. Looptake makes shorts from a sentence and also clips. Compare inputs, posting and agency tools. - [Looptake vs InVideo AI: built for shorts vs any length](https://looptake.com/compare/looptake-vs-invideo): Looptake vs InVideo AI: both make video from a prompt. Compare short-form focus, viral score, auto-posting, agency tools and API access to choose the right one. - [Looptake vs HeyGen: viral shorts vs AI avatars](https://looptake.com/compare/looptake-vs-heygen): Looptake vs HeyGen: HeyGen leads on avatars and translation. Looptake makes viral shorts from a sentence and posts them. See which fits your work. - [Looptake vs Captions: AI generation vs AI editing](https://looptake.com/compare/looptake-vs-captions): Looptake vs Captions: Captions edits footage you film. Looptake makes shorts from one sentence and posts them. Compare inputs, posting and team features here. - [Looptake vs Arcads: viral shorts vs AI UGC ads](https://looptake.com/compare/looptake-vs-arcads): Looptake vs Arcads: Arcads makes AI actor ads for paid testing. Looptake makes viral shorts from a sentence and posts them. Compare UGC, posting and agency tools. - [Looptake vs Creatify: viral shorts vs AI video ads](https://looptake.com/compare/looptake-vs-creatify): Looptake vs Creatify: Creatify makes product ad variants from a URL. Looptake makes viral shorts from a sentence and posts them. Compare features and fit here. - [Looptake vs Synthesia: viral shorts vs training video](https://looptake.com/compare/looptake-vs-synthesia): Looptake vs Synthesia: Synthesia makes avatar training videos for companies. Looptake makes viral shorts and posts them. See where each one is the right pick. - [Looptake vs Pictory: viral shorts vs repurposing](https://looptake.com/compare/looptake-vs-pictory): Looptake vs Pictory: Pictory turns articles and recordings into videos. Looptake makes viral shorts from a sentence and posts them. Compare inputs and features. - [Looptake vs CapCut: AI-made shorts vs hand editing](https://looptake.com/compare/looptake-vs-capcut): Looptake vs CapCut: CapCut is a full editor for your own footage. Looptake makes viral shorts from one sentence and posts them. See which one fits your workflow. - [Looptake vs Crayo: viral shorts vs faceless clips](https://looptake.com/compare/looptake-vs-crayo): Looptake vs Crayo: Crayo makes Reddit story, fake text and clip videos. Looptake makes many viral formats from a sentence and posts them. Compare features here. - [Looptake vs AutoShorts: faceless video autopilot](https://looptake.com/compare/looptake-vs-autoshorts): Looptake vs AutoShorts: both can keep a faceless channel posting. Compare inputs, formats, viral score, approvals and agency tools to see which one fits you. ## Alternatives Options when leaving a named tool. Hub: https://looptake.com/alternatives - [Revid alternatives for viral short-form video](https://looptake.com/alternatives/revid-alternatives): Looking past Revid? Compare prompt-to-video tools for TikTok, Reels and Shorts on viral formats, auto-posting, agency features and developer access. - [OpusClip alternatives: clip or create shorts](https://looptake.com/alternatives/opusclip-alternatives): OpusClip alternatives for when you need more than clips: prompt-to-video, faceless formats, AI avatars, auto-posting and agency approvals, compared briefly. - [InVideo AI alternatives for TikTok, Reels and Shorts](https://looptake.com/alternatives/invideo-alternatives): InVideo AI alternatives for short-form creators and teams: tools that write hooks, score virality, post to TikTok, Reels and Shorts, and run client approvals. - [HeyGen alternatives for short-form and AI avatars](https://looptake.com/alternatives/heygen-alternatives): HeyGen alternatives for creators and teams who need viral shorts, not just avatar videos: AI UGC, faceless formats, auto-posting and agency tools compared. - [Captions app alternatives for short-form video](https://looptake.com/alternatives/captions-alternatives): Captions alternatives for people who do not want to film: AI video generators, faceless formats, AI avatars and auto-posting for TikTok, Reels and Shorts. - [Arcads alternatives for AI UGC video](https://looptake.com/alternatives/arcads-alternatives): Arcads alternatives for AI UGC that also goes out organically: AI avatar tools, ad generators and viral short makers with auto-posting, compared in brief. - [Creatify alternatives for AI video ads and shorts](https://looptake.com/alternatives/creatify-alternatives): Creatify alternatives for teams that need organic reach as well as ads: AI UGC, avatar and viral short tools with scheduling and agency approvals, compared. - [Synthesia alternatives for marketing and social video](https://looptake.com/alternatives/synthesia-alternatives): Synthesia alternatives for teams that need social video, not training video: AI avatars, AI UGC and viral short generators for TikTok, Reels and Shorts. - [Pictory alternatives for AI short-form video](https://looptake.com/alternatives/pictory-alternatives): Pictory alternatives for creators who want shorts built to go viral: prompt-to-video, AI clipping, faceless formats and auto-posting tools compared briefly. - [CapCut alternatives: AI tools that make the video](https://looptake.com/alternatives/capcut-alternatives): CapCut alternatives for people who want less editing: AI video generators, clippers and faceless tools that make and post TikTok, Reels and Shorts for you. - [Crayo alternatives for faceless short-form video](https://looptake.com/alternatives/crayo-alternatives): Crayo alternatives for faceless channels and brands: AI video generators with more formats, AI avatars, viral scores and auto-posting to TikTok, Reels, Shorts. - [AutoShorts alternatives for faceless channels](https://looptake.com/alternatives/autoshorts-alternatives): AutoShorts alternatives for faceless TikTok and YouTube channels: AI video generators with more formats, viral scores, approvals and multi-brand workspaces. ## Best-of guides Ranked roundups with stated criteria. Hub: https://looptake.com/best - [Best AI Video Generators for Short-Form](https://looptake.com/best/ai-video-generators): The best AI video generators for TikTok, Reels and Shorts, ranked on what they make from one prompt, output quality, editing and what happens after the render. - [Best Faceless Video Generators for TikTok & Shorts](https://looptake.com/best/faceless-video-generators): The best faceless video generators for TikTok, Reels and YouTube Shorts, ranked on format range, hook quality, voice and captions, and whether they auto-post. - [Best TikTok Video Generators, Ranked](https://looptake.com/best/tiktok-video-generators): The best AI TikTok video generators, ranked on how close they get to a ready-to-post TikTok from little input, hook strength, captions and direct posting. - [Best AI UGC Video Generators, Ranked](https://looptake.com/best/ai-ugc-video-generators): The best AI UGC video generators, ranked on actor realism, script control, cost per usable video and what the tool does after the render: export, schedule or post. - [Best AI Avatar Video Generators, Ranked](https://looptake.com/best/ai-avatar-video-generators): The best AI avatar video generators, ranked on avatar realism, language range, custom avatars and how well the output works as a short-form social video. - [Best AI Clipping Tools for Shorts, Ranked](https://looptake.com/best/ai-clipping-tools): The best AI clipping tools for turning podcasts, streams and webinars into vertical shorts, ranked on moment picking, reframing, captions and direct posting. - [Best Short-Form Video Tools for Agencies](https://looptake.com/best/short-form-video-tools-for-agencies): The best short-form video tools for agencies, ranked on client workspaces, brand kits, approvals, white label and the volume of TikToks, Reels and Shorts they ship. - [Best TikTok Automation Tools, Ranked](https://looptake.com/best/tiktok-automation-tools): The best TikTok automation and auto-posting tools, ranked on whether they make the video too, posting through official APIs, review steps and analytics. ## Blog Hooks, formats and running a short-form channel. Hub: https://looptake.com/blog - [AI Script Writing for Short-Form Video](https://looptake.com/blog/ai-script-writing-for-short-form): How to brief an AI to write short-form video scripts: the five slots every script fills, word counts per duration, a seven-part brief and a six-pass edit. - [How to Go Viral on TikTok: What You Control](https://looptake.com/blog/how-to-go-viral-on-tiktok): How TikTok decides which posts spread, the five inputs you control, ranked, why more attempts beat perfect posts, and what to do in the 48 hours after a hit. - [How to Make an AI Influencer: Step-by-Step Guide](https://looptake.com/blog/how-to-make-ai-influencer): How to make an AI influencer: write a character spec, fix the image and voice, script the first posts, pick the account, follow disclosure rules, and test it. - [Short-Form Video Script Templates (6 With Timings)](https://looptake.com/blog/short-form-script-templates): Six short-form video script templates with second-by-second beats, a fill-in skeleton for each, the point where each one fails, and how to adapt them to any format. - [How to Add Captions to Short Videos (TikTok, Reels)](https://looptake.com/blog/how-to-add-captions-to-short-videos): Caption settings for TikTok, Reels and Shorts that people can read: safe-zone position, line length, reading speed, contrast, type size, and block or karaoke style. - [Repurpose One Video for TikTok, Reels and Shorts](https://looptake.com/blog/how-to-repurpose-one-video-for-tiktok-reels-and-shorts): Post one video to TikTok, Instagram Reels and YouTube Shorts the right way, clip long videos into shorts, and find posts hidden in content you already own. - [How to Warm Up a TikTok Account in 30 Days](https://looptake.com/blog/how-to-warm-up-tiktok-account): A day-by-day plan to warm up a new TikTok account: setup, first posts, cadence, what to measure at day 30, the myths to ignore, and when to switch on auto-posting. - [How to Write Video Hooks That Stop the Scroll](https://looptake.com/blog/how-to-write-video-hooks-that-stop-the-scroll): Why video hooks fail, five edit passes that fix most of them, twelve hook patterns for TikTok videos and slideshows, and how to test two openings fairly. - [Text-Based Video Formats for TikTok (Wall of Text)](https://looptake.com/blog/text-based-video-format-for-tiktok): Five text-based video formats for TikTok, including wall of text videos: what each is for, reading-speed timing, typography rules, and how to pick the right one. - [How to Make AI Product Videos for Ecommerce](https://looptake.com/blog/how-to-make-ai-product-videos-for-ecommerce): Make AI product videos for a store catalogue: inputs each SKU needs, five moments that earn a video, photo rules for clean renders, a quarter plan and attribution. - [How Often to Post on TikTok: A Decision Tree](https://looptake.com/blog/how-often-to-post-on-tiktok): How often to post on TikTok depends on account age, hook quality and what you can produce. Four situations, a clear answer for each, and what breaks at volume. - [AI Spokesperson Videos for Ads: A Practical Guide](https://looptake.com/blog/ai-spokesperson-videos-for-ads): When an AI spokesperson beats UGC in ads, how to design a reusable brand character, the five-line direct response script, the claims rules, and what to measure. - [Best Time to Post Instagram Reels (Test Your Own)](https://looptake.com/blog/best-time-to-post-instagram-reels): The best time to post Instagram Reels depends on your follower split. How Reels reach two audiences, why days beat hours, and a four-week test to find yours. - [How to Make Green Screen Meme Videos for TikTok](https://looptake.com/blog/how-to-make-green-screen-meme-videos): How to make green screen meme videos: what to put behind the presenter, three ways to build them, layout defaults, a three-beat script and a pre-publish checklist. - [How to start a faceless YouTube channel](https://looptake.com/blog/faceless-youtube-channel): Start and run a faceless YouTube channel: three decisions to make first, setup for search, the first 30 uploads, monetization rules, and when to stop. - [How to Make AI Avatar Videos: Step by Step](https://looptake.com/blog/how-to-make-ai-avatar-videos): A step-by-step process for AI avatar videos: script to a word count, pick a believable presenter, set the length, generate in the right language, and QC on a phone. - [Best Time to Post on TikTok: Find Your Own Slot](https://looptake.com/blog/best-time-to-post-on-tiktok): The best time to post on TikTok depends on your audience, not a generic chart. How timing affects reach, and a four-week slot test that gives a real answer. - [Best niches for faceless channels and how to score one](https://looptake.com/blog/best-niches-for-faceless-channels): Score a faceless niche on supply, refill, monetization, format fit and rule risk, then pick a series engine so episode 40 is as easy to make as episode 4. - [AI Video Generator for TikTok, Reels and Shorts](https://looptake.com/blog/ai-video-generator-for-tiktok): What an AI video generator for TikTok, Reels and Shorts must get right: 9:16 output, safe zones, captions, audio, review, scheduling and posting, plus a checklist. - [How to Automate Instagram Reels Posting](https://looptake.com/blog/how-to-automate-instagram-reels-posting): Automate Instagram Reels posting: the account you need, the two-step publish through Meta's API, what it rejects, and how to ramp a backlog safely. - [How to make money with faceless videos](https://looptake.com/blog/how-to-make-money-with-faceless-videos): Four ways faceless videos make money: sell your own product, affiliates, run channels for clients and platform payouts, plus the math to do before you pick. - [AI UGC Generator: How to Choose and Use One](https://looptake.com/blog/ai-ugc-generator): How to choose an AI UGC generator, test it in 30 minutes, and run AI UGC as a program: brand context, presenters, script shapes, faceless shots and variants. - [How to Post Consistently on Social Media](https://looptake.com/blog/how-to-post-consistently-on-social-media): Why a steady cadence beats chasing one viral hit, how to batch a month of posts in one sitting, and a simple content calendar that keeps you posting on bad weeks. - [Faceless Video Content Ideas: 8 Sources That Never Run Out](https://looptake.com/blog/faceless-video-content-ideas): Faceless video ideas grouped by where they come from: questions, objections, reviews, docs, searches and the calendar. Plus how to turn one source into a month. - [AI UGC Ads for TikTok and Meta: Script and Test](https://looptake.com/blog/ai-ugc-ads): How to script, build and test AI UGC ads for TikTok and Meta: the four-beat script, the claims rule, test structure, and the platform differences that matter. - [How to Schedule TikTok Posts (and How Far Ahead)](https://looptake.com/blog/how-to-schedule-tiktok-posts): Schedule TikTok posts with the web scheduler, an API tool or your own code. How far ahead to plan, what to keep manual, and why scheduled posts fail. - [How to Grow a Faceless Instagram Page With Reels](https://looptake.com/blog/how-to-grow-a-faceless-instagram-page): Grow a faceless Instagram page with one clear promise, a consistent Reels series, sends and saves over likes, a fixed schedule and reuse that is not reposting. - [How to make faceless TikTok videos, step by step](https://looptake.com/blog/how-to-make-faceless-videos-for-tiktok): Make faceless TikTok videos step by step: pick a format, write the hook first, keep text in safe zones, add sound and captions, then post daily without filming. - [TikTok Slideshow Guide: Specs, Structure and Ideas](https://looptake.com/blog/tiktok-slideshow): How to make a TikTok slideshow: how photo posts work, specs, slide structure, when to pick a slideshow over a video, idea sources and Instagram carousels. - [Faceless video content: the complete guide](https://looptake.com/blog/ultimate-guide-to-faceless-video-content): What faceless videos are made of, which format fits your business, how to run a brand-owned channel, and a first month plan that lasts past week six. ## Developer docs REST API, CLI and MCP. Full content is in llms-full.txt. Hub: https://looptake.com/developers - [Getting started with the Looptake API](https://looptake.com/developers/docs/getting-started): Create an API key, check it, generate video ideas and schedule one to TikTok. The full Looptake API loop in a few curl commands, plus the same steps from the CLI. - [Looptake API authentication and scopes](https://looptake.com/developers/docs/authentication): How Looptake API keys work: the lt_live_ prefix, the Bearer and X-API-Key headers, all 18 scopes, test keys, rotation, and the 401 and 403 errors you can get. - [Generations API: AI video ideas and decks](https://looptake.com/developers/docs/generations): Generate short-form video ideas with hooks, scripts and captions from your brand, read the deck behind each one, accept it, and create A/B hook variants by API. - [Posts and scheduling with the Looptake API](https://looptake.com/developers/docs/posts-and-scheduling): Create, preview, schedule and auto-post short-form videos to TikTok, Instagram Reels and YouTube Shorts by API. Content sources, targets, drafts and statuses. - [Automations API: a month of videos at once](https://looptake.com/developers/docs/automations): Create a posting cadence, generate every slot, review the batch, then launch and extend it. The automation status machine and the call that is legal at each step. - [AI avatars API: presenters and talking heads](https://looptake.com/developers/docs/influencers): Create a reusable AI avatar, render lip-synced talking-head videos from a script, and poll each job until it is ready. Failed renders refund their credits. - [Credits, quotas and plan limits](https://looptake.com/developers/docs/credits-and-limits): What spends Looptake credits and what is free, the cost of each AI media action, automatic refunds for failed renders, and how plan quotas differ from rate limits. - [Errors and rate limits in the Looptake API](https://looptake.com/developers/docs/errors-and-rate-limits): All 9 Looptake API error types with their status codes and the fix for each, the rate-limit groups, the headers on every response, and safe retry rules. - [Webhooks: events, signatures and retries](https://looptake.com/developers/docs/webhooks): Get Looptake events pushed to your HTTPS endpoint, signed with HMAC-SHA-256. Verify a signature in Python or Node, see what is retried, and read the delivery log. - [The Looptake CLI: npx looptake](https://looptake.com/developers/docs/cli): Run Looptake from a terminal with npx looptake: log in, generate, schedule and auto-post videos, and script it all with --json, --wait and stable exit codes. - [Looptake MCP server: hosted and local](https://looptake.com/developers/docs/mcp): Connect Claude, Cursor or any MCP client to Looptake. Use the hosted endpoint with a Bearer API key or run it locally with npx @looptake/mcp. Setup for each client. - [Looptake API conventions: envelope and ids](https://looptake.com/developers/docs/reference): The conventions behind every Looptake API endpoint: the response envelope, pagination, ids, timestamps, 202 and polling, and idempotency keys for safe retries. - [Full API reference](https://looptake.com/developers/reference): Every endpoint. ## Other pages - [Home](https://looptake.com) - [AI video generator](https://looptake.com/ai-video-generator): One sentence in, a viral short out. - [Viral video remix](https://looptake.com/viral-video-remix): Paste a hit. Get your version. - [Clip long videos](https://looptake.com/clip-long-videos): Podcasts and webinars into shorts. - [Pricing](https://looptake.com/pricing) - [How it works](https://looptake.com/go-viral): From prompt to posted video. - [Product demo](https://looptake.com/product-demo): See a video get made. - [AI avatars and influencers](https://looptake.com/ai-influencers): A consistent AI presenter for your brand. - [AI UGC](https://looptake.com/ai-ugc): Creator-style ads without creators. - [Automations](https://looptake.com/tiktok-automation): Videos made and posted on a cadence. - [Auto-post to TikTok](https://looptake.com/auto-post-to-tiktok): TikTok, Reels and Shorts on schedule. - [Auto-post to Instagram](https://looptake.com/auto-post-to-instagram) - [Auto-post to YouTube](https://looptake.com/auto-post-to-youtube) - [Integrations](https://looptake.com/integrations): Platforms, API, CLI and MCP. - [FAQ](https://looptake.com/faq): Answers before you sign up. - [Roadmap](https://looptake.com/roadmap) - [Changelog](https://looptake.com/changelog) - [For AI agents](https://looptake.com/agents): llms.txt and tool schemas. - [Free tools](https://looptake.com/resources): Hook analyzer, caption checker and more. - [Free AI UGC generator](https://looptake.com/free-ai-ugc-generator) - [Hook analyzer](https://looptake.com/hook-analyzer) - [Caption length checker](https://looptake.com/caption-length-checker) - [Stat video maker](https://looptake.com/stat-video-maker) - [TikTok to MP3](https://looptake.com/tiktok-to-mp3) - [Reel to MP3](https://looptake.com/reel-to-mp3) - [Shorts to MP3](https://looptake.com/shorts-to-mp3) - [Viral content principles](https://looptake.com/viral-content-principles) - [Terms](https://looptake.com/terms) - [Privacy](https://looptake.com/privacy)