Skip to the main content

REST API v1

87 endpoints in 15 groups. Base URL, auth, envelope and errors are shared, so the rest is parameters.

Base URL, auth and the envelope

Base URL

Every path is relative to https://looptake.com/api/v1. The version is in the path.

Authentication

Keys start with lt_live_ and carry scopes, checked first. See authentication and scopes.

Auth

curl "https://looptake.com/api/v1/me" -H "Authorization: Bearer lt_live_…"
curl "https://looptake.com/api/v1/me" -H "X-API-Key: lt_live_…"

Errors

9 types, one status each. See errors and rate limits.

Error body

{
  "success": false,
  "error": {
    "type": "forbidden_scope",
    "message": "This key does not carry posts:write."
  }
}
TypeStatus
invalid_input400
unauthorized401
forbidden_scope403
not_found404
conflict409
insufficient_credits402
usage_limit_reached429
rate_limited429
internal_error500

87 endpoints, 15 resources

Identity

What this key can do, which team it belongs to, and how many credits that team has left.

GET/me

Introspect the key.

  • No credits
  • Any scope
  • 600 per 300s

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.

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

Request

curl "https://looptake.com/api/v1/me" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • credits:read
  • 600 per 300s
  • Paginated

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.

  • 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

NameTypeRequiredDescription
typeenumOptionalFilters the ledger to one kind of movement. One of: recurring, topup, spending, trial, spin, reward, refund, carryover, carry_expired.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.

Request

curl "https://looptake.com/api/v1/credits?type=refund&limit=2" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:read
  • 600 per 300s

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.

  • Ordered live first, then the default, then oldest first. A key pinned to one workspace sees only that one, archived or not.

Request

curl "https://looptake.com/api/v1/workspaces" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

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.

  • The cap counts live workspaces only, so archiving one frees its slot and restoring one needs a free slot.

Body fields

NameTypeRequiredDescription
namestringRequiredWhat the brand is called. Shown everywhere the workspace is chosen.
websiteUrlstringOptionalThe site to read the brand context from. Queues a crawl when present. Format: uri.
logoUrlstringOptionalOverrides the logo found on the site. Format: uri.

Request

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

Response 201 (application/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.

  • No credits
  • workspaces:read
  • 600 per 300s

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.

Path parameters

NameTypeDescription
idstringThe workspace id.

Request

curl "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

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.

  • Changing `websiteUrl` leaves the brand context alone. Call POST /brand/analyze afterwards to read the new site.

Path parameters

NameTypeDescription
idstringThe workspace id.

Body fields

NameTypeRequiredDescription
namestringOptionalThe brand’s display name.
slugstringOptionalThe URL handle, unique within your team. Slugified before it is stored; a handle another workspace holds is `invalid_input`.
websiteUrlstringOptionalThe brand’s site. Send `null` to clear it. Format: uri.
logoUrlstringOptionalThe brand’s mark. Send `null` to clear it. Format: uri.
timezonestringOptionalIANA zone, for example `Europe/Lisbon`. Everything scheduled in this workspace is interpreted in it.

Request

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

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe workspace id.

Request

curl -X POST "https://looptake.com/api/v1/workspaces/d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f/archive" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe workspace id.

Request

curl -X POST "https://looptake.com/api/v1/workspaces/d0e2f3a4-5b6c-4d7e-8f90-1a2b3c4d5e6f/restore" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

Points every request that omits `workspaceId` at this workspace, clearing whatever held it. One default per team, and an archived workspace cannot hold it.

  • 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

NameTypeDescription
idstringThe workspace id.

Request

curl -X POST "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/default" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:read
  • 600 per 300s

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

Path parameters

NameTypeDescription
idstringThe workspace id.

Request

curl "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/brand-kit" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

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

  • A colour that is not `#rrggbb`, an unknown font or a logo from another workspace is `400 invalid_input`.

Path parameters

NameTypeDescription
idstringThe workspace id.

Body fields

NameTypeRequiredDescription
logoAssetIdstringOptionalAn image asset in this workspace. Used by the outro and the watermark. Format: id.
primaryColorstringOptionalBrand colour, `#rrggbb`. Fills the outro card.
secondaryColorstringOptionalSecond brand colour, `#rrggbb`.
textColorstringOptionalOutro text colour, `#rrggbb`.
fontstringOptional`display`, `body` or `mono`.
captionsobjectOptional`preset` (`karaoke` or `block`), `fill`, `stroke`, `highlight` (colours) and `position` (`top`, `middle`, `bottom`).
voiceIdstringOptionalThe voice used when a request names none.
outroobjectOptional`{ text, logo }`: a 2 second end card. `null` removes it.
watermarkbooleanOptionalDraw the logo (or the workspace name) in the corner.

Request

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

Response 200 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

Removes the kit, so later renders use the house look.

Path parameters

NameTypeDescription
idstringThe workspace id.

Request

curl -X DELETE "https://looptake.com/api/v1/workspaces/91e14cbf-2c80-4b3b-a6bf-776bbde6b54c/brand-kit" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • accounts:read
  • 600 per 300s

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.

Query parameters

NameTypeRequiredDescription
platformenumOptionalFilters to one platform. One of: tiktok, instagram, youtube.
ownerTypeenumOptionalBrand accounts or accounts that belong to an AI influencer. One of: brand, influencer.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/accounts?platform=tiktok" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • generations:write
  • 10 per 300s

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.

  • 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

NameTypeRequiredDescription
countinteger 1-10OptionalHow many suggestions to return. Default 3.
formatenumOptionalPins every suggestion to one format. Left out, the mix is chosen from the brand context. One of: walloftext, slideshow, greenscreen, talkinghead, ugc, characterswap, listicle, ranking, askmeanything, splitscreen, grid2x2, fadein, hookdemo, interview, productspokesperson, greenscreenmobile, clonevideo, statvideo.
promptstringOptionalA steer for this batch: a launch, a season, an objection to answer. With no brand profile, the prompt is the brief.
influencerIdstringOptionalWrites the scripts for one AI influencer, in that influencer’s voice. Format: id.
workspaceIdstringOptionalWhich brand to write for. Defaults to the key’s default workspace. Format: id.

Request

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

Response 201 (application/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.

  • 202, poll to finish
  • 3 credits per AI still
  • generations:write
  • 10 per 300s
  • Idempotency-Key

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.

  • 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

NameTypeRequiredDescription
promptstringRequiredThe video, in one sentence. Up to 600 characters.
lengthenumOptionalTarget length. Default 30s. One of: 15s, 30s, 60s.
formatenumOptionalLeft out or `auto`, the agent picks. One of: auto, walloftext, slideshow, greenscreen, listicle, ranking, splitscreen, askmeanything, statvideo, fadein.
languagestringOptionalLanguage of the script, voice and captions. Default en.
voicestringOptionalA voice id such as `nova`. Left out, no voice-over.
countinteger 1-4OptionalHow many videos to make from the best hooks. Default 1.
workspaceIdstringOptionalWhich workspace. Defaults to the key’s default workspace. Format: id.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 3 credits per AI still
  • generations:write
  • 10 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
urlstringRequiredThe video to learn from: a TikTok, YouTube or Instagram link, or any page with og tags. Format: uri.
topicstringOptionalWhat your video is about. Left out, the workspace brand is used; a workspace with no brand needs a topic.
lengthenumOptionalTarget length. Default 30s. One of: 15s, 30s, 60s.
formatenumOptionalLeft out or `auto`, the source structure picks. One of: auto, walloftext, slideshow, greenscreen, listicle, ranking, splitscreen, askmeanything, statvideo, fadein.
languagestringOptionalLanguage of the script, voice and captions. Default en.
voicestringOptionalA voice id such as `nova`. Left out, no voice-over.
countinteger 1-4OptionalHow many videos to make. Default 1.
workspaceIdstringOptionalWhich workspace. Defaults to the key’s default workspace. Format: id.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 1 credits per minute of source video
  • generations:write
  • 10 per 300s
  • Idempotency-Key

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

  • 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

NameTypeRequiredDescription
assetIdstringOptionalA video in the asset library. Send this or `mediaUrl`. Format: id.
mediaUrlstringOptionalA public URL of the video file, up to 200 MB. Send this or `assetId`. Format: uri.
countinteger 1-10OptionalHow many clips to cut. Default 3.
lengthenumOptionalThe longest a clip may run. Clips are at least 15 seconds. Default 60s. One of: 15s, 30s, 60s.
languagestringOptionalLanguage of the speech, titles and hooks. Default en.
workspaceIdstringOptionalWhich workspace. Defaults to the key’s default workspace. Format: id.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 3 credits per AI still
  • generations:write
  • 10 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
csvstringOptionalThe CSV text, with a header row. Send this or `rows`.
rowsobject[]OptionalRows as objects with the same keys as the CSV columns. Send this or `csv`.
timezonestringOptionalThe zone a `schedule_at` without an offset is read in. Default UTC. Format: timezone.
validateOnlybooleanOptionalCheck the rows and create nothing. Default false.
workspaceIdstringOptionalWhich workspace. Defaults to the key’s default workspace. Format: id.

Request

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

Response 202 (application/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.

  • No credits
  • generations:read
  • 600 per 300s

Counts by status, the average `progress`, `done` once every row is `ready` or `failed`, and each row’s status and outputs.

Path parameters

NameTypeDescription
idstringThe batch id from POST /videos/bulk.

Request

curl "https://looptake.com/api/v1/videos/bulk/2b4c6d8e-0f1a-4b3c-9d5e-7f8a9b0c1d2e" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • generations:read
  • 10 per 300s
  • Paginated

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.

Query parameters

NameTypeRequiredDescription
statusenumOptionalFilters by lifecycle status. One of: pending, accepted, scheduled, discarded.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/generations?status=pending&limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • generations:read
  • 10 per 300s

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

Path parameters

NameTypeDescription
idstringThe suggestion id.

Request

curl "https://looptake.com/api/v1/generations/6522d1ec-2c11-4d3a-b078-6f6cfe9f217b" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • generations:write
  • 10 per 300s

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

  • 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

NameTypeDescription
idstringThe suggestion id.

Request

curl -X POST "https://looptake.com/api/v1/generations/6522d1ec-2c11-4d3a-b078-6f6cfe9f217b/accept" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 201 (application/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.

  • No credits
  • generations:write
  • 10 per 300s

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.

Path parameters

NameTypeDescription
idstringThe suggestion to vary.

Body fields

NameTypeRequiredDescription
countinteger 2-5OptionalHow many variants to write. Default 3.

Request

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

Response 201 (application/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.

  • No credits
  • posts:write
  • 60 per 300s
  • Idempotency-Key

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.

  • `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

NameTypeRequiredDescription
suggestionIdstringOptionalSource 1. Accepts the suggestion and posts it in one call. Format: id.
contentIdstringOptionalSource 2. An already-accepted content piece. Format: id.
deckobjectOptionalSource 3. A render blueprint. Requires `format`, and returns 202.
formatenumOptionalRequired with `deck`, ignored otherwise. One of: walloftext, slideshow, greenscreen, talkinghead, ugc, characterswap, listicle, ranking, askmeanything, splitscreen, grid2x2, fadein, hookdemo, interview, productspokesperson, greenscreenmobile, clonevideo, statvideo.
videoUrlstringOptionalSource 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. Format: uri.
imagesstring[]OptionalSource 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.
thumbnailUrlstringOptionalCover frame. Only read alongside `videoUrl`. Format: uri.
selectedAccountsobjectRequiredPlatform to account ids, for example {"tiktok":["ff9d429b-07c5-4320-a98c-ac6e1791b828"]}. At least one list must be non-empty.
scheduleenumOptionalWhen 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. Default asap. One of: asap, scheduled, draft.
scheduledTimestringOptionalISO 8601, in the future. Required when `schedule` is `scheduled`, and optional when it is `draft`. Format: date-time.
timezonestringOptionalIANA zone the schedule is read in, for example Europe/Lisbon. Default UTC. Format: timezone.
captionstringOptionalOverrides 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.
hashtagsstring[]OptionalAppended to the caption, per platform convention.
perPlatformobjectOptionalThe 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.
ownerTypeenumOptionalWhose accounts this publishes from. Default brand. One of: brand, influencer.
influencerIdstringOptionalRequired when `ownerType` is `influencer`. Format: id.
publishModeenumOptionalHow 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. Default publish. One of: publish, draft, inbox.
tiktokSendToInboxbooleanOptionalThe 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. Default false.
tiktokOptionsobjectOptionalTikTok 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.
declarationsobjectOptionalStatements 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.
workspaceIdstringOptionalWhich brand this post belongs to. Format: id.

Request

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

Response 201 (application/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.

  • No credits
  • posts:write
  • 60 per 300s

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.

  • `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

NameTypeRequiredDescription
selectedAccountsobjectRequiredPlatform to account ids, exactly as on `POST /posts`. One preview target comes back per account.
contentIdstringOptionalA 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. Format: id.
captionstringOptionalThe caption body. Previewed exactly as it would be stored: trimmed, and empty when you send none, because nothing fills it in from the source.
hashtagsstring[]OptionalAppended to the caption in `bodyText`, each normalised to a single leading #.
perPlatformobjectOptionalExactly 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.
publishModeenumOptionalHow far to take the publish. A mode a named platform cannot honour is a 400 here, exactly as on `POST /posts`. Default publish. One of: publish, draft, inbox.
tiktokSendToInboxbooleanOptionalRoute 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. Default false.
tiktokOptionsobjectOptionalThe same object `POST /posts` takes, including the permissive spellings. What comes back is the value the publisher would send, not the value you wrote.
declarationsobjectOptionalThe 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.
workspaceIdstringOptionalWhich brand the accounts belong to. Format: id.

Request

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

Response 200 (application/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.

  • No credits
  • posts:write
  • 60 per 300s
  • Idempotency-Key

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.

  • 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

NameTypeRequiredDescription
csvstringRequiredThe 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.
selectedAccountsobjectRequiredPlatform 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.
scheduleenumOptionalWhether 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`. Default draft. One of: draft, scheduled.
timezonestringOptionalThe 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. Format: timezone.
declarationsobjectOptionalThe 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.
validateOnlybooleanOptionalRead 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. Default false.
workspaceIdstringOptionalWhich brand these posts belong to. Defaults to the key’s default workspace. Format: id.

Request

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

Response 200 (application/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.

  • No credits
  • posts:read
  • 60 per 300s
  • Paginated

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.

  • `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

NameTypeRequiredDescription
statusenumOptionalFilters 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. One of: draft, queued, scheduled, publishing, posted, partial, failed, cancelled.
approvalenumOptionalThe 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. One of: pending, rejected, none.
automationIdstringOptionalOnly posts produced by one automation. Format: id.
influencerIdstringOptionalOnly 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. Format: id.
fromstringOptionalInclusive lower bound on the scheduled time. Format: date-time.
tostringOptionalExclusive upper bound on the scheduled time. Format: date-time.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/posts?approval=pending&limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • posts:read
  • 60 per 300s

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.

  • 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

NameTypeDescription
idstringThe post id.

Request

curl "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • posts:write
  • 60 per 300s

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.

  • 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

NameTypeDescription
idstringThe post id.

Body fields

NameTypeRequiredDescription
captionstringOptionalThe 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.
hashtagsstring[]OptionalThe 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.
perPlatformobjectOptionalThe 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.
declarationsobjectOptionalThe 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.

Request

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

Response 200 (application/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.

  • No credits
  • posts:write
  • 60 per 300s

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.

  • Any other status returns 409 `conflict`. The content piece is untouched and can be scheduled again.

Path parameters

NameTypeDescription
idstringThe post id.

Request

curl -X DELETE "https://looptake.com/api/v1/posts/973821cf-5cc7-44f7-8795-e68da31c59ef" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • posts:approve
  • 60 per 300s

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.

  • 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

NameTypeDescription
idstringThe post id.

Body fields

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

Request

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

Response 200 (application/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.

  • No credits
  • posts:approve
  • 60 per 300s

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.

  • 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

NameTypeDescription
idstringThe post id.

Body fields

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

Request

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

Response 200 (application/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.

  • No credits
  • posts:write
  • 60 per 300s

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.

  • 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

NameTypeDescription
idstringThe post id.

Body fields

NameTypeRequiredDescription
scheduledTimestringRequiredThe 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. Format: date-time.
timezonestringOptionalThe 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.

Request

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

Response 200 (application/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.

  • No credits
  • posts:write
  • 60 per 300s

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.

  • `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

NameTypeDescription
idstringThe post id.

Request

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

Response 200 (application/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.

  • No credits
  • posts:read
  • 60 per 300s

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.

  • `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

NameTypeRequiredDescription
fromstringRequiredFirst day, inclusive. ISO date. Format: date.
tostringRequiredLast day, inclusive. ISO date. Format: date.
statusenumOptionalFilters the posts inside each day. One of: draft, queued, scheduled, publishing, posted, partial, failed, cancelled.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/calendar?from=2026-09-01&to=2026-09-02" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:write
  • 600 per 300s

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.

Body fields

NameTypeRequiredDescription
namestringRequiredWhat this run is called in the app and in the ledger.
cadenceobjectOptionalpostsPerDay, days, startDate, times[] and timezone. Defaults to one post a day for seven days from tomorrow.
selectedAccountsobjectOptionalPlatform to account ids, the same shape POST /posts takes.
settingsobjectOptionalFormat mix, prompt and caption preferences applied to every post in the run.
ownerTypeenumOptionalWhose accounts the run publishes from. Default brand. One of: brand, influencer.
influencerIdstringOptionalRequired when `ownerType` is `influencer`. Format: id.
tiktokModeenumOptionalPublish 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. Default direct. One of: direct, inbox.
tiktokOptionsobjectOptionalPer-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.
declarationsobjectOptionalThe 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.
workspaceIdstringOptionalWhich brand the run belongs to. Defaults to the key’s default workspace. Format: id.

Request

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

Response 201 (application/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.

  • No credits
  • automations:read
  • 600 per 300s
  • Paginated

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.

Query parameters

NameTypeRequiredDescription
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/automations?limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:read
  • 600 per 300s

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

Path parameters

NameTypeDescription
idstringThe automation id.

Request

curl "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:write
  • 600 per 300s

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.

  • Any status other than `draft` returns 409 `conflict`.

Path parameters

NameTypeDescription
idstringThe automation id.

Body fields

NameTypeRequiredDescription
namestringOptionalRename the run.
cadenceobjectOptionalReplaces the cadence object wholesale.
selectedAccountsobjectOptionalReplaces the target set.
settingsobjectOptionalReplaces the settings object.
tiktokModeenumOptionalSwitches the run between publishing and drafts. One of: direct, inbox.
tiktokOptionsobjectOptionalReplaces the TikTok options object.
declarationsobjectOptionalReplaces 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.

Request

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

Response 200 (application/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.

  • 202, poll to finish
  • No credits
  • automations:write
  • 600 per 300s

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

  • Terminal statuses for this poll: `review`, or `cancelled` if the run was cancelled mid-generation.

Path parameters

NameTypeDescription
idstringThe automation id.

Request

curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/generate" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 202 (application/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.

  • 202, poll to finish
  • No credits
  • automations:write
  • 600 per 300s

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.

Path parameters

NameTypeDescription
idstringThe automation id.

Body fields

NameTypeRequiredDescription
daysintegerRequiredHow many days to add after the current last day.

Request

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

Response 202 (application/json)

{
  "success": true,
  "data": {
    "id": "4ecde50a-7af3-43b9-b66e-a36422c7e51f",
    "status": "generating",
    "days": 44,
    "addedSlots": 14
  }
}

POST/automations/{id}/launch

Launch a reviewed automation.

  • No credits
  • automations:write
  • 600 per 300s
  • Idempotency-Key

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.

  • 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

NameTypeDescription
idstringThe automation id.

Request

curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/launch" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe automation id.

Request

curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/pause" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe automation id.

Request

curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/resume" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:write
  • 600 per 300s

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.

Path parameters

NameTypeDescription
idstringThe automation id.

Request

curl -X POST "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/cancel" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • automations:read
  • 600 per 300s
  • Paginated

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.

Path parameters

NameTypeDescription
idstringThe automation id.

Query parameters

NameTypeRequiredDescription
statusenumOptionalFilters 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. One of: draft, queued, scheduled, publishing, posted, partial, failed, cancelled.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.

Request

curl "https://looptake.com/api/v1/automations/4ecde50a-7af3-43b9-b66e-a36422c7e51f/posts?limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • influencers:read
  • 600 per 300s
  • Paginated

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.

Query parameters

NameTypeRequiredDescription
searchstringOptionalMatches name, description and tags.
nichestringOptionalFilters to one niche.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/influencers?niche=retail&limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • 10 credits per influencer
  • influencers:write
  • 600 per 300s
  • Idempotency-Key

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.

  • 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

NameTypeRequiredDescription
namestringRequiredWhat the character is called.
imageUrlstringRequiredA reference portrait. The face every later render is built from. Format: uri.
subjectenumOptionalWhat 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. One of: synthetic, real_person.
consentobjectOptionalRequired 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.
descriptionstringOptionalWho they are. Feeds the scripts written in their voice.
nichestringOptionalThe subject they speak about.
genderstringOptionalPresented gender, used for voice selection.
ageintegerOptionalApparent age in years.
ethnicitystringOptionalApparent ethnicity, used for character consistency across renders.
tagsstring[]OptionalYour own labels, searchable through `search`.
workspaceIdstringOptionalWhich brand the character belongs to. Defaults to the key’s default workspace. Format: id.

Request

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

Response 201 (application/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.

  • No credits
  • influencers:write
  • 600 per 300s

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.

  • 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

NameTypeRequiredDescription
kindenumRequiredWhat 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. One of: written_release, recorded_statement.
filenamestringRequiredA plain name with its extension, using letters, digits, `.`, `_` and `-` only. The extension has to match `kind`.
bytesinteger 1-33554432RequiredThe 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.
workspaceIdstringOptionalWhich 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. Format: id.

Request

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

Response 201 (application/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.

  • No credits
  • influencers:write
  • 600 per 300s

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.

  • `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

NameTypeDescription
idstringThe influencer id.

Body fields

NameTypeRequiredDescription
actionenumRequiredWhat 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. One of: attest, withdraw, refuse.
statementstringRequiredWhat 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.
subjectNamestringOptionalWho the subject is. Up to 120 characters.
artefactobjectOptionalRequired 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.

Request

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

Response 200 (application/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.

  • No credits
  • influencers:read
  • 600 per 300s

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.

  • `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

NameTypeDescription
idstringThe influencer id.

Request

curl "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • influencers:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe influencer id.

Request

curl -X DELETE "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • 202, poll to finish
  • 5 credits per second of video
  • influencers:write
  • 600 per 300s
  • Idempotency-Key

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.

  • Terminal statuses: `ready` and `failed`. A failure refunds the reserved credits automatically.
  • Honours `Idempotency-Key`, because the call spends credits.

Path parameters

NameTypeDescription
idstringThe influencer id.

Body fields

NameTypeRequiredDescription
scriptstringRequiredWhat they say, word for word. Paced to `duration` rather than cut to it.
imageUrlstringOptionalOverrides the influencer’s portrait for this one render. Format: uri.
languageenumOptionalBCP 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. 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.
durationintegerOptionalTarget length in seconds. Drives the credit cost at 5 per second.
modestringOptionalRender mode to produce the video in. Defaults to the influencer’s usual mode.

Request

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

Response 202 (application/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.

  • No credits
  • influencers:read
  • 600 per 300s
  • Paginated

Every video rendered for this character, newest first, with its status and what it cost.

Path parameters

NameTypeDescription
idstringThe influencer id.

Query parameters

NameTypeRequiredDescription
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.

Request

curl "https://looptake.com/api/v1/influencers/d076f17e-4529-48a3-b5fe-860bc329c5b4/videos?limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • influencers:read
  • 600 per 300s

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

Path parameters

NameTypeDescription
idstringThe video id.

Request

curl "https://looptake.com/api/v1/videos/08363fcd-f524-4126-b68d-fd64f2f9c09e" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • assets:read
  • 600 per 300s
  • Paginated

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

Query parameters

NameTypeRequiredDescription
kindenumOptionalFilters by media kind. One of: image, video, audio, font.
categorystringOptionalYour own grouping, for example `b-roll` or `product`.
turboEnabledbooleanOptionalOnly assets you have offered to the Turbo formats.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/assets?kind=video&turboEnabled=true&limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • assets:write
  • 600 per 300s

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.

Body fields

NameTypeRequiredDescription
urlstringRequiredWhere to fetch the file from. Must be publicly reachable at the time of the call. Format: uri.
kindenumOptionalDeclared kind. Detected from the response when omitted. One of: image, video, audio, font.
namestringOptionalLabel in the library. Defaults to the filename.
categorystringOptionalYour own grouping.
turboEnabledbooleanOptionalOffer 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}`.

Request

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

Response 201 (application/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.

  • No credits
  • assets:write
  • 600 per 300s

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.

Path parameters

NameTypeDescription
idstringThe asset id.

Body fields

NameTypeRequiredDescription
namestringOptionalThe label shown in the library.
categorystringOptionalYour own grouping. Send `null` to clear it.
turboEnabledbooleanOptionalWhether this file is offered to the Turbo formats.

Request

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

Response 200 (application/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.

  • No credits
  • assets:write
  • 600 per 300s

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.

  • This is the one call in this API that removes stored bytes. Everything else keeps what it has written.

Path parameters

NameTypeDescription
idstringThe asset id.

Request

curl -X DELETE "https://looptake.com/api/v1/assets/a7273471-d932-4f9a-958e-79abdd1f3fd7" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • content:write
  • 600 per 300s

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.

Path parameters

NameTypeDescription
idstringThe content id.

Request

curl "https://looptake.com/api/v1/content/c02119cb-ed7a-40ed-87fe-c18edc1fa1a2" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • content:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe content id.

Request

curl "https://looptake.com/api/v1/content/c02119cb-ed7a-40ed-87fe-c18edc1fa1a2/download" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
videoUrlstringRequiredThe source video whose motion and timing are kept. Format: uri.
characterImageUrlstringRequiredThe face that replaces the one in the source. Format: uri.
scenestringOptionalDescribes the setting to place the new character in.
keepSourceAudiobooleanOptionalKeeps the original audio track instead of generating a new voice. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
appScreenshotstringRequiredThe screen behind the presenter. Format: uri.
presenterImagestringRequiredThe person in front of it. Format: uri.
scriptstringRequiredWhat the presenter says while the screen is on show.
durationintegerOptionalTarget length in seconds. The script is paced to fit rather than truncated.
languageenumOptionalBCP 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. 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.
captionOverlaybooleanOptionalBurns word-level captions into the video. Leave it on: most short-form is watched muted. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
imageUrlstringRequiredThe character to render. Format: uri.
videoUrlstringRequiredThe video whose motion is cloned. Format: uri.
videoDurationintegerOptionalTrims the clone to this many seconds from the start.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
scriptstringRequiredWhat they say, word for word.
avatarImageUrlstringOptionalA specific face to use. Omit and the presenter is built from the appearance fields below, or from your brand context when those are empty. Format: uri.
durationintegerOptionalTarget length in seconds. The script is paced to fit rather than truncated.
languageenumOptionalBCP 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. 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.
modestringOptionalRender mode to produce the video in.
genderstringOptionalPresented gender of the character to choose.
ageintegerOptionalApparent age of the character to choose.
ethnicitystringOptionalApparent ethnicity of the character to choose.
appearancestringOptionalFree text: clothing, setting, framing.
referenceImagesstring[]OptionalExtra references that keep the character consistent between renders.
captionOverlaybooleanOptionalBurns word-level captions into the video. Leave it on: most short-form is watched muted. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
scriptstringRequiredWhat the presenter says over the demo.
avatarImageUrlstringRequiredThe presenter’s face. Format: uri.
demoVideoUrlstringRequiredThe footage behind them. Format: uri.
demoThumbstringOptionalCover frame for the demo layer. Format: uri.
avatarPositionstringOptionalWhere the presenter sits in the frame, for example `bottom-left`.
durationintegerOptionalTarget length in seconds. The script is paced to fit rather than truncated.
languageenumOptionalBCP 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. 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.
captionOverlaybooleanOptionalBurns word-level captions into the video. Leave it on: most short-form is watched muted. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
scriptstringRequiredThe exchange, as it should be spoken.
avatarImageUrlstringOptionalThe interviewee’s face. Format: uri.
durationintegerOptionalTarget length in seconds. The script is paced to fit rather than truncated.
languageenumOptionalBCP 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. 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.
genderstringOptionalPresented gender of the character to choose.
ageintegerOptionalApparent age of the character to choose.
ethnicitystringOptionalApparent ethnicity of the character to choose.
appearancestringOptionalFree text: clothing, setting, framing.
captionOverlaybooleanOptionalBurns word-level captions into the video. Leave it on: most short-form is watched muted. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
scriptstringRequiredWhat the spokesperson says.
spokespersonImagestringOptionalThe presenter’s face. Format: uri.
avatarImagestringOptionalAn alternative character reference. Format: uri.
productImagestringOptionalOne photograph of the product, ideally on a plain ground. Format: uri.
instructionstringOptionalWhat they do with the product: hold it, open it, point at a detail.
durationintegerOptionalTarget length in seconds. The script is paced to fit rather than truncated.
languageenumOptionalBCP 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. 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.
captionOverlaybooleanOptionalBurns word-level captions into the video. Leave it on: most short-form is watched muted. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

A 2x2 grid does the work of four posts in one, and the retention curve tells you which quarter people actually watched.

Body fields

NameTypeRequiredDescription
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
portraitbooleanOptionalRenders 9:16. Turn it off for a square grid. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
layoutstringOptionalHow the list is arranged on the frame, for example `stacked` or `card`.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

Turns the objection you hear on every sales call into a video that handles it before it is asked.

Body fields

NameTypeRequiredDescription
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
captionstringOptionalOn-screen text over the demo half.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

The quietest format in the set, and the one that works when the product is the whole argument.

Body fields

NameTypeRequiredDescription
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
imageUrlstringOptionalThe single image. Generated from the brand context when omitted. Format: uri.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • 202, poll to finish
  • 10 credits per render
  • content:write
  • 30 per 300s
  • Idempotency-Key

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.

Body fields

NameTypeRequiredDescription
hookVideoUrlstringRequiredThe opening clip. Three seconds is usually enough. Format: uri.
demoVideoUrlstringRequiredYour real product footage, played after the hook. Format: uri.
captionstringOptionalOn-screen text over the hook.
promptstringOptionalSteers the script. Left out, the brand context decides what the piece is about.
hookThumbstringOptionalCover frame for the hook layer. Format: uri.
mentionBusinessbooleanOptionalNames the brand in the script. Turn it off for a piece meant to read as editorial. Default true.
influencerIdstringOptionalAttributes the render to an AI influencer, so it can be published from that influencer’s accounts. Format: id.
namestringOptionalLabel for the piece in your library. Defaults to the format name and the creation time.

Request

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

Response 202 (application/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.

  • No credits
  • analytics:read
  • 600 per 300s

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

  • `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

NameTypeRequiredDescription
windowenumOptionalHow far back to read. Mutually exclusive with `from`/`to`. Default 28d. One of: 7d, 28d, 90d.
fromstringOptionalFirst day of an arbitrary range, inclusive. Send with `to`, and without `window`. Format: date.
tostringOptionalLast day of an arbitrary range, inclusive. May not be after today. Format: date.
platformenumOptionalRestricts the figures to one platform. One of: tiktok, instagram, youtube.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/analytics/overview?from=2026-01-01&to=2026-03-31" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • analytics:read
  • 600 per 300s
  • Paginated

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

Query parameters

NameTypeRequiredDescription
sortenumOptionalColumn to order by. Default views. One of: views, likes, comments, shares, saves, clicks, publishedAt.
orderenumOptionalSort direction. Default desc. One of: asc, desc.
fromstringOptionalInclusive lower bound on the publish date. Format: date.
tostringOptionalInclusive upper bound on the publish date. Format: date.
platformenumOptionalRestricts to one platform. One of: tiktok, instagram, youtube.
pageintegerOptionalOne-based page number. Default 1.
limitinteger 1-100OptionalItems per page. Default 20.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/analytics/posts?sort=views&order=desc&limit=1" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • analytics:read
  • 600 per 300s

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.

  • 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

NameTypeRequiredDescription
sortenumOptionalColumn to order by. Default views. One of: views, likes, comments, shares, saves, clicks, publishedAt.
orderenumOptionalSort direction. Default desc. One of: asc, desc.
fromstringOptionalInclusive lower bound on the publish date. Format: date.
tostringOptionalInclusive upper bound on the publish date. Format: date.
platformenumOptionalRestricts to one platform. One of: tiktok, instagram, youtube.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

curl "https://looptake.com/api/v1/analytics/export?from=2026-08-01&to=2026-08-31" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (text/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.

  • No credits
  • analytics:read
  • 600 per 300s

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.

  • 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

NameTypeRequiredDescription
recipientLabelstringRequiredWho the link is for. Recorded at mint time; it is the only record of who received the numbers.
windowenumOptionalHow far back the frozen period reaches. Mutually exclusive with `from`/`to`. Default 28d. One of: 7d, 28d, 90d.
fromstringOptionalFirst day of an arbitrary frozen period, inclusive. Send with `to`, and without `window`. Format: date.
tostringOptionalLast day of an arbitrary frozen period, inclusive. May not be after today. Format: date.
expiresInDaysinteger 1-30OptionalHow long the link lives. There is no “never”. Default 7.
workspaceIdstringOptionalScopes the request to one workspace. Defaults to the key’s default workspace. Format: id.

Request

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

Response 201 (application/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.

  • No credits
  • analytics:read
  • 600 per 300s

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.

  • An unknown id, or one belonging to another team, is `404 not_found`.

Path parameters

NameTypeDescription
idstringThe report id returned when the link was minted.

Request

curl -X DELETE "https://looptake.com/api/v1/analytics/reports/b1f0c2d4-8f3a-4a2e-9c17-6b0d5e8a4412" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/json)

{
  "success": true,
  "data": {
    "id": "b1f0c2d4-8f3a-4a2e-9c17-6b0d5e8a4412",
    "revokedAt": "2026-09-03T11:20:04Z"
  }
}

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.

  • No credits
  • webhooks:write
  • 600 per 300s

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.

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

Request

curl "https://looptake.com/api/v1/webhooks" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • No credits
  • webhooks:write
  • 600 per 300s

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=<unix>,v1=<hex>`, an HMAC-SHA-256 over `<t>.<raw body>`. 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.

  • 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

NameTypeRequiredDescription
urlstringRequiredWhere deliveries are posted. Must be HTTPS. Format: uri.
eventsenumRequiredWhich events to receive. At least one. One of: post.published, post.failed, content.ready, credits.low.
workspaceIdstringOptionalRestricts deliveries to one workspace. Format: id.

Request

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

Response 201 (application/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.

  • No credits
  • webhooks:write
  • 600 per 300s

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.

  • 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

NameTypeDescription
idstringThe webhook id.

Body fields

NameTypeRequiredDescription
urlstringOptionalWhere deliveries are posted. Must be HTTPS. Format: uri.
eventsenumOptionalThe complete new event list. At least one; replaces what is there. One of: post.published, post.failed, content.ready, credits.low.
activebooleanOptionalWhether to deliver to this endpoint. `true` also clears a disablement and its failure count.

Request

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

Response 200 (application/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.

  • No credits
  • webhooks:write
  • 600 per 300s

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.

Path parameters

NameTypeDescription
idstringThe webhook id.

Request

curl -X DELETE "https://looptake.com/api/v1/webhooks/ae7ede5e-9f5a-42a3-8901-40b74b2130af" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/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.

  • 202, poll to finish
  • No credits
  • workspaces:write
  • 600 per 300s

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.

  • Poll GET /workspaces/{id} until `brandContext.refreshedAt` moves past the time of this call.

Body fields

NameTypeRequiredDescription
urlstringOptionalThe site to read. Defaults to the workspace’s own `websiteUrl`. Format: uri.
workspaceIdstringOptionalWhich workspace to rebuild. Defaults to the key’s default workspace. Format: id.

Request

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

Response 202 (application/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.

  • No credits
  • workspaces:write
  • 600 per 300s

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.

  • Nothing else is removed: the workspace, its accounts, its posts and everything it has published are untouched.

Query parameters

NameTypeRequiredDescription
workspaceIdstringOptionalWhich workspace to clear. Defaults to the key’s default workspace. Format: id.

Request

curl -X DELETE "https://looptake.com/api/v1/brand?workspaceId=91e14cbf-2c80-4b3b-a6bf-776bbde6b54c" \
  -H "Authorization: Bearer $LOOPTAKE_API_KEY"

Response 200 (application/json)

{
  "success": true,
  "data": {
    "workspaceId": "91e14cbf-2c80-4b3b-a6bf-776bbde6b54c",
    "deleted": true
  }
}

Guides

  • Getting startedFrom API key to a scheduled short-form video in five requests.
  • Authentication and scopesKeys belong to a team and carry a fixed set of scopes.
  • GenerationsA generation is a video idea: hook, caption, hashtags, hook score and, for template formats, the deck that renders it.
  • Posts and schedulingOne endpoint creates a post for any mix of connected accounts and reports one result per account.
  • AutomationsAn automation is a cadence plus target accounts. It generates a whole run and publishes nothing until you launch it.
  • AI avatars and influencersCreate a presenter once and render talking-head videos from it. Renders are asynchronous and priced per second.
  • Credits and plan limitsCredits pay for AI media only. Scripts, editing, scheduling and posting are free.
  • Errors and rate limitsEvery failure is one of 9 types. Branch on the type, never on the message.
  • WebhooksSigned events sent to a URL you own. Delivery is at least once.
  • The Looptake CLIOne npm package, one binary, a command for every endpoint.
  • MCP serverThe Looptake API as MCP tools. Hosted over HTTP or local over stdio, with the same scopes as your key.
  • Reference conventionsRules that hold for every endpoint. Learn them once, then read the reference as tables.

Developer overviewFor AI agentsPricing

Describe it. Looptake writes, renders, scores and posts it to TikTok, Reels and Shorts.

Free trial, no card. Paid plans from $29/month.