API
Programmable Talk · for hosts automating their events
Everything you do with events in the dashboard can be scripted: create and update events, pull registrants, mark attendance, send invitations, and export to CSV. Requests and responses are JSON (except the CSV exports and image upload). The base URL is https://programmable.talk.
Successful responses wrap their result in data, or answer { "ok": true } when there is nothing to return. Lists come back whole; there is no pagination.
Authentication
Make a key in Settings. It is shown once; we keep only its hash. Send it in the Authorization header (never in the query string):
Authorization: Bearer pev_…- Full keys act as the account owner and can read and change events.
- Read keys can only make GET requests; anything else answers
403“This API key is read-only.” - Keys are limited to event work. Team, billing, connections, domains and keys themselves need a person signed in (see below).
- While an account is closing, it can be read and exported but not changed: writes answer
423withcode: "closing". - Revoking a key in Settings stops it at once.
Rate limits. Wrong keys are counted per IP address: after 30 unrecognized keys in an hour, every request from that address carrying a key answers 429 for the rest of the hour. Valid keys have no general request limit; image uploads are limited to 120 an hour per account, and the public directory to 120 requests an hour per IP.
Errors
Errors are JSON with a human-readable error, sometimes with a machine-readable code. For an invalid body, the message names the first field that failed, like title: String must contain at least 2 character(s).
{ "error": "This API key is read-only." }
{ "error": "The free plan includes 3 live events. Upgrade to Pro in Settings for more.", "code": "upgrade" }400: the body isn't JSON, or a field is invalid.401: no key, or a key that wasn't recognized (or was revoked).402withcode: "upgrade": the free plan's limit on live events was reached.403: a read-only key tried to write, the action isn't open to keys, or an account limit was reached (events, entry points, speakers, series).404: no such event (or registrant, speaker…) on this account.409: a conflict, such as an event address or entry point code already taken.423: the account is closing.429: too many wrong keys, uploads, or directory requests.500: “Something went wrong on our side.”
Events
An event is the page people register on, plus where the event really runs (platform: luma, eventbrite, zoom, meetup, other, or here for events hosted on Programmable Talk). An event object carries everything below plus id, slug, created_at, sync timestamps and errors. Dates are ISO 8601 with an offset.
/api/eventsread or full keyEvery event on the account, latest first, each with registrants (excluding cancelled and waitlisted) and last_7_days. → { data: Event[] }
/api/eventsfull keyCreate an event. Only title is required. → 201 { data: Event }. Every new event starts with two entry points, the page link (web) and email registration (mail).
title(2–160),summary(≤4000),host_name(≤120),host_offer(≤1500)starts_at,ends_at(ISO date-time with offset, or null),timezone(defaultUTC),location(≤300)platform(defaultother),platform_url,platform_ref,push_mode:api|link|none(defaultlink;apionly for Luma and Zoom)status:draft|live|closed(defaultlive),capacity(1–1,000,000 or null; past it, people join a waitlist)hope_question(3–200),lens: up to 3{ prompt }(5–600 chars) researched for each registrant,research(default true),reminders(default true)recording_url,video_url,booking_url,next_url,review_url(web addresses),followup_note(≤4000),quotes(up to 12{ quote, name, role? })series_id,listed(in the public directory),template:talk|evaluation,template_config:{ rereview_days? }theme:{ accent?, cover? }, accent one of emerald, blue, violet, rose, amber, slate, brand; cover an https:// image or an uploaded/a/<id>slug: 2–48 lowercase letters, digits and dashes (made from the title if omitted;409if taken)
Errors: 402 creating a live event past the free plan's limit; 403 at the account's event limit.
/api/events/{id}read or full keyOne event. → { data: Event }
/api/events/{id}full keyChange any of the fields above; send only what changes. slug can't be changed and is ignored. Raising capacity moves people up from the waitlist. → { data: Event }. 402 when going live past the free plan's limit.
/api/events/{id}full keyDelete the event and its registrations. → { ok: true }
/api/events/{id}/duplicatefull keyThe next session: a copy as a draft in the same series, a week later, or at { starts_at }. → { data: Event }
/api/events/{id}/syncfull keyRead attendance and cancellations back from Luma or Zoom now. → { data: { attended, cancelled, noShow } }. Other platforms, or a failed sync, answer 502 with the reason.
/api/events/{id}/followupread or full keyHow many people would get each follow-up, and a text preview. → { data: { counts: { attended, missed }, preview: { attended, missed } } }
/api/events/{id}/followupfull keySend the follow-up email: { audience: "attended" | "missed" }. Each person gets it once. Needs a recording, note, next date or review link on the event first (400). → { data: { sent, skipped, total } }
Example: create an event
curl -X POST https://programmable.talk/api/events \
-H "Authorization: Bearer pev_your_key" \
-H "Content-Type: application/json" \
-d '{
"title": "Caching for LLM apps",
"starts_at": "2026-11-12T17:00:00Z",
"ends_at": "2026-11-12T18:00:00Z",
"timezone": "Europe/London",
"platform": "zoom",
"platform_url": "https://zoom.us/webinar/register/WN_abc",
"status": "draft"
}'Registrants
A registrant's status is registered, waitlisted, cancelled, attended or no_show; channel says how they registered.
/api/events/{id}/registrantsread or full keyEveryone, newest first: id, email, name, company_domain, company_name, source_id, source_label, channel, status, hope, questions (their questions' text), help (what the research found you can help with), research_status, push_status, push_error, join_url, utm, created_at. → { data: Registrant[] }
/api/events/{id}/exportread or full keyEvery registrant as CSV (text/csv, as an attachment named <slug>-registrants.csv), oldest first. Columns: email, name, company_domain, company_name, status, channel, source, source_code, one utm_* column per UTM tag, registered_at, hope, asked, how_host_can_help, suggested_questions, one lens_N per lens question, platform_push, platform_push_error. Cells that would start a spreadsheet formula are prefixed with '.
/api/events/{id}/registrants/{rid}full key{ action }: attended, no_show or registered sets their status (not for cancelled registrations); retry_push sends them to Luma or Zoom again; retry_research researches their company again. → { ok: true }, or 409 “Nothing to change for this registrant.”
/api/events/{id}/registrants/{rid}full keyErase a registrant: the registration and everything attached to it, their invitation to this event, and the account's email records to them. An email opt-out they made is kept. → { ok: true }
/api/events/{id}/attendancefull keyMark attendance from any text containing email addresses (an attendee export, a column): { text, rest_no_show? } (text ≤2,000,000 chars). With rest_no_show: true, everyone else still registered becomes no_show. → { data: { found, marked, noShow, unknown } }; 400 if no addresses are found.
/api/events/{id}/questions/{qid}full keyAnswer a registrant's question: { answer } (≤4000; empty clears it). The answer shows on their page and in the follow-up. → { ok: true }
Example: list registrants, and export them
curl https://programmable.talk/api/events/EVENT_ID/registrants \
-H "Authorization: Bearer pev_your_key"curl https://programmable.talk/api/events/EVENT_ID/export \
-H "Authorization: Bearer pev_your_key" \
-o registrants.csvInvitations
/api/events/{id}/invitesfull key{ text, note?, send? }: one person per line, email, name, company (up to 500 per request), an optional note (≤600). Each person gets a personal link, returned only in this response. With send: true they are also emailed, with replies going to you. → { data: [{ email, name, link }], sent }
/api/events/{id}/invitesread or full keyThe invitations: id, email, name, company_domain, note, status, created_at, personal, review_status, has_link. → { data: Invite[] }. With ?format=csv: a CSV of email, name, company_domain, status and link, for merging into your own mail tool.
Entry points, speakers, series and images
Entry points
Each way into an event (a link, QR code, email address, partner…) is an entry point with a short code, so every registration says where it came from.
/api/events/{id}/sourcesread or full key→ { data: [{ id, code, label, kind, clicks, scans, short, registrations, utm, created_at }] }
/api/events/{id}/sourcesfull key{ label, kind?, code?, utm? }: label 1–80; kind one of link, qr, email, card, speaker, registrant, partner, invite, listing (default link); code 2–24 lowercase letters and digits (409 if used on this event); utm preset tags. → 201 { data: Source }
/api/events/{id}/sources/{sid}full keySet preset UTM tags: { utm } ({} clears them). → { data: { utm } }
/api/events/{id}/sources/{sid}full keyRetire an entry point. Its registrations keep pointing at it. → { ok: true }
Speakers
/api/events/{id}/speakersread or full keyIn order. → { data: [{ id, position, name, role, talk_title, bio, link, photo_asset_id, photo, code }] }
/api/events/{id}/speakersfull keyAdd one: { name, role?, talk_title?, bio?, link?, photo_asset_id? }, or several at once, matched by name: { speakers: [{ name, role?, talk_title?, bio? }] }. Up to 20 per event. Each speaker gets their own entry point. → { data }
/api/events/{id}/speakers/{sid}full keyAny of the fields above, or { move: "up" | "down" }. → { data: Speaker }
/api/events/{id}/speakers/{sid}full key→ { ok: true }
Series
One link for every session of a recurring event. Sessions are ordinary events with series_id set.
/api/seriesread or full key→ { data: [{ id, slug, title, summary, created_at, sessions, next_at }] }
/api/seriesfull key{ title, summary?, slug? } (title 2–160; up to 100 series per account). → { data: Series }
/api/series/{id}full key{ title?, summary? }. → { data: Series }
/api/series/{id}full keyIts sessions are kept, no longer grouped. → { ok: true }
Images
/api/assetsfull keymultipart/form-data with file (up to 8 MB) and kind: logo | photo | cover. The image is re-encoded and resized. → { data: { id, kind, mime, width, height, url } }; use url (/a/<id>) as an event cover, or id as a speaker's photo_asset_id.
Public directory
/api/public/events?q=&limit=no key neededListed events, for partner sites and calendars. limit 1–100 (default 50). CORS is open. → { data: [{ id, title, summary, host, starts_at, ends_at, timezone, location, platform, url, register_by_email, cover }] }
Webhooks
Add an HTTPS endpoint under Connections in Settings (signed in; keys can't). Its signing secret (whsec_…) is shown once. The URL must use https on port 443 or 8443 and resolve to a public address; redirects aren't followed.
Events:
registration.created,registration.updated(someone registered again),registration.promoted(off the waitlist),registration.cancelled(withvia: "luma" | "zoom"when the cancellation came from the platform):datais{ event, registrant }as below.question.created:{ event: { id, slug }, registrant: { id, email, name }, question }question.answered:{ event: { id }, question: { id, body, answer }, registrant: { id } }
Each delivery is a POST with a JSON body:
{
"id": "1842",
"type": "registration.created",
"created_at": "2026-11-01T09:30:00.000Z",
"data": {
"event": { "id": "…", "slug": "…", "title": "…", "starts_at": "…", "platform": "zoom", "platform_url": "…" },
"registrant": {
"id": "…", "email": "jane@example.com", "name": "Jane Doe", "company_domain": "example.com",
"channel": "page", "source_id": "…", "status": "registered", "hope": "…",
"utm": { "source": "newsletter" }, "created_at": "…"
}
}
}Headers. X-Programmable-Id is the delivery id (the same as the body's id), stable across retries, so you can drop duplicates. X-Programmable-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256, keyed with your signing secret, of <t>.<raw body>. Check it against the raw body before parsing, and reject old timestamps:
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the X-Programmable-Signature value; body: the raw request body, unparsed.
function verify(secret: string, header: string, body: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest();
const got = Buffer.from(parts.v1, "hex");
return expected.length === got.length && timingSafeEqual(expected, got);
}Retries. Answer with a 2xx within 15 seconds. A timeout, network error, or a 408, 409, 425, 429 or 5xx is retried after 1, 5, 15, 60, 180, 360 and 720 minutes, 8 attempts in all. Any other status stops delivery at once.
Not available to API keys
These need a person signed in to the dashboard, so that a leaked key can't add a teammate, reach billing, or change where data goes: team members and invitations, billing and checkout, connections (Luma, Zoom, AI provider, webhook), custom domains, listing, creating or revoking API keys, the full account export, and closing the account.
Not documented here: endpoints for AI drafting, the live room, posts, kits, print bundles, website reviews and invitation graphics. They serve the dashboard and may change without notice.