Blip API
Build Blip into your app.
Make recorder links, list and share Blips, take support tickets on video, send Reply invites from your own events, and hear back through signed webhooks. Everything here is live.
Install with your AI
Building with Cursor, Claude Code, Lovable, Bolt or v0? Make your key and app in Blip (Settings, Developers), put the key in your server's env as BLIP_API_KEY, then paste this to your AI. The install guide it reads has the files to copy, the two routes to add and the checks to run, for a Record button, a Report a problem button, or both.
Add Blip to this app: a Record button (people record a Blip from inside my app) and a Report a problem button (what they send becomes a ticket in my app). Read https://sendablip.com/plugin/v1/INSTALL.md first and follow it step by step. Use the files it links to; don't write your own versions of them. My Blip API key is in the env as BLIP_API_KEY (and BLIP_WEBHOOK_SECRET if I made an app). Never put either in browser code or in the chat. When it's in, run the checks in the guide (GET /api/v1/me and the test webhook) and tell me what they said.
The files, all on sendablip.com: blip.ts (server), blip-react.tsx (React), recorder.js and support.js (any page), and llms.txt for AIs.
Getting started
Every call goes to https://sendablip.com/api/v1, sends and returns JSON, and carries your key:
curl https://sendablip.com/api/v1/videos?limit=10 \
-H "Authorization: Bearer blip_sk_..."Or copy the plugin, one file with no dependencies, and call it from your server (see The plugin).
/meScope any key · Plugin whoami
Is this key set up right, and what's left to do? The workspace, the key's scopes, its app and webhook address, your support buttons with the code to paste for each, the plugin's latest version, and next_steps: plain sentences for whatever is still missing. It answers even when your plan doesn't include the API, with api.ok false and why.
{
"workspace": { "id": "...", "name": "Acme", "windows": false },
"key": { "name": "Help desk", "prefix": "blip_sk_4f1c", "scopes": ["videos:read", "videos:write"] },
"app": { "slug": "help-desk", "name": "Help desk", "active": true,
"webhook_url": "https://acme.com/api/blip/webhook", "events": ["video.ready", "ticket.created"],
"sites": ["app.acme.com", "localhost"] },
"api": { "ok": true },
"support_buttons": [{ "name": "Acme app", "active": true, "files_with_this_app": true,
"sites": ["app.acme.com"], "snippet": "<script src="https://sendablip.com/plugin/v1/support.js" ...></script>" }],
"plugin": { "version": "2.27.0", "files": { "server": "https://sendablip.com/plugin/v1/blip.ts", ... } },
"next_steps": []
}Keys and scopes
Make keys in Blip under Settings, Developers. A key starts blip_sk_, is shown once, and only its hash is kept. Keep it on your server. A key belongs to one workspace and can only reach that workspace.
An app key is a key connected to an app there. Apps have a webhook address and a signing secret, and Reply and access calls need one.
| Scope | Type | Notes |
|---|---|---|
videos:read | List and read Blips, requests and assignments | |
videos:write | Recorder links, personal links, requests, access | |
replies:read | List Reply invites | |
replies:write | Send events, make and cancel invites, erase a person | |
threads:read | Read Threads, their people and messages | |
threads:write | Add people, open Threads, post messages, assign and split | |
tickets:read | List and read support tickets: the thread, notes, screenshots, recording | |
tickets:write | Add notes, assign, tag and move tickets | |
tickets:reply | Answer a visitor on a ticket (needs tickets:write, so Settings ticks the two together) |
Errors and limits
A failed call answers with a status and a plain sentence you can show or log:
{ "error": "This key can't do that. It needs videos:write." }| Status | Type | Notes |
|---|---|---|
400 | Something in the request isn't right; the error says what | |
401 | No key, or the key isn't valid or was revoked | |
403 | The key lacks the scope, or its app is off, or the call needs an app key | |
402 | The workspace's plan doesn't include the API (Business and Reply do); the error says what to do | |
404 | Not found in this workspace | |
413 | The request body is too big | |
429 | Too many calls; wait and try again (Retry-After says how long, in seconds) |
Refs
A ref is your own id for the thing a Blip belongs to, like yourapp:project:142 or yourapp:deal:88. Up to 200 characters: letters, numbers and . _ : / -, starting with a letter or number. Put one on a recorder link and every Blip made with it carries it, so you can list a project's Blips with ?ref=, or everything under a prefix with ?ref_prefix=.
Recording
/recorder-linksScope videos:write · Plugin recorderUrl
A link to Blip's recorder for your own team. Whoever opens it signs in to Blip and must be in your workspace. For your app's users, who have no Blip account, make a request instead. Either way, recorder.js opens it from your button:
<script src="https://sendablip.com/plugin/v1/recorder.js" async></script>
<!-- Your route POSTed, answering {url} from /recorder-links or /requests -->
<button data-blip-record="/api/blip/record" data-blip-ref="yourapp:project:42">Record a Blip</button>| Fields | Type | Notes |
|---|---|---|
ref | string? | Put on every Blip made with this link |
title | string? | Up to 200 characters |
expires_in | integer? | Seconds, from 60 to a week. Two hours if left out |
{ "url": "https://sendablip.com/record?t=...", "expires_at": "2026-11-09T18:00:00.000Z" }/recorder-sessionsScope app key, early access · Plugin recorderSession
The recorder inside your own page, with no new window and no second sign-in. Your server asks for a session for one person on your site; the browser opens it with the hosted script. A session works only on origin and runs out after six hours. During early access this is switched on for each app with us.
| Fields | Type | Notes |
|---|---|---|
user_id | uuid | The person in Blip |
email | string | Their email |
origin | string | Your site, like https://app.yourtool.com. The recorder refuses to load anywhere else |
<script src="https://sendablip.com/plugin/v1/recorder.js"></script>
<script>
// url comes from your server's call to /recorder-sessions
BlipRecorder.open(url, { anchor: recordButton });
</script>/accessScope videos:write, app key · Plugin syncAccess
Who can see what, by your own rules. Run your own query as this person, so your own permissions decide, and send every ref they can see. It replaces their list for your app; an empty list removes everything. Send it for every person, not only the ones who open Blip, and again whenever their access changes. Add /manage to a ref (acme:project:42/manage) when they can change other people's videos there too.
| Fields | Type | Notes |
|---|---|---|
email | string | The person |
refs | string[] | Every ref they can see |
{ "synced": 12 }/refsScope videos:write, app key · Plugin syncRefs
Your places and their names: acme:project:42 is “Acme website”. Blip shows the names, and offers the places a person can see (PUT /access) when they file a Blip. It replaces your last list. An archived place keeps its name but isn't offered. A place named <your-app>:project:<uuid> becomes one of the workspace's projects in Blip, beside the ones it makes itself; one you stop sending is archived, never deleted.
| Fields | Type | Notes |
|---|---|---|
refs | {ref, label, archived?}[] | Every place, 10,000 at most |
{ "listed": 22 }/membersScope videos:write, app key · Plugin syncMembers
Everyone your app lets into Blip in your workspace, by your own rules. It replaces your last list: people on it become members, now or when they first sign in to Blip, and members your list made who aren't on it any more stop being members. People your workspace's owners add in Blip aren't touched. Send it on a schedule and when someone joins or leaves.
| Fields | Type | Notes |
|---|---|---|
people | {email, name?}[] | Everyone allowed in, 5,000 at most. A name fills theirs in Blip if it has none |
{ "listed": 24 }Videos
/videosScope videos:read · Plugin listVideos, getVideo
Your workspace's Blips, newest first. GET /videos/:id returns one.
| Query | Type | Notes |
|---|---|---|
ref | string? | Only Blips with this ref |
ref_prefix | string? | Only Blips whose ref starts with this |
purpose | "blip" | "support" | "reply" | "thread"? | Only Blips, only support reports, only Blip Reply answers and clips, or only video messages recorded in Threads. Without it, all of them |
limit | integer? | 50 by default, 100 at most |
before | string? | The next page: next_before from the last answer |
player | 1? | Add a signed player address for your own team, which works with the share link off |
{
"videos": [{
"id": "4f1c...", "title": "Q3 plan walkthrough", "status": "ready",
"duration_seconds": 214, "external_ref": "yourapp:project:142",
"created_at": "2026-11-09T15:02:11Z", "owner": { "name": "Mei Chen" },
"guest_name": null, "source": "web", "views": 7, "request_id": null, "purpose": "blip",
"share": { "code": "9cc6cf7bb13c36b3ae83eb03", "enabled": true,
"url": "https://sendablip.com/9cc6...", "embed_url": "https://sendablip.com/embed/9cc6...",
"thumbnail_url": "https://sendablip.com/api/video/thumb/9cc6....jpg" }
}],
"next_before": null
}Status is uploading, processing, ready or failed.
embed_url takes ?t=30 to start 30 seconds in and ?title=0 to hide the title bar. An embed shows the video's picture and a play button, and loads the player when someone presses it, so nothing reaches the video host until then. It can't start by itself: ?autoplay=1 is still accepted, but it waits for that press too.
purpose says what each one is, set where it was made and never changed: blip (recorded or uploaded to share),support (a report through a support button or a team member's bug report) or reply (an answer to a Blip Reply, or a clip of answers). Blip's own library shows Blips only.
/videos/:idScope videos:write · Plugin updateVideo, deleteVideo
Rename a Blip, set or clear its ref, or turn its share link on or off. Send any of the three. DELETE /videos/:id deletes it: the link stops and its files go.
| Fields | Type | Notes |
|---|---|---|
title | string? | Up to 200 characters |
ref | string | null? | Your ref; null takes it off |
share_enabled | boolean? | The share link on or off |
{ "video": { "id": "4f1c...", "title": "Q3 plan, final", ... } }/videos/:id/transcriptScope videos:read · Plugin getTranscript
Every Blip gets a transcript, a summary, chapters and action items, made on Blip's side a minute or so after it's ready.
{ "transcript": {
"state": "done", "language": "en",
"text": "Hey everyone. Today I'm showing you...",
"segments": [{ "start": 0.4, "end": 3.1, "text": "Hey everyone." }],
"summary": "Three changes that cut onboarding time in half.",
"chapters": [{ "start": 0, "title": "Why calls were slow" }],
"action_items": [{ "title": "Record the setup walkthrough", "description": "...", "quote": "..." }]
} }state is queued or running while it's being made, done with words, and no_speech, failed or none when there won't be any.
/videos/:id/statsScope videos:read · Plugin getVideoStats
How a Blip is doing. The owner watching their own doesn't count.
{ "stats": { "views": 31, "viewers": 18, "minutes_watched": 64.5,
"completed": 12, "button_clicks": 4, "reactions": { "🔥": 3, "👏": 5 } } }/linksScope videos:write · Plugin createLink
A personal link to one Blip for one person, so you know when they watched.
| Fields | Type | Notes |
|---|---|---|
video_id | uuid | The Blip |
name | string | Who it's for, up to 120 characters |
email | string? | Their email |
ref | string? | Your ref for it, like a deal |
expires_at | datetime? | When it stops working; must be in the future |
{ "id": "b2e0...", "url": "https://sendablip.com/0123...cdef" }/requestsScope videos:write (list: videos:read) · Plugin createRequest, listRequests, newRequestLink, revokeRequest
A link anyone can record on, with no account: ask a customer to show you a problem, or a teammate for an update. The url comes back this once.GET /requests?ref= lists them; POST /requests/:id/new-link replaces the link (the old one stops) andPOST /requests/:id/revoke ends it.
| Fields | Type | Notes |
|---|---|---|
ref | string | What it's for |
label | string? | What they see it's for, up to 120 characters |
note | string? | A note to them, up to 1,000 characters |
recipient_name | string? | Who it's for |
due_at | datetime? | When you need it |
expires_in_days | integer? | 1 to 365, 30 by default |
multi_use | boolean? | Keep taking recordings after the first |
max_minutes | integer? | 1 to 60 |
{ "id": "...", "url": "https://sendablip.com/r/...?open=page", "open": "page" }Made with an app key, open is page: recorder.js opens it inside your page, on the sites listed on the app. It's window only when your workspace allows pop-up windows and the app lists no sites. Emailed or pasted, the link is a page of its own either way.
A request's state is open, received, expired or revoked, with received_count and latest_video_id.
/assignmentsScope videos:read · Plugin listAssignments
What one person was asked to watch and hasn't finished, with how far they got. ?user= is the person; &done=1 includes finished ones.
{ "assignments": [{ "video_id": "4f1c...", "title": "Onboarding", "external_ref": null,
"due_at": null, "best_percent": 64, "completed_at": null, "assigned_at": "2026-11-08T10:00:00Z" }] }/threads/peopleScope threads:write (list: threads:read) · Plugin syncThreadPeople, listThreadPeople
Threads is a DM between a student and your team, inside your own app: video, voice or text both ways, with a coach, an assistant or your AI answering. It needs an app key and Threads switched on for the app. Your people are known by your own ids; they need no Blip account. Add them here, up to 500 at a time. active: false switches a person off, so who may use Threads (every member, or only people who paid) is yours to decide.GET /threads/people?role= lists them.
| Fields | Type | Notes |
|---|---|---|
people[].id | string | Your id for the person |
people[].name | string | Their name, as the other side sees it |
people[].email | string? | Their email |
people[].role | string | student, coach or assistant |
people[].active | boolean? | false switches them off; true by default |
{ "saved": 3 }/threadsScope threads:write (list: threads:read) · Plugin openThread, listThreads, getThread, updateThread, splitThread
POST /threads gives a student's DM, making it the first time (201) and returning the same one after (200): one DM per student and ref.GET /threads lists them. Pass ?for= a person's id for anything a person sees (a student gets only their own, and the groups they're in), and each thread then has unread: how many messages from others that person hasn't read.?state=waiting is the coach's inbox, the longest-waiting first. Also ?assigned= (an id, or none), ?student=,?kind=dm|topic|group, ?parent=, ?ref=, ?archived=true, ?limit= and ?before=.
GET /threads/:id reads one. PATCH /threads/:id with {by, assigned_to?, answered?, title?} lets a coach or assistant (by) give it to someone (null: nobody), mark it answered without a reply, or name it. POST /threads/:id/split with {by, message_id, title?} moves a question out of a DM into a topic of its own (or out of a group: see groups).
| Fields | Type | Notes |
|---|---|---|
student | string | Your id for the student |
ref | string? | Your name for what it's about, like a course |
title | string? | A title, up to 200 characters |
{ "thread": { "id": "7d1e...", "kind": "dm", "student": { "id": "u_42", "name": "Nathan", "role": "student" },
"ref": "course:ai", "assigned_to": null, "waiting_since": "2026-11-13T15:02:11Z", "answered_at": null,
"messages": 1, "last_message_at": "2026-11-13T15:02:11Z", "last_preview": "How do I price this?" } }/threads/:id/messagesScope threads:write (list: threads:read) · Plugin postThreadMessage, postThreadVoice, listThreadMessages
Post a message from one of your people (from, your id for them), or from your own AI with {"as": "ai", "text": "..."}. A student posts only in their own threads; a coach or assistant in any. A student's message starts the wait. A coach's or assistant's ends it and takes the thread if nobody has it; the AI's does neither.GET /threads/:id/messages lists them, newest first.
| Fields | Type | Notes |
|---|---|---|
from | string | Your id for who's posting (leave out with as: ai) |
kind | string? | text (default) or video. A voice note is sent as its sound file: see below |
text | string? | The words, up to 10,000 characters |
video_id | string? | For video: a Blip in your workspace |
reply_to | string? | A message of this thread to put it under |
{ "message": { "id": "a41b...", "from": { "id": "c_7", "name": "Coach Bob", "role": "coach" }, "role": "coach",
"kind": "text", "text": "Charge more.", "video_id": null, "voice": null, "reply_to": null, "created_at": "2026-11-13T15:20:00Z" },
"thread": { "id": "7d1e...", "waiting_since": null, "answered_at": "2026-11-13T15:20:00Z" } }A voice note is sound only. Your people can record one straight into the Threads screens. To send one yourself, post the sound file as the body of the same address instead of JSON, with its Content-Type and who it's from in the address (the plugin's postThreadVoice does this):POST /threads/:id/messages?from=u_42&seconds=42. Blip keeps the file as it is, so it costs far less than a video and doesn't use your video minutes. Its words are written out a few seconds later: voice.transcript_state goes from pending to done(none if nobody spoke), so read the message again for voice.transcript. Play it from voice.audio_url, which works for three hours.
| Fields | Type | Notes |
|---|---|---|
Content-Type | header | audio/webm, audio/mp4 or audio/m4a: what a browser's or a phone's recorder makes |
(body) | bytes | The sound file, 4 MB and 5 minutes at most |
from | query | Your id for who's posting |
seconds | query | Its length, a whole number. Blip measures it again when it listens |
reply_to | query? | A message of this thread to put it under |
{ "message": { "id": "c93d...", "from": { "id": "u_42", "name": "Nathan", "role": "student" }, "role": "student", "kind": "voice", "text": null,
"voice": { "seconds": 42, "transcript": null, "transcript_state": "pending", "audio_url": "https://sendablip.com/api/threads/audio/c93d...?k=..." },
"created_at": "2026-11-13T15:25:00Z" },
"thread": { "id": "7d1e...", "waiting_since": "2026-11-13T15:25:00Z", "last_preview": "Voice note" } }/threads/groupsScope threads:write (read: threads:read) · Plugin createThreadGroup, updateThreadGroup, listThreadGroupMembers, setThreadGroupMembers, changeThreadGroupMembers
A group is one place where several students and your team talk together, like a cohort. POST /threads/groups with {name, ref?, members?, by?} makes one (201). With a ref (your name for the cohort or course), the same call again gives the group there is (200), so you can call it every time you sync. A group is a thread of kind group: list it with GET /threads?kind=group (add ?for= for one person's groups), and read and post with /threads/:id/messages, in text, video or voice.
Who's in it. Members are students, by your ids. Every coach and assistant of your app is in every group already. GET /threads/groups/:id/members lists them, PUT /threads/groups/:id/members with {members} makes the list exactly that, and POST /threads/groups/:id/members with {add?, remove?} changes it. A group holds 500 students at most. A student reads and posts in a group only while they're a member: take them out and it stops at once, and what they wrote stays. A student can't join or leave by themselves; your app decides. In the inbox screen a coach can start a group and add or take out students too.
Nobody waits in a group. It isn't a queue: it has no waiting_since, it isn't given to anyone, and PATCH /threads/:id refuses it. To follow a question, move it out: POST /threads/:id/split on a student's message (by a coach, an assistant or the student who wrote it) makes a private topic between that student and your team, which waits like any topic. The AI never answers in a group. A student's monthly video and voice minutes cover what they record in a group too.
Changing it. PATCH /threads/groups/:id with {name?, ref?, archived?}. Archived, a group is still read by its members, nothing new is said in it (a post answers 409), and it leaves the lists unless you ask with ?archived=true.
Telling people. A group's thread.message webhook carries members: the ids of the students who can read it, so you can notify them (all but the sender) and your team. The list stops at 200 ids; when complete is false, ask for the members. Blip doesn't notify anyone itself, and there are no @mentions yet.
| Fields | Type | Notes |
|---|---|---|
name | string | The group's name, up to 200 characters |
ref | string? | Your name for what it's for, like a cohort. One group per ref |
members | string[]? | Your ids for the students to put in it (send them with PUT /threads/people first) |
by | string? | The coach doing it, if a person is. Leave it out when it's your app |
{ "thread": { "id": "5b0c...", "kind": "group", "title": "Spring cohort", "ref": "cohort:spring", "student": null,
"member_count": 24, "archived_at": null, "waiting_since": null, "assigned_to": null,
"messages": 0, "last_message_at": null, "last_preview": null } }// thread.message, in a group
{ "type": "thread.message", "thread": { "id": "5b0c...", "kind": "group", "title": "Spring cohort", "member_count": 24 },
"message": { "id": "c93d...", "from": { "id": "u_42", "name": "Nathan", "role": "student" }, "kind": "text", "text": "Is the call recorded?" },
"members": { "count": 24, "ids": ["u_42", "u_43", "u_51"], "complete": true } }/threads/sessionsScope threads:write · Plugin createThreadSession
The drop-in screens: a student's conversation and your team's inbox, inside your own app, in your colour. Your server says who is signed in and gets a session for that one person, good for two hours on one of your sites. Put thread_url (a student) or inbox_url (a coach or an assistant) in the plugin's BlipThread or BlipThreadInbox, or in threads.js on any page. The person is added or brought up to date, so there's nothing to sync first; someone you switched off gets no session.
In the screen, people write, or record a Blip with their camera, their screen or both; they need no Blip account. A video shows as getting ready, then plays in the conversation. The inbox lists who's waiting, the longest first, and a coach or assistant can answer, give a conversation to someone, mark it answered or move a question to a conversation of its own. origin must be one of your app's sites (Blip, Settings, Developers): only that site can hold the screen. Make the session on your server, never in the browser. To tell people about a new message when they're somewhere else, use the thread.message webhook.
| Fields | Type | Notes |
|---|---|---|
user.id | string | Your id for the signed-in person |
user.name | string | Their name, as the other side sees it |
user.email | string? | Their email |
user.role | string | student, coach or assistant |
origin | string | The site the screen will be on, like https://yourapp.com. In a phone app: capacitor://localhost |
{ "token": "blip_th_...", "thread_url": "https://sendablip.com/threads/embed?view=thread&s=blip_th_...",
"inbox_url": null, "expires_at": "2026-11-13T17:00:00Z",
"user": { "id": "u_42", "name": "Nathan", "email": null, "role": "student" } }// React: the url comes from your server (createThreadSession).
import { BlipThread, BlipThreadInbox } from "@bigsteele/blip/react";
<BlipThread url={session.thread_url} accent="#0F766E" about="course:ai" onUnread={(n) => setBadge(n)} />
<BlipThreadInbox url={session.inbox_url} accent="#0F766E" height="100%" onWaiting={(n) => setWaiting(n)} /><!-- Any page: -->
<div id="coach"></div>
<script src="https://sendablip.com/plugin/v1/threads.js"></script>
<script>BlipThreads.mount("#coach", { url: THREAD_URL, accent: "#0F766E", onExpired: getANewSession });</script>Options on both: accent (a hex), theme (light or dark; the person's device decides otherwise), height,about (your name for what a student's conversation is about: one conversation each) and onUnread, onWaiting, onMessage,onExpired. When a session runs out, make a new one and pass its address.
To open one conversation first (from a notification or a link), add &thread= and the thread's id to the address. It opens only if that person may read it.
In your own phone app. Inside a phone app's shell your pages aren't on a site, so origin is the shell's own address:capacitor://localhost on an iPhone, https://localhost on Android. List it on your app under Phone app addresses (Blip, Settings, Developers); it is matched exactly, never by wildcard, and opens Threads only. Every app built on the same shell has that address, so there Blip keeps websites out but can't tell one phone app from another: keep the session's address on your server and out of links and notifications. Notifications are yours to send, from the thread.message webhook. The recipe, with code, and what hasn't been tested on a real phone: THREADS-PHONE.md.
/threads/settingsScope threads:write (read: threads:read) · Plugin getThreadSettings, setThreadSettings
student_minutes is how many minutes of video one student may record into Threads in a calendar month: 60 unless you set it. Their recordings count against your workspace's minutes like any other recording, so this keeps one student from using everyone's. At the limit they can still write.0 means students write and send no video. Your coaches and assistants have no limit of their own. GET /threads/settings reads it.
student_voice_minutes is how many minutes of voice notes one student may send in a calendar month: 120 unless you set it. Voice is sound only, so it doesn't use your workspace's video minutes. 0 means no voice notes from students. Send either setting or both.
ai_mode and ai_monthly_answers are the AI's: see Threads: the AI.
| Fields | Type | Notes |
|---|---|---|
student_minutes | number? | Video: whole minutes a month, 0 to 100000 |
student_voice_minutes | number? | Voice notes: whole minutes a month, 0 to 100000 |
ai_mode | "off" | "draft" | "answer"? | What the AI does with a student's question. Off unless you set it |
ai_monthly_answers | number? | AI answers and drafts a calendar month. Yours to lower; never more than ai_monthly_answers_max |
{ "student_minutes": 60, "student_voice_minutes": 120,
"ai_mode": "off", "ai_monthly_answers": 500, "ai_monthly_answers_max": 500, "ai_answers_used": 0 }/threads/knowledgeScope threads:write (read: threads:read) · Plugin putThreadKnowledge, listThreadKnowledge, getThreadKnowledge, deleteThreadKnowledge, askThreadPerson, getThreadDraft, sendThreadDraft, discardThreadDraft
Blip's AI can give a student a first answer, only from material you give it. Your coach chooses what it does (ai_mode in PUT /threads/settings, or the AI switch in the inbox screen): off, which is how it starts; draft, where it writes an answer and your team sends, changes or throws it away; or answer, where it answers on its own. Its messages have role: "ai" and are labelled as AI in the screens. It never ends a student's wait or takes the thread: only a person does that.
The material. PUT /threads/knowledge adds a document: pasted text with a title, or one of your workspace's Blips for its transcript. Send the same ref again and the document is replaced. GET /threads/knowledge lists them, GET /threads/knowledge/:id gives one with its text, DELETE /threads/knowledge/:id removes it. A coach can paste text in the inbox screen too. Blip cuts each document into pieces and, for each question, finds the pieces about it by their words; the AI may answer only from those.
| Fields | Type | Notes |
|---|---|---|
title | string | The document's name, shown to students as the answer's source. Optional for a Blip (its own title is used) |
text | string? | Pasted text, 100,000 characters at most |
video_id | string? | Instead of text: a Blip in your workspace, for its transcript |
video_ref | string? | Instead of video_id: your own ref for the Blip |
ref | string? | Your own name for the document. The same ref again replaces it |
{ "document": { "id": "5b0c...", "kind": "text", "ref": "module-3", "title": "Module 3: Pricing your first offer",
"video_id": null, "state": "ready", "characters": 4210, "created_at": "2026-11-13T15:00:00Z", "updated_at": "2026-11-13T15:00:00Z" } }An answer. A few seconds after a student's message (a voice note or a video once its words are written out), the AI's answer arrives as a message of its own, and thread.message is sent. ai.sources are the documents it rests on. When your material doesn't cover the question it says so once, in a short note (ai.unknown), and leaves the question to your team. It gives no medical, legal or financial advice beyond what your material says, and treats what a student writes as a question, never as instructions.
{ "id": "e41a...", "from": null, "role": "ai", "kind": "text", "reply_to": "c93d...",
"text": "Your coach's material says not to discount to close a sale. Offer a payment plan of three monthly payments instead.",
"ai": { "sources": [{ "id": "5b0c...", "title": "Module 3: Pricing your first offer", "kind": "text", "video_id": null }],
"unknown": false, "needs_person": false, "from_draft": false } }Ask a person. Under every AI answer the student's screen has an Ask a person button. POST /threads/:id/person with {by} (the thread's own student) does the same from screens of your own: the thread is flagged (thread.person_asked_at), it is waiting, thread.person_asked is sent, and the AI stays out of that thread until someone on your team has written.
A draft. In draft mode nothing reaches the student until a person sends it. GET /threads/:id/draft reads the draft, POST /threads/:id/draft with {by, text?} sends it as that coach's or assistant's own message (with their changes, if text; message.ai.from_draft is true), and DELETE /threads/:id/draft?by= throws it away. The inbox screen shows it with Send, Edit and Discard. thread.ai.state says where the AI stands on the latest question: answered, unknown, draft, nothing (your material had nothing, so no draft), no_material, allowance or failed.
How many. Your app has ai_monthly_answers AI answers and drafts a calendar month (500 unless Blip changed it; you can set it lower). When they're used up the AI stays quiet until next month, the inbox says why, and every question still reaches your team as it always did.
Support tickets
A Report a problem button for your app or site. It opens a small card inside your page, so your customer never loses their place: they write what happened, take a screenshot and mark it up, or show it on video. The page's address, their browser, window size and the page's last few errors go along. Every report is a ticket in your Support inbox in Blip, and your app can hear about it too. Make a button, with the sites it goes on and the app that files its tickets, in Settings, Developers, then paste its code on your site.
<script src="https://sendablip.com/plugin/v1/support.js"
data-token="YOUR_TOKEN" async></script>| Attribute | Type | Notes |
|---|---|---|
data-token | string | Your button's token |
data-way | "screenshot" | "video"? | Open on a screenshot or a video. Write it by default |
data-launcher | "tab" | "circle" | "pill"? | A tab on the screen's edge (the default), a floating round button, or a pill with the words |
data-position | "left"? | The left side instead of the right |
data-color | hex? | The button's colour. Blip purple (#8C00FF) by default; the words go white or near-black to read on it |
data-icon | "none"? | Leave out Blip's icon (the circle always has it) |
data-label | string? | The button's words, and the circle's tooltip. "Report a problem" by default |
data-button | "false"? | No floating button: call window.BlipSupport.open() from your own |
data-network | "true"? | Send the page's last 10 failed requests with each report: method, address without its query string, status, time. Never bodies, headers or cookies. Off unless you add it |
Someone signed in to your app? BlipSupport.identify({ name, email }) fills those in for them. When a report is filed, the page gets a blip:ticket event with detail.ref, the number they were shown, and detail.url, the link to their status page for it. In React, <BlipSupportButton token="..." user={me} onTicket={(ref, url) => ...} /> from blip-react.tsx does all of it.
Verified visitors. A name and email passed to identify only fill the form in. To have Blip trust them, make a signing secret for the button (Settings, Developers, Support buttons, Verified visitors), sign the person on your server with signSupportUser(secret, { email, name, id }) from blip.ts, and pass what it returns to identify (or user in React). Blip checks the signature: the ticket is filed under that name and email, your team sees Verified, and ticket events carry visitor.verified: true and visitor.external_id. A signature that's wrong is ignored; one that has run out only fills the form in. The inbox, every ticket event, GET /tickets and the visitor's own ticket page all give the same answer about who filed a ticket.
The same place has Ticket page replies need a verified visitor: on, a ticket's link can be read by anyone who has it, but its reply box works only for that signed-in person, from your site. It covers the ticket page only. A reply by email that your own tool posts back (with the ticket's reply token, or an API key and as: "visitor") is still taken: it came through your mailbox, which you run. Blip didn't see who sent that email, so the thread marks it By email, through your tool and Sender not checked, and it's via: "email" in the event and the API. If only verified replies should reach a ticket, don't post emailed replies back, or have your tool check the sender's address first.
Failed requests. With data-network="true" a report also carries the last 10 requests the page made that failed: the method, the address without its query string, the status and how long it took. Your team sees them on the ticket, and ticket.created has them as ticket.network. What a request sent, what came back, and its headers and cookies are never read.
Just part of the page. When someone records a video of the page in Chrome or Edge, they can choose Area and draw a box on the page itself; only what's inside it is recorded, and the rest is never sent. In other browsers, or when they share another tab or window, they draw the box in the card instead (Chrome and Edge), or record the whole screen. Nothing to set (support.js 2.27).
On a phone, where no browser can capture the screen, the card offers Write it, Photo (the camera, or a picture or screenshot from the library) and Video (recorded with the camera).
The card stays inside your page. Only if your workspace turns on Allow pop-up windows (Settings, Developers) does it open a window of its own when your site's rules keep the screen from it, or float Pause and Stop over other apps while someone records. Already using widget/v1/record.js with data-mode="bug"? It opens the same card now; add data-popup="true" to keep the old window.
Only sites on your button's list can use it, each visitor can send a few reports an hour, and reports have no public link. Customers can mark a report major or minor; only your team can call something a blocker.
Every report is a ticket with its own number in your workspace (1, 2, 3...), whether it's written, a screenshot or a video. To have tickets sent to your app, tick ticket.created for it in Settings, Developers. Answer with your own ticket number and your customer sees that instead of Blip's. Video reports still come as video.reported too, as they always have.
A recorded report gets a transcript, a summary and chapters, the same as any Blip. Your team reads them under the recording in the Support inbox. They're written a few minutes after the ticket is filed, so they aren't in ticket.created: tick ticket.transcript_ready to have them sent to your app when they're done.
/api/support/ticketsScope the support button's pass
What the support recorder sends: a ticket written, with screenshots, or with its recording. It's called from the recorder's own page, which holds your button's pass (ticket); people signed in to Blip use it for Blip's own Report a problem without one.
| Fields | Type | Notes |
|---|---|---|
ticket | string | The support recorder's pass for your button |
kind | "text" | "screenshot" | "video" | What it is |
body | string? | What happened, up to 5,000 characters |
title | string? | A few words. Without it, the first line of body |
severity | "minor" | "major"? | Minor unless they say |
reporter_name, reporter_email | string | Who sent it |
page_url, browser, viewport | string? | Where they were: the page (only on your button's sites, without its query), the browser, and the window as 1280x800 |
errors | string[]? | Errors the page caught, 20 at most |
network | object[]? | Failed requests the page made: {method, url, status, ms} each, 20 at most. Only those four are kept, and the address loses its query string |
identity | object? | Who your server signed the visitor as: {email, name, id?, expires, signature} from signSupportUser. Checked against the button's secret |
file_ids | string[]? | Screenshots sent first, 5 at most. Needed for a screenshot |
upload | string? | The recording's upload pass from the recorder. Needed for a video |
{ "ok": true, "ticket": { "number": 12, "ref": "SUP-214",
"status_url": "https://sendablip.com/ticket/3f9a1c07b2d84e6a9c51f0e27b3d6a84" } }ref is what to show them: your app's ticket number if it answered with one, otherwise Blip's, like #12. status_url is their status page for it.
/api/support/tickets/filesScope the support button's pass
One screenshot, sent as a form with file and ticket: PNG, JPEG or WebP, 8 MB at most. Blip checks what it is from the file itself. Name its id in the ticket's file_ids within a day, or it's cleared out. Screenshots are kept private; your app gets links on https://sendablip.com/api/files/... that work for 7 days, and stop when the ticket is deleted. A link answers ranges (Range: bytes=0-) and never sends you on to another address.
{ "id": "5b0e...", "content_type": "image/png", "width": 1440, "height": 900, "bytes": 412337 }The status page
When someone sends a report, the card says "Sent. Ticket #12" and gives them a link: See your ticket. That link is their status page. No login. It shows where the ticket stands in plain words ("We've got it", "We answered, over to you", "Sorted"), what they sent, every answer in order, and a reply box. After an answer it asks Did this solve it? Yes closes the ticket; a reply on a closed ticket reopens it. It wears your brand, on your own domain when your workspace has one in Blip, otherwise on sendablip.com.
The link opens that one ticket and nothing else, and Blip keeps only a scrambled copy of its code. It doesn't run out while the ticket is open; 90 days after it closes, the page shows only the status. Your team's own notes are never on it. The support card also remembers the tickets sent from that browser, under Your reports, with "New answer" on any you've answered. So even with no email set up, your visitor learns they were answered.
If your workspace has published guides, the card shows them with no change to your install: the ones you set for the page it's opened on, and ones that match what the person types in "What happened". A guide opens inside the card and ends with "Did this solve it?". Send is on screen the whole time, so nobody has to read or search before they can report. With no published guides the card is exactly as before.
You can also put your guides on a page of their own, with a search and a guide on each page: switch it on in Blip, under Guides. It's at Blip's address, or on your own domain if your plan has one, and its Report a problem button opens this same card.
Your team hears by email from Blip: a new ticket, and a visitor's reply (owners, admins and staff; each can turn theirs off in Settings, Profile). A reply moves the ticket back to To do.
Emails about tickets
Blip never emails your visitor. An email to your visitor is your email: it leaves from your domain, in your words and your design, from the tool you already use. Blip has no email templates and no editor. It tells your tool what happened and hands over the facts, as one signed event at each moment in a ticket's life.
| Event | Type | Notes |
|---|---|---|
ticket.created | A report is filed | For an email like "We've got your report, ticket #12" |
ticket.replied | A message is added | message.from says team (your answer: "Here's our answer") or visitor (they wrote back on their ticket page) |
ticket.status_changed | Done, moved or reopened | ticket.status, ticket.previous_status and ticket.solved_by (visitor, team, or no_reply: closed after silence, not solved). "Your ticket is sorted" |
ticket.solved_check | An answer went unanswered | After the days you set in Settings, Email (off until you turn it on). "Did that fix it?" |
Two ways to get them. A webhook: in Settings, Email, paste the https address your email tool listens on (a GoHighLevel workflow's Inbound Webhook, a Zapier, Make or n8n hook, or a route in your app). Blip posts every event there, signed with that webhook's own secret, and Send a test posts a sample of each. Or your app: tick the events for it in Settings, Developers and they come to its webhook. With neither, your visitors aren't emailed, and your inbox says so.
| Fields | Type | Notes |
|---|---|---|
type, event_key | string | Which moment, and a key that's the same on every retry: send one email per event_key |
visitor | object | name, first_name, email, verified (true only when Blip knows who they are: your server signed them; a typed name and email aren't checked), external_id (the id you signed, or null) |
ticket | object | id, number, ref (what they were shown: your own number if your app gave one), title, body, kind, severity, status, status_words ("We answered, over to you"), solved_by, reopened_count, origin, page, created_at, assignee ({id, name} of who on your team has it, or null) and tags |
message | object? | ticket.replied only: id, from (team or visitor), author_name, body (plain text), via (inbox, status_page, email, api) |
video | object? | ticket.replied only, when your team answered with a video: id, title, url (their status page at that answer, where it plays), thumbnail_url (a picture of it, served by Blip while the ticket's link works), duration_seconds and duration ("1:42"). For a line like "Watch the answer (1:42)" over the picture. Sent once the video is ready to watch |
status_url | string | Their status page. Put it in every email: it's how they read the thread and answer |
solved_url, not_solved_url | string | The status page opened on "Did this solve it?". Opening one changes nothing: they press the button there |
reply_token | string | How a reply by email comes back to this ticket: see Replies by email, below. It adds a reply and can read nothing |
button | object? | The support button it came through: name, ref (your product or queue), site |
workspace | object | id, name |
delivery_id, sent_at | string | This delivery, and when. A test from Settings, Email also has test: true |
{ "type": "ticket.replied", "event_key": "ticket.replied:9c2e...",
"visitor": { "name": "Dana Reyes", "first_name": "Dana", "email": "dana@example.com", "verified": false, "external_id": null },
"ticket": { "id": "...", "number": 12, "ref": "#12", "title": "I pressed Pay and nothing happened",
"body": "I pressed Pay and nothing happened", "kind": "text", "severity": "minor",
"status": "waiting", "status_words": "We answered, over to you", "solved_by": null, "reopened_count": 0,
"origin": "support_button", "page": "https://example.com/checkout", "created_at": "2026-11-12T15:00:00.000Z" },
"message": { "id": "...", "from": "team", "author_name": "Ann", "body": "Fixed. Try again? I recorded how.", "via": "inbox" },
"video": { "id": "...", "title": "How to pay again", "url": "https://sendablip.com/ticket/...#answer-...",
"thumbnail_url": "https://sendablip.com/api/support/tickets/.../video/....jpg",
"duration_seconds": 102, "duration": "1:42" },
"status_url": "https://sendablip.com/ticket/...",
"solved_url": "https://sendablip.com/ticket/...?solved=yes",
"not_solved_url": "https://sendablip.com/ticket/...?solved=no",
"reply_token": "rt_4f1c...",
"button": { "name": "Website", "ref": null, "site": "example.com" },
"workspace": { "id": "...", "name": "Acme" },
"delivery_id": "...", "sent_at": "2026-11-12T16:00:00.000Z" }There's no subject, no HTML and no template in it: your tool decides what email, if any, each becomes. It's signed like every Blip webhook, and retried the same way: six tries over a day, and a 4xx stops. Settings, Email lists each send and what your tool answered. Blip can say your tool took an event; whether the email was sent or opened is your tool's to say.
Setting it up. Settings, Email has a guided setup: pick your tool (GoHighLevel, Zapier, Make, n8n, your own code, or someone else who looks after your email), follow its steps, and send a test. The page then says where the connection stands (not set up, connected, tested, or stopped answering) and reads a failure for you: a 404 is an address that's wrong or a workflow that's off, a 401 is a key your tool wanted, no answer in 15 seconds is a tool that works before it answers. The same steps are in the install guide, Step 5B, with a few lines of Node and a ready-made n8n workflow to import.
Tickets: replies by email
When someone presses Reply on your email, their words go to your mailbox, not to their ticket: it's your domain, and Blip reads no mailbox. The ticket page link in your email always works. To bring emailed replies onto the ticket as well, your tool posts them back to Blip.
1. Send the reply token out. Every ticket event carries reply_token, one per ticket. Put it where it comes back with the reply: a last line of your email like Ref: rt_4f1c... (mail programs quote it back), or the reply-to address, support+rt_4f1c...@your-domain, if your mailbox takes plus addresses.
2. Post the reply. When a reply arrives, your tool sends its words to POST https://sendablip.com/api/support/tickets/reply, as JSON or form fields. No key: the token is the key, for that one ticket. It adds a reply and can read nothing; the answer says nothing about the ticket.
| Fields | Type | Notes |
|---|---|---|
text | string | The email's plain text, as it arrived (or html, if that's all your tool has) |
reply_token | string? | The ticket's token. Leave it out and Blip finds it in to, subject or the words |
to | string? | The address the reply was sent to, when the token is in it |
subject | string? | The email's subject |
message_id | string? | The email's own Message-ID: the same email posted twice is taken once |
attachments | number? | How many files it had. They aren't taken; the ticket says they stayed in the email |
curl -s -X POST https://sendablip.com/api/support/tickets/reply \
-H "content-type: application/json" \
-d '{"reply_token": "rt_4f1c...", "text": "Still broken on my phone.\n\nOn Thu, Nov 12, 2026 at 3:04 PM Acme <help@acme.com> wrote:\n> Fixed. Try again?", "message_id": "<CAF...@mail.gmail.com>"}'{ "ok": true, "reopened": false, "duplicate": false }Blip keeps what they wrote and drops the email it quotes (from the "On ... wrote:" line down, quoted lines, an "Original Message" divider) and the signature mark with what's under it. The reply goes on the ticket as the visitor's, moves it back to To do, and reopens a closed one, exactly as a reply from their ticket page does: your team is told, and ticket.replied fires with message.from: "visitor" and via: "email".
Who sent it isn't checked. The reply token is in an email, so whoever has that email can reply to it, as with any email. Blip takes what your tool posts and says how it came: your team reads By email, through your tool and Sender not checked on it, and it is never shown as verified, even on a ticket whose visitor is. visitor.verified is about who filed the ticket; a reply's via is about that reply. Ticket page replies need a verified visitor (Verified visitors) covers the ticket page's reply box, not this: a reply by email is taken with that switch on or off. To hold emailed replies to the same person, have your tool compare the sender's address with visitor.email before it posts.
| Answer | Type | Notes |
|---|---|---|
200 | Taken. duplicate: true means this email was already on the ticket; reopened: true means it reopened a closed one | |
400 | No token anywhere in it, or no words of theirs (only what it quotes) | |
404 | The token isn't a ticket's | |
410 | The ticket closed more than 90 days ago and takes no replies | |
413 | Over 256 KB. Send the plain text only | |
429 | Too many: 20 replies an hour on one ticket |
The token in Send a test is a made-up one (rt_0000...). Post a reply with it and Blip answers {ok: true, test: true, kept}, where kept is what it would keep, and touches no ticket. With an API key instead of a token: POST /api/v1/tickets/:number/messages with {as: "visitor", body}. Settings, Email walks through it for GoHighLevel, Zapier, Make, n8n and your own code; the install guide has the same steps. Forwarding straight from an email provider isn't built yet.
Tickets: the API
Your own key can work your Support inbox: list and read tickets, answer the visitor, add a note, assign, tag and move a ticket. A ticket is named by its number in your workspace (the 12 in #12). A number that isn't one of your workspace's tickets answers 404.
Three scopes, because reading, changing and speaking to a visitor are different risks. tickets:read reads.tickets:write adds notes, assigns, tags and moves. Answering a visitor needs tickets:write and tickets:reply, so a key can be allowed to sort and note without being allowed to send.
Limits, per key: 600 reads and 120 writes a minute; 30 answers an hour on one ticket and 600 an hour in all. A request body is 32 KB at most and a message 5,000 characters. Every read is in your access log and every write in your audit log, naming the key. A message sent with a key says so on the thread in Blip.
/ticketsScope tickets:read · Plugin listTickets
Your workspace's tickets, newest first. A filter that isn't right is refused with a 400, never ignored.
| Query | Type | Notes |
|---|---|---|
status | string? | open (To do), waiting or done. Without it, all three |
q | string? | The inbox's search: the ticket, its messages and notes. #12 is ticket 12 alone |
assignee | string? | A person's id (GET /tickets/team), or none for unassigned |
button | string? | A support button's id |
tag | string? | One tag |
kind | string? | text, screenshot or video |
severity | string? | minor or major |
since | time? | Filed at or after |
updated_since | time? | Changed at or after |
limit | number? | 50 unless said, 100 at most |
before | number? | The next_before of the page before |
{ "tickets": [{
"id": "7d1f...", "number": 12, "ref": "#12", "title": "I pressed Pay and nothing happened", "body": "...",
"kind": "text", "severity": "minor", "status": "open", "status_words": "We've got it",
"solved_by": null, "reopened_count": 0, "awaiting_team": false,
"origin": "support_button", "page": "https://acme.com/checkout", "site": "acme.com",
"visitor": { "name": "Dana Okafor", "first_name": "Dana", "email": "dana@example.com", "verified": false, "external_id": null },
"assignee": { "id": "5b0c...", "name": "Ben", "email": "ben@acme.com" }, "tags": ["billing"],
"button": { "id": "c2a9...", "name": "Website", "ref": null }, "app_ref": null, "app_url": null,
"status_url": "https://sendablip.com/ticket/9f3a...",
"created_at": "2026-11-12T15:04:05Z", "updated_at": "2026-11-12T15:04:05Z",
"first_answer_at": null, "last_team_at": null, "last_visitor_at": null, "solved_at": null }],
"next_before": null }visitor is the same here as in every ticket event: verified is true when your server signed them (Verified visitors) or they were signed in to Blip, and external_id is the id you signed, or null.
/tickets/:numberScope tickets:read (PATCH: tickets:write) · Plugin getTicket, updateTicket, setTicketStatus
GET /tickets/:number is one ticket, whole: everything the list gives, and browser, viewport, errors (the page's last errors), files (the screenshots; each link is on sendablip.com/api/files/ and works for 7 days), video (the visitor's recording: player_url, and transcript with its state, summary, chapters, action_items and text), messages (the thread, oldest first) and email (how your visitors get emailed).
A message's kind is answer (your team's), visitor (theirs), note (your team's own) or event (something that happened). visible_to_visitor says whether it's on their ticket page; it is never true for a note. Never show a note to a visitor. ?view=visitor gives the thread as its visitor sees it: no notes, none of your team's own events, no team member's id. Use it for anything you build for the visitor.
{ "id": "a41c...", "kind": "answer", "from": "team", "via": "api", "event": null, "visible_to_visitor": true,
"author": { "id": null, "name": "Ben" }, "key": { "id": "e19b...", "name": "Triage bot" },
"body": "Thanks for telling us. We fixed it, so try again.", "created_at": "2026-11-12T15:20:00Z",
"video": null, "notice": { "state": "sent", "attempts": 1, "last_error": null }, "held": false }PATCH /tickets/:number changes any of status, assignee and tags, and answers {ticket, changed}. Sending what's already so changes nothing (changed: []), so a PATCH is safe to send again.
| Fields | Type | Notes |
|---|---|---|
status | string? | open, waiting or done |
assignee | string | null? | The id or email address of someone who works on Support in your workspace; null for nobody |
tags | string[]? | All of the ticket's tags, from your workspace's tag list |
/tickets/:number/messagesScope tickets:write (an answer: and tickets:reply) · Plugin replyToTicket, addTicketNote, addVisitorReply
{as: "team", body} answers the visitor. It goes on their ticket page, and ticket.replied goes to your email webhook and connected apps, so your own tool emails it from your domain. Blip sends the visitor no email. {as: "note", body} adds a note for your team: the visitor never sees it and no email tool is told. {as: "visitor", body} brings in the visitor's own reply that reached you by email (Replies by email): body is the email's plain text as it came, with message_id and attachments if you have them. It needs tickets:write only. It's kept as via: "email" and marked on the thread as by email, never as verified.
| Fields | Type | Notes |
|---|---|---|
as | string | team (an answer), note, or visitor (their emailed reply) |
body | string | Plain text, 5,000 characters at most |
then | string? | An answer only: where the ticket goes. waiting unless said; open or done |
author_name | string? | An answer only: who the visitor reads it's from. Without it, your business's name |
Send an Idempotency-Key header (anything, 200 characters at most) and the same key again on the same ticket adds nothing: it answers 200 with the first message and replayed: true. A first send answers 201.
{ "message": { "id": "a41c...", "kind": "answer", ... }, "ticket": { "number": 12, "status": "waiting", ... },
"notice": { "state": "sent", "error": null }, "apps": 1, "replayed": false }notice is how telling your email webhook went, or null when your workspace has none.
/tickets/teamScope tickets:read · Plugin getTicketTeam
Who a ticket can be assigned to (your owner, admins and staff) and the tags a ticket can carry. Each person's role is owner, admin or staff.
{ "people": [{ "id": "5b0c...", "name": "Ben", "email": "ben@acme.com", "role": "admin" }], "tags": ["billing", "bug"] }Blip Reply
Webhooks
Blip POSTs JSON to your app's webhook address. Each is signed with your app's secret: x-blip-signature is the hex HMAC-SHA256 of `${x-blip-timestamp}.${rawBody}`. Refuse anything older than five minutes. Read the raw body before parsing it.
import { createHmac, timingSafeEqual } from "node:crypto";
const ts = req.headers.get("x-blip-timestamp");
const sig = req.headers.get("x-blip-signature");
const raw = await req.text();
const want = createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || !sig || !timingSafeEqual(Buffer.from(want, "hex"), Buffer.from(sig, "hex"))) {
return new Response("bad signature", { status: 401 });
}Answer 2xx once it's handled. Anything else is retried after 5, 15, 60, 360 and 1,440 minutes, and stops on a 4xx other than 408, 425 or 429. A retry has the same delivery_id, so dedupe on it.
| Type | Type | Notes |
|---|---|---|
video.ready | always / if ticked | A Blip is ready: always for Blips from your recorder links, and for any of the workspace's if ticked. The video, as GET /videos returns it |
video.reported | if ticked | A bug report or support ticket: video, reporter, finding {title, severity, description (HTML: page, browser, steps), meta}. Answer {task: {id, ref, url}} and the reporter sees ref |
ticket.created | if ticked | A support ticket: ticket {number, kind, title, body, severity, page, browser, viewport, errors, network}, reporter {name, email, guest}, files [{url (on sendablip.com/api/files/, works for 7 days), content_type, width, height}], video {id, url, transcript_state} or null, project_ref, workspace, and what every ticket event has: visitor, status_url, button. Answer {ticket: {ref, url}} and the reporter sees ref. transcript_state is queued, running, done or none: the recording's words follow in ticket.transcript_ready |
ticket.replied | if ticked | A message was added to a ticket: message {id, from (team or visitor), author_name, body, via}, with visitor, ticket, status_url, button, workspace, and video {id, title, url, thumbnail_url, duration_seconds, duration} when the team answered with a video. For an app that emails the visitor itself |
ticket.status_changed | if ticked | A ticket was marked done, moved or reopened: ticket.status, ticket.previous_status, ticket.solved_by. Not sent for the move an answer or a reply makes by itself |
ticket.solved_check | if ticked | An answer has had no reply for the days the workspace set: solved_url and not_solved_url open the status page on "Did this solve it?" |
ticket.assigned | if ticked | A ticket was assigned to someone on your team, or to nobody: assignee and previous_assignee ({id, name, email}, or null), by, with the ticket as every ticket event has it. To apps only, never an email webhook |
ticket.note_added | if ticked | Your team added a note to a ticket: note {id, author_name, body}, by. A note is your team's own: the visitor never sees it, so don't put it in anything they read |
ticket.tagged | if ticked | A ticket's tags changed: tags (all of them now), added, removed, by |
ticket.transcript_ready | if ticked | What was said in a video ticket's recording, once per ticket, when its transcript is written (a few minutes after ticket.created; never for a recording nobody speaks in): ticket {id, number, title, ref (your own number, if you answered ticket.created with one)}, video {id, url}, transcript {language, summary, chapters [{start, title}], action_items [{title, description, quote}], text (cut at 100,000 characters), truncated}, workspace. The timed lines are at GET /videos/:id/transcript |
thread.message | if ticked | A message was added to a Thread, the AI's answers included (message.role "ai"): thread and message, as the Threads calls return them (a voice note arrives with voice.transcript_state pending). Notify whoever should know: a student's message, the assigned coach; a team message, the student. In a group it also has members {count, ids, complete}: the students who can read it |
thread.assigned | if ticked | A Thread was given to a coach or an assistant: thread (thread.assigned_to), by |
thread.answered | if ticked | A waiting Thread was answered: thread, by, and message_id (null when it was marked answered without a reply) |
thread.person_asked | if ticked | A student asked for a person instead of the AI: thread (thread.person_asked_at), by. Tell your team |
thread.draft | if ticked | The AI left a draft answer for your team: thread, draft {text, sources, for_message_id, needs_person, partial}. For your team only, never the student |
video.tasks | if ticked | Action items, or a comment, to become tasks: video, project_ref, kind, items [{key, title, description}]. Answer {tasks: [{key, id, ref, url}]} |
reply.invite_ready | always | An invite is ready for your app to send: url, expires_at, person, questions, meta, reply {name, heading, intro, thank_you, minutes}, reward {label, note, image_url, url, code}, brand {name, logo_url, accent_hex, glow_hex, background_hex, website_url, support_email} or null. Everything your email needs |
reply.reminder | always | The one reminder, with a new url; the old one has stopped |
reply.completed | always | They finished: reward_code (the same for everyone), reward {label, note, image_url, url, code}, brand, finished_at, answers {videos, texts} |
reply.cancelled | always | An invite was cancelled |
reply.opened | if ticked | They opened their link, once |
reply.started | if ticked | They answered the first question, once |
reply.expired | if ticked | Their link ran out before they finished, once |
reply.tagged | if ticked | Your team tagged their answers: review is use, maybe or no, with previous_review |
reply.clip_published | if ticked | A clip went live: clip {code, path, embed_path, wall, ...} |
reply.clip_taken_down | if ticked | That clip came down, with taken_down_reason |
webhook.test | on request | From POST /deliveries/test, to check your route. Answer 2xx |
video.alert | set up by Blip | One of Blip's own alerts, for an app that shows them in its own bell: alert_id, kind (video_watched, video_link, video_overdue, video_comment, video_view, video_assigned...), title, body, path, entity {type, id}, recipient {user_id, email}, actor_user_id. Names a viewer only to a recipient who may see who watched (Settings, Workspace) |
video.link_milestone | set up by Blip | Someone opened or finished a Blip through their personal link: video {id, title, owner_id, url}, link {id, person_name, person_email, created_by}, milestone (link_view or link_finished), body |
video.gate_entry | set up by Blip | Someone typed their name and email to watch a Blip that asks for them: video {id, title, owner_id, url}, entry {id, name, email} |
"If ticked" types go only to apps that pick them in Settings, Developers. "Set up by Blip" types go to an app Blip's team connects for a workspace, such as Project System. Every reply.* event carries invite_id, reply_id, external_id, person and your meta.
Viewer names in webhooks
Each app has a switch in Settings, Developers: Include viewer names and emails. It's on unless a workspace admin turns it off, so an app keeps getting what it always got. Off, the events that say who watched a Blip still arrive, in the same shape, saying that someone did, and carry viewers_hidden: true. Retries and redeliveries follow the switch as it is when they're sent. Only the workspace's owner and admins can change it, and each change is in the workspace's audit log.
| With the switch off | Fields | Notes |
|---|---|---|
video.alert | watching alerts | For kind video_watched, video_link and video_overdue: title is the same words without the name ("A client watched Onboarding", "Someone with a personal link finished Onboarding", "Someone hasn't finished Onboarding"), and actor_user_id is null. recipient stays: it's the person on your team the alert is for. Other kinds name no viewer and don't change |
video.link_milestone | link.person_name, link.person_email | null. body says "Someone watched the video ..." or "Someone finished the video ...". link.id, link.created_by, video and milestone stay |
video.gate_entry | entry.name, entry.email | null. entry.id and video stay, so you can still count entries |
The switch is about people who watch your Blips. It doesn't change a Reply's person (your app sent them), a ticket's reporter or visitor (they wrote to you), a Thread's students, a comment's author (a comment is on the Blip for everyone who can see it) or your own team.
/deliveriesScope any scope, app key · Plugin listDeliveries, redeliver
The webhooks Blip sent your app, or is still trying to send: newest first, 50 by default and 200 at most. Filter with ?state=(queued, sent or failed) and ?type=. POST /deliveries/:id/redeliver sends one again within a few minutes, with the same delivery_id and a fresh signature: for one that failed while your app was down.
{ "deliveries": [{ "id": "...", "type": "reply.completed", "state": "failed", "attempts": 6,
"last_status": 500, "last_error": "...", "created_at": "...", "sent_at": null,
"next_attempt_at": "...", "invite_id": "..." }] }/deliveries/testScope any scope, app key · Plugin testWebhook
Sends {type: "webhook.test", delivery_id, sent_at} to your app's webhook address now, signed like every other, and tells you what it answered. It isn't kept or retried. ok is true for a 2xx; a 401 usually means your route's secret isn't the app's signing secret, or it parsed the body before checking it. The address has to be a public https name.
{ "ok": true, "status": 200, "answer": "{"ok":true}", "error": null,
"url": "https://acme.com/api/blip/webhook", "ms": 184 }The plugin
blip.ts (version 2.27.0) wraps every call above and the webhook check, typed, with no dependencies. Copy it into your server code and set BLIP_API_KEY and, for webhooks, BLIP_WEBHOOK_SECRET. BLIP_URL is https://sendablip.com unless you set it. GET /me says the latest version appVideoUrl, watchUrl and embedUrl make a Blip's addresses in Blip, on its share page and in a player.
curl -o src/lib/blip.ts https://sendablip.com/plugin/v1/blip.ts
curl -o src/components/blip.tsx https://sendablip.com/plugin/v1/blip-react.tsx # React appsimport { requireBlip, whoami, listVideos, readWebhook } from "./blip";
const blip = requireBlip(); // throws, saying what's missing, if BLIP_API_KEY isn't set
const me = await whoami(blip);
if (me.ok) console.log(me.data.next_steps);
const r = await listVideos(blip, { ref: "yourapp:project:142" });
if (r.ok) console.log(r.data.videos.length);
const w = readWebhook(process.env.BLIP_WEBHOOK_SECRET!, raw, req.headers);
if (w?.type === "reply.completed") giveReward(w.reward_code, w.invite_id);
/videos/:id/commentsScope
videos:read· PluginlistCommentsA Blip's comments, oldest first, each at its second in the video when it has one. A visitor's name is what they typed, marked
guest.