Put it on your site with one script tag, start conversations from your own front end, and have what happens pushed into your systems.
One header, one base URL. If you can run curl you can be finished with this page in five minutes.
Create a key in Settings → API keys. Choose the scopes it needs and nothing more; a key can be restricted to a single workspace, given an expiry, and revoked at any time. The full key is shown once, at creation — we store only a hash, so there is no way to recover it later and no way for anyone here to read it.
Send it as a Bearer token. Every request is metered and auditable against the key that made it.
curl https://YOUR-HOST/api/v1/assistants \
-H "Authorization: Bearer sk_live_..."Feed readers that cannot set a header may pass the key as a query parameter instead. Use it only for the published RSS feed — a key in a URL ends up in logs, proxies and browser history.
curl "https://YOUR-HOST/api/v1/feeds/published.rss?api_key=sk_live_..."A key carries exactly the scopes you ticked. A call without the scope it needs is refused with 403 and the name of the missing scope, so the fix is never a guess.
content:readRead content tasks and their outputcontent:writeCreate content tasks and approve gatesmedia:readRead media tasks and renditionsmedia:writeGenerate and edit mediaassets:readRead the asset libraryassets:writeUpload and manage assetspublish:readRead publishing state and scheduled postspublish:writePublish and schedule postsanalytics:readRead content performance metricswebhooks:manageCreate and manage outbound webhooksworkspace:readRead workspace settings and membersorganization:readRead organization detailsbilling:readRead subscription and Credit balancerealtime:sessionStart live assistant sessions from your own front endrealtime:readRead live assistant sessions and their transcriptsStarting a conversation and reading one back are deliberately separate scopes, because one of them spends money and the other does not. A dashboard that displays transcripts should not be able to open a line.
The shortest path: one script tag, no API key, no backend.
Create an embed key in the assistant panel and paste one line into your page. It draws a button; clicking it opens the conversation.
<script src="https://YOUR-HOST/api/v1/realtime/embed/widget.js?key=EMBED_KEY"
async></script>The button's label, colour and side are properties of the key, so they are changed in our panel rather than by editing your site — but a query parameter still wins where you give one, because running the same assistant behind two different buttons is a real thing to want.
<script src="https://YOUR-HOST/api/v1/realtime/embed/widget.js?key=EMBED_KEY&label=Ask%20us&color=%234f46e5&position=left"
async></script>The conversation opens in an iframe on our origin, not as a bundle inside your page. That is deliberate: it needs a WebRTC stack and a microphone permission, and asking your visitors to grant microphone access to your domain for code you did not write is both worse for them and the first thing a strict Content-Security-Policy breaks.
An embed key is public by design — it is in your page source, and anyone who reads it can start a conversation on your Credits. It is not an API key and carries no scopes. What bounds it is its ceilings: minutes per session, sessions per day, concurrent sessions, which domains may frame it, an expiry, a total number of uses, opening hours, an address range and a passcode. All are off until you set them.
When the widget is not the interface you want, start the session yourself and render it with LiveKit's own SDK.
Ask for a session and you get a server URL and a room token back. From there use LiveKit's JavaScript, iOS, Android or Flutter SDK. We ship no client library of our own, because LiveKit already publishes one and a wrapper around it would only age.
curl -X POST https://YOUR-HOST/api/v1/realtime/sessions \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"identity": "customer-4821", "assistantId": "asst_..."}'The workspace is taken from the key when the key is restricted to one, and is otherwise the organization's default — or whichever you name in the body. An assistant id belonging to another tenant does not resolve; it falls back to the default rather than answering as somebody else.
POST | /api/v1/realtime/sessions | Open a session and get a LiveKit URL and token |
GET | /api/v1/realtime/sessions | List sessions |
GET | /api/v1/realtime/sessions/:id/transcript | Read what was said, and every tool call |
DELETE | /api/v1/realtime/sessions/:id | End a session and settle what it used |
GET | /api/v1/assistants | List the assistants in the workspace |
GET | /api/v1/assistants/:id | One assistant, with its persona and limits |
Whichever way in was used — the widget, this API, or your own backend acting for your users — the session record, the transcript, the assistant's knowledge and the ceilings are identical.
The content pipeline and the publishing calendar are reachable from the same key.
GET | /api/v1/content/tasks | List content tasks |
POST | /api/v1/content/tasks | Start one |
GET | /api/v1/content/tasks/:id | One task and its state |
DELETE | /api/v1/content/tasks/:id | Cancel it |
POST | /api/v1/content/tasks/:id/topics/approve | Approve the topic gate |
POST | /api/v1/content/tasks/:id/drafts/approve | Approve the draft gate |
POST | /api/v1/content/packages/:id/approve | Approve a finished post |
GET | /api/v1/publish/tasks | What is scheduled and what shipped |
POST | /api/v1/publish/tasks | Publish or schedule |
GET | /api/v1/analytics/tasks/:id | Metrics for one task |
GET | /api/v1/feeds/published.rss | Everything published, as RSS |
Approving a gate through the API calls the same service function the interface does, so the same rules, the same audit record and the same events apply. There is no back door that skips a review.
Rather than polling: we POST to your endpoint when something happens, signed, with retries.
Register an endpoint and pick the events you want, or subscribe to everything with a single asterisk. Each delivery carries the event id, its type, a timestamp and a signature.
X-Wcolo-Event-Id: <uuid>
X-Wcolo-Event-Type: service.handover.requested
X-Wcolo-Timestamp: 1758412800
X-Wcolo-Signature: v1=<hex>Verify before you trust it. The signature is an HMAC-SHA256 over the timestamp, a dot, and the exact request body, keyed with your endpoint's signing secret. Compare the bytes you received, not a re-serialised copy of them, and reject a timestamp that is far from now.
const mac = crypto.createHmac("sha256", SIGNING_SECRET);
mac.update(`${headers["x-wcolo-timestamp"]}.${rawBody}`);
const expected = `v1=${mac.digest("hex")}`;
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headers["x-wcolo-signature"]));The body is the same shape for every event: an id, a type, a version, the organization and workspace it belongs to, when it happened, and a data object that differs per type.
{
"id": "evt_...",
"type": "realtime.session.ended",
"version": 1,
"organizationId": "...",
"workspaceId": "...",
"occurredAt": "2026-09-21T10:04:11.000Z",
"data": { }
}Answer 2xx quickly and do your work afterwards; a delivery times out after ten seconds and is retried. Deliveries can arrive more than once, so key your handler on the event id.
content.task.completedcontent.task.failedcontent.topics.readycontent.draft.readycontent.package.readycontent.package.deliveredcontent.package.importedcontent.media.decision_neededcontent.media.review_neededcontent.gate.auto_approvedmedia.task.readymedia.task.failedmedia.plan.readymedia.project.renderedmedia.rendition.completedpublish.task.createdpublish.post.publishedpublish.task.failedpublish.cloud.haltedengage.message.receivedengage.reply.sentrealtime.session.startedrealtime.session.endedrealtime.proposal.createdrealtime.service.hiredservice.handover.requestedanalytics.snapshot.updatedcredits.lowpayment.settledTwo events are deliberately not offered. Every inbound customer message would be a firehose rather than a signal, and consent is a legal state we act on ourselves — a system acting on it independently is how somebody gets messaged after they said stop.
What comes back when something is wrong, and how to see what a key has been doing.
401The key is missing, malformed, expired or revoked.403The key is valid but lacks the scope, or the record belongs to another tenant. The message names the missing scope.404No such record in this organization. A record that exists elsewhere is a 404 here, never a 403 — the difference would tell you it exists.429Rate limited. Back off and retry.5xxOurs. Safe to retry with the same idempotency, which is why cost-bearing operations settle rather than double-charge.Settings → API keys shows what each key has done: requests, errors, how many were rate limited, and when it was last used. A key that has not been used in months is one to revoke.