API docs
Read what you make in Programmable, start animations from a prompt, render videos, and hear about it all with signed webhooks. Every request goes to https://programmable.digital/api/integrations/v1.
The machine-readable description is at /api/integrations/v1/openapi.json (OpenAPI 3.1). Components from the marketplace install with npx programmable-cli add <component>.
Authentication
Create a key in the studio under Developers › API keys and choose what it may do. Send it with every request:
curl https://programmable.digital/api/integrations/v1/me \ -H "Authorization: Bearer pgm_…"
A key is shown once, when it is created. Revoke it any time; it stops working at once. A request without the scope an endpoint needs is answered with 403.
Scopes
components:read | List published animations and their embeds (Shopify, WordPress, Webflow). |
animations:read | List and read your animations, with share, embed and poster links. |
decks:read | List and read your decks, with share links. |
graphics:read | List and read your graphics. |
logos:read | List and read your logos. |
leads:read | List the emails people left on your sign-up cards. |
animations:write | Start new animations from a prompt. Uses your credits like the studio.Starts work; credits are used exactly as in the studio. |
animations:share | Make an animation's public share link, or turn it off.Makes things public; sharing is free and needs no credits. |
exports:write | Render MP4 or GIF exports of your animations.Starts work; credits are used exactly as in the studio. |
Rate limits
300 requests a minute from one address and 120 a minute per key. Over the limit, the answer is 429 with Retry-After: 60. Starting animations and renders has its own, lower limits, as in the studio.
Errors
Errors are JSON: { "error": { "code": "not_found", "message": "No such animation." } }. Lists take limit (1 to 100) and offset, and answer with data, total and next_offset.
Endpoints
- GET
/meCheck a key - GET
/componentsList published components, newest first - GET
/components/{token}One published component of this account - GET
/poster/{token}Poster frame (public, no key) - GET
/marketplaceThe component marketplace catalog, with this month's download allowance - GET
/marketplace/{slug}Download a marketplace component's source (counts toward the monthly allowance) - GET
/animationsList your animations - POST
/animationsStart an animation from a prompt - GET
/animations/{id}Get one of your animations - POST
/animations/{id}/sharePublish or turn off an animation's share link - GET
/decksList your decks - GET
/decks/{id}Get one of your decks - GET
/graphicsList your graphics - GET
/graphics/{id}Get one of your graphics - GET
/logosList your logos - GET
/logos/{id}Get one of your logos - GET
/jobs/{id}Get a generation job - GET
/leadsList leads - POST
/exportsRender an MP4 or GIF - GET
/exports/{id}Get an export - GET
/exports/{id}/downloadDownload a finished export - GET
/openapi.jsonThis document
GET /me
Check a key.
Scope: components:read
Answers 200: version, key.
GET /components
List published components, newest first.
Scope: components:read
q query | stringCase-insensitive title filter. |
limit query | integer |
offset query | integer |
Answers 200: version, components, total, nextOffset.
GET /components/{token}
One published component of this account.
Scope: components:read
token path | string |
Answers 200: version, component.
GET /poster/{token}
Poster frame (public, no key).
Scope: components:read
No key needed.
token path | string |
v query | integerVersion pin from posterUrl (longer cache). |
Answers 200.
GET /marketplace
The component marketplace catalog, with this month's download allowance.
q query | stringCase-insensitive name/slug/description filter. |
category query | stringOne of the marketplace categories (charts, dashboards, pricing, product-demos, ...). |
Answers 200: version, components, total, allowance.
GET /marketplace/{slug}
Download a marketplace component's source (counts toward the monthly allowance).
slug path | string |
format query | "json" | "tsx"json: metadata plus the file in `source`; tsx: the .tsx file itself as text/plain. |
Answers 200: any.
GET /animations
List your animations.
Scope: animations:read
limit query | integer |
offset query | integer |
q query | stringCase-insensitive title filter. |
Answers 200: object, data, total, next_offset.
POST /animations
Start an animation from a prompt. Starts a new animation the way the studio's chat does: research, storyboard, animate and review. It runs for several minutes; poll GET /jobs/{id} or open it in the studio. Credits are charged as in the studio and refunded if nothing is saved. With share: true (the key also needs animations:share) the share link is published when it finishes, by the same rules as POST /animations/{id}/share: the job then has share_url, or share_error if it couldn't be shared (the animation is still saved).
Scope: animations:write
Body
promptrequired | stringWhat the animation should show and say. |
title | string |
duration_seconds | integer |
aspect | "landscape" | "square" | "portrait" | "wide" | "tall" | "feed" | "video" |
company_name | string |
draft | booleanStop at the first checked cut (faster, fewer credits). |
share | booleanPublish the share link when it finishes. Needs the animations:share scope as well (403 without it); not allowed with draft. Sharing is free. |
Answers 202: job.
GET /animations/{id}
Get one of your animations.
Scope: animations:read
id path | string (uuid) |
Answers 200: Animation.
GET /decks
List your decks.
Scope: decks:read
limit query | integer |
offset query | integer |
q query | stringCase-insensitive title filter. |
Answers 200: object, data, total, next_offset.
GET /decks/{id}
Get one of your decks.
Scope: decks:read
id path | string (uuid) |
Answers 200: Item.
GET /graphics
List your graphics.
Scope: graphics:read
limit query | integer |
offset query | integer |
q query | stringCase-insensitive title filter. |
Answers 200: object, data, total, next_offset.
GET /graphics/{id}
Get one of your graphics.
Scope: graphics:read
id path | string (uuid) |
Answers 200: Item.
GET /logos
List your logos.
Scope: logos:read
limit query | integer |
offset query | integer |
q query | stringCase-insensitive title filter. |
Answers 200: object, data, total, next_offset.
GET /logos/{id}
Get one of your logos.
Scope: logos:read
id path | string (uuid) |
Answers 200: Item.
GET /jobs/{id}
Get a generation job.
Scope: animations:write or animations:read
id path | string (uuid) |
Answers 200: job.
GET /leads
List leads. Emails people left on your animations' sign-up cards, newest first.
Scope: leads:read
limit query | integer |
offset query | integer |
animation_id query | string (uuid)Only this animation's leads. |
Answers 200: object, data, total, next_offset.
POST /exports
Render an MP4 or GIF. Renders in the background. Poll GET /exports/{id}, or listen for the render.completed webhook. The file can be downloaded for a day.
Scope: exports:write
Body
animation_idrequired | string (uuid) |
format | "mp4" | "gif" |
Answers 202: export.
GET /exports/{id}
Get an export.
Scope: exports:write
id path | string (uuid) |
Answers 200: export.
GET /exports/{id}/download
Download a finished export.
Scope: exports:write
id path | string (uuid) |
Answers 200.
GET /openapi.json
This document.
No key needed.
Answers 200: .
Objects
Animation
object | "animation" |
id | string (uuid) |
title | string |
version | integer |
aspect | string |
width | integer |
height | integer |
duration_seconds | integer |
created_at | string (date-time), or null |
updated_at | string (date-time), or null |
share_url | string (uri), or nullThe public share link (also the iframe embed address), or null when not shared. |
embed_script_url | string (uri), or null |
poster_url | string (uri), or nullA still frame (PNG), or null when not shared. |
studio_url | string (uri) |
Item
A deck, graphic or logo.
object | "deck" | "graphic" | "logo" |
id | string (uuid) |
title | string |
version | integer |
created_at | string (date-time), or null |
updated_at | string (date-time), or null |
share_url | string (uri), or null |
studio_url | string (uri) |
Lead
object | "lead" |
id | string (uuid) |
email | string (email) |
name | string, or null |
page_url | string (uri), or null |
source | "embed" | "share" |
animation_id | string (uuid) |
variant | "a" | "b" |
created_at | string (date-time) |
Job
object | "job" |
id | string (uuid) |
type | "animation.generate" |
status | "running" | "succeeded" | "failed" | "cancelled" |
title | string |
message | string, or nullThe latest progress message. |
error | string, or null |
animation_id | string (uuid), or null |
animation_url | string (uri), or null |
share_url | string (uri), or nullWith share: true, the published share link (/share/<token>) once it succeeded. |
share_error | "APPROVAL_REQUIRED" | "SHARE_FAILED"With share: true, why the finished animation wasn't shared (it is still saved). APPROVAL_REQUIRED: approve it in the studio, then share it with POST /animations/{id}/share. |
share_error_message | string, or nullThe reason in words, for share_error. |
created_at | string (date-time) |
updated_at | string (date-time) |
Export
object | "export" |
id | string (uuid) |
status | "queued" | "running" | "succeeded" | "failed" |
error | string, or null |
download_url | string (uri), or nullWith the same API key, for a day after it finishes. |
Webhooks
Add endpoints in the studio under Developers › Webhooks, each with the events it should receive and its own signing secret. Each event is one POST with a JSON body and these headers:
Programmable-Event: the event name.Programmable-Delivery: the event id. The same event is never sent twice on purpose, but a retry can arrive after a timeout: ignore an id you have already handled.Programmable-Signature:t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 oft, a dot and the raw body, keyed with the endpoint’s secret.
Answer with any 2xx within 10 seconds. Anything else is retried after 1, 5 and 30 minutes and 2 hours, then marked failed in the endpoint’s log, where you can retry it.
Checking the signature
import { createHmac, timingSafeEqual } from "node:crypto";
// Express: app.post("/webhooks/programmable", express.raw({ type: "application/json" }), handler)
export function verifyProgrammable(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
if (!/^[a-f0-9]{64}$/.test(parts.v1 || "")) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
// verifyProgrammable(req.body.toString("utf8"), req.get("Programmable-Signature"), process.env.PROGRAMMABLE_WEBHOOK_SECRET)import hmac, hashlib, time
def verify_programmable(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Check the signature against the raw bytes you received, before parsing the JSON, and reject timestamps more than 5 minutes old.
Events
lead.created
Someone left their email on an animation that collects emails.
{
"type": "lead.created",
"id": "4f1c2a9e-0d3b-4c51-9a7e-2b8d4f6e1a05",
"created_at": "2026-10-27T09:30:00.000Z",
"lead": {
"email": "[email protected]",
"name": "Jane Example",
"page_url": "https://example.com/pricing",
"source": "embed"
},
"animation": {
"id": "6f1c2a9e-0d3b-4c51-9a7e-2b8d4f6e1a05",
"title": "Spring launch"
}
}render.completed
An MP4 or GIF finished rendering and can be downloaded.
{
"type": "render.completed",
"id": "evt_3f8a1c0b9d2e4f6a8b7c",
"created_at": "2026-10-27T09:30:00.000Z",
"data": {
"item": {
"kind": "animation",
"id": "6f1c2a9e-0d3b-4c51-9a7e-2b8d4f6e1a05",
"title": "Spring launch"
},
"format": "mp4",
"job_id": "3a0f6c1e-5b2d-4e8f-9c7a-1d2e3f4a5b6c",
"download_url": "https://programmable.digital/api/studio?action=render-download&id=3a0f6c1e-5b2d-4e8f-9c7a-1d2e3f4a5b6c",
"expires_at": "2026-10-28T12:00:00.000Z"
}
}export.completed
A background export (a platform pack or a sent file) finished.
{
"type": "export.completed",
"id": "evt_3f8a1c0b9d2e4f6a8b7c",
"created_at": "2026-10-27T09:30:00.000Z",
"data": {
"item": {
"kind": "animation",
"id": "6f1c2a9e-0d3b-4c51-9a7e-2b8d4f6e1a05",
"title": "Spring launch"
},
"format": "pack",
"job_id": "3a0f6c1e-5b2d-4e8f-9c7a-1d2e3f4a5b6c",
"filename": "spring-launch-social.zip",
"download_url": "https://programmable.digital/api/studio?action=render-download&id=3a0f6c1e-5b2d-4e8f-9c7a-1d2e3f4a5b6c",
"expires_at": "2026-10-28T12:00:00.000Z"
}
}item.published
Something was posted to a social account or placed on a site.
{
"type": "item.published",
"id": "evt_3f8a1c0b9d2e4f6a8b7c",
"created_at": "2026-10-27T09:30:00.000Z",
"data": {
"item": {
"kind": "animation",
"id": "6f1c2a9e-0d3b-4c51-9a7e-2b8d4f6e1a05",
"title": "Spring launch"
},
"service": "linkedin",
"location": "LinkedIn (posted)",
"link": "https://www.linkedin.com/feed/update/urn:li:share:7000000000000000000",
"scheduled": false
}
}version.created
An animation, deck, graphic or logo was saved as a new version.
{
"type": "version.created",
"id": "evt_3f8a1c0b9d2e4f6a8b7c",
"created_at": "2026-10-27T09:30:00.000Z",
"data": {
"item": {
"kind": "animation",
"id": "6f1c2a9e-0d3b-4c51-9a7e-2b8d4f6e1a05",
"title": "Spring launch"
},
"version": 4,
"label": "Edited"
}
}