Skip to content

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.

Paste this to your AI
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:

shell
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).

GET/me

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

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

ScopeTypeNotes
videos:readList and read Blips, requests and assignments
videos:writeRecorder links, personal links, requests, access
replies:readList Reply invites
replies:writeSend events, make and cancel invites, erase a person
threads:readRead Threads, their people and messages
threads:writeAdd people, open Threads, post messages, assign and split
tickets:readList and read support tickets: the thread, notes, screenshots, recording
tickets:writeAdd notes, assign, tag and move tickets
tickets:replyAnswer 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:

json
{ "error": "This key can't do that. It needs videos:write." }
StatusTypeNotes
400Something in the request isn't right; the error says what
401No key, or the key isn't valid or was revoked
403The key lacks the scope, or its app is off, or the call needs an app key
402The workspace's plan doesn't include the API (Business and Reply do); the error says what to do
404Not found in this workspace
413The request body is too big
429Too 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

POST/recorder-sessions

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

FieldsTypeNotes
user_iduuidThe person in Blip
emailstringTheir email
originstringYour site, like https://app.yourtool.com. The recorder refuses to load anywhere else
html
<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>
PUT/access

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

FieldsTypeNotes
emailstringThe person
refsstring[]Every ref they can see
json
{ "synced": 12 }
PUT/refs

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

FieldsTypeNotes
refs{ref, label, archived?}[]Every place, 10,000 at most
json
{ "listed": 22 }
PUT/members

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

FieldsTypeNotes
people{email, name?}[]Everyone allowed in, 5,000 at most. A name fills theirs in Blip if it has none
json
{ "listed": 24 }

Videos

GET/videos

Scope videos:read · Plugin listVideos, getVideo

Your workspace's Blips, newest first. GET /videos/:id returns one.

QueryTypeNotes
refstring?Only Blips with this ref
ref_prefixstring?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
limitinteger?50 by default, 100 at most
beforestring?The next page: next_before from the last answer
player1?Add a signed player address for your own team, which works with the share link off
json
{
  "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.

PATCH/videos/:id

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

FieldsTypeNotes
titlestring?Up to 200 characters
refstring | null?Your ref; null takes it off
share_enabledboolean?The share link on or off
json
{ "video": { "id": "4f1c...", "title": "Q3 plan, final", ... } }
GET/videos/:id/transcript

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

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

GET/videos/:id/comments

Scope videos:read · Plugin listComments

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

json
{ "comments": [{ "id": "...", "body": "Can we keep the old button?", "at_seconds": 42,
  "created_at": "2026-11-09T16:20:00Z", "author": { "name": "Ruth Ellis", "guest": false },
  "task": { "label": "LP-150", "url": "https://..." } }] }
GET/videos/:id/stats

Scope videos:read · Plugin getVideoStats

How a Blip is doing. The owner watching their own doesn't count.

json
{ "stats": { "views": 31, "viewers": 18, "minutes_watched": 64.5,
  "completed": 12, "button_clicks": 4, "reactions": { "🔥": 3, "👏": 5 } } }
POST/requests

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

FieldsTypeNotes
refstringWhat it's for
labelstring?What they see it's for, up to 120 characters
notestring?A note to them, up to 1,000 characters
recipient_namestring?Who it's for
due_atdatetime?When you need it
expires_in_daysinteger?1 to 365, 30 by default
multi_useboolean?Keep taking recordings after the first
max_minutesinteger?1 to 60
json
{ "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.

GET/assignments

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

json
{ "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" }] }
PUT/threads/people

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

FieldsTypeNotes
people[].idstringYour id for the person
people[].namestringTheir name, as the other side sees it
people[].emailstring?Their email
people[].rolestringstudent, coach or assistant
people[].activeboolean?false switches them off; true by default
json
{ "saved": 3 }
POST/threads

Scope 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).

FieldsTypeNotes
studentstringYour id for the student
refstring?Your name for what it's about, like a course
titlestring?A title, up to 200 characters
json
{ "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?" } }
POST/threads/:id/messages

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

FieldsTypeNotes
fromstringYour id for who's posting (leave out with as: ai)
kindstring?text (default) or video. A voice note is sent as its sound file: see below
textstring?The words, up to 10,000 characters
video_idstring?For video: a Blip in your workspace
reply_tostring?A message of this thread to put it under
json
{ "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.

FieldsTypeNotes
Content-Typeheaderaudio/webm, audio/mp4 or audio/m4a: what a browser's or a phone's recorder makes
(body)bytesThe sound file, 4 MB and 5 minutes at most
fromqueryYour id for who's posting
secondsqueryIts length, a whole number. Blip measures it again when it listens
reply_toquery?A message of this thread to put it under
json
{ "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" } }
POST/threads/groups

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

FieldsTypeNotes
namestringThe group's name, up to 200 characters
refstring?Your name for what it's for, like a cohort. One group per ref
membersstring[]?Your ids for the students to put in it (send them with PUT /threads/people first)
bystring?The coach doing it, if a person is. Leave it out when it's your app
json
{ "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 } }
json
// 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 } }
POST/threads/sessions

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

FieldsTypeNotes
user.idstringYour id for the signed-in person
user.namestringTheir name, as the other side sees it
user.emailstring?Their email
user.rolestringstudent, coach or assistant
originstringThe site the screen will be on, like https://yourapp.com. In a phone app: capacitor://localhost
json
{ "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" } }
tsx
// 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)} />
html
<!-- 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.

PUT/threads/settings

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

FieldsTypeNotes
student_minutesnumber?Video: whole minutes a month, 0 to 100000
student_voice_minutesnumber?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_answersnumber?AI answers and drafts a calendar month. Yours to lower; never more than ai_monthly_answers_max
json
{ "student_minutes": 60, "student_voice_minutes": 120,
  "ai_mode": "off", "ai_monthly_answers": 500, "ai_monthly_answers_max": 500, "ai_answers_used": 0 }
PUT/threads/knowledge

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

FieldsTypeNotes
titlestringThe document's name, shown to students as the answer's source. Optional for a Blip (its own title is used)
textstring?Pasted text, 100,000 characters at most
video_idstring?Instead of text: a Blip in your workspace, for its transcript
video_refstring?Instead of video_id: your own ref for the Blip
refstring?Your own name for the document. The same ref again replaces it
json
{ "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.

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

html
<script src="https://sendablip.com/plugin/v1/support.js"
  data-token="YOUR_TOKEN" async></script>
AttributeTypeNotes
data-tokenstringYour 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-colorhex?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-labelstring?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.

Build your button

Pick the look, your colour and your logo. The code below rebuilds as you go: for any site, for React, or to hand to your AI.

Look
Side
Icon
Opens on
Preview
The card that opens takes your logo and colour from Blip, Settings, Brand.
Code
<script src="https://sendablip.com/plugin/v1/support.js" data-token="YOUR_BUTTON_TOKEN" async></script>
Paste your token above and it goes into the code.
POST/api/support/tickets

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

FieldsTypeNotes
ticketstringThe support recorder's pass for your button
kind"text" | "screenshot" | "video"What it is
bodystring?What happened, up to 5,000 characters
titlestring?A few words. Without it, the first line of body
severity"minor" | "major"?Minor unless they say
reporter_name, reporter_emailstringWho sent it
page_url, browser, viewportstring?Where they were: the page (only on your button's sites, without its query), the browser, and the window as 1280x800
errorsstring[]?Errors the page caught, 20 at most
networkobject[]?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
identityobject?Who your server signed the visitor as: {email, name, id?, expires, signature} from signSupportUser. Checked against the button's secret
file_idsstring[]?Screenshots sent first, 5 at most. Needed for a screenshot
uploadstring?The recording's upload pass from the recorder. Needed for a video
json
{ "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.

POST/api/support/tickets/files

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

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

EventTypeNotes
ticket.createdA report is filedFor an email like "We've got your report, ticket #12"
ticket.repliedA message is addedmessage.from says team (your answer: "Here's our answer") or visitor (they wrote back on their ticket page)
ticket.status_changedDone, moved or reopenedticket.status, ticket.previous_status and ticket.solved_by (visitor, team, or no_reply: closed after silence, not solved). "Your ticket is sorted"
ticket.solved_checkAn answer went unansweredAfter 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.

FieldsTypeNotes
type, event_keystringWhich moment, and a key that's the same on every retry: send one email per event_key
visitorobjectname, 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)
ticketobjectid, 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
messageobject?ticket.replied only: id, from (team or visitor), author_name, body (plain text), via (inbox, status_page, email, api)
videoobject?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_urlstringTheir status page. Put it in every email: it's how they read the thread and answer
solved_url, not_solved_urlstringThe status page opened on "Did this solve it?". Opening one changes nothing: they press the button there
reply_tokenstringHow a reply by email comes back to this ticket: see Replies by email, below. It adds a reply and can read nothing
buttonobject?The support button it came through: name, ref (your product or queue), site
workspaceobjectid, name
delivery_id, sent_atstringThis delivery, and when. A test from Settings, Email also has test: true
json
{ "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.

FieldsTypeNotes
textstringThe email's plain text, as it arrived (or html, if that's all your tool has)
reply_tokenstring?The ticket's token. Leave it out and Blip finds it in to, subject or the words
tostring?The address the reply was sent to, when the token is in it
subjectstring?The email's subject
message_idstring?The email's own Message-ID: the same email posted twice is taken once
attachmentsnumber?How many files it had. They aren't taken; the ticket says they stayed in the email
sh
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>"}'
json
{ "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.

AnswerTypeNotes
200Taken. duplicate: true means this email was already on the ticket; reopened: true means it reopened a closed one
400No token anywhere in it, or no words of theirs (only what it quotes)
404The token isn't a ticket's
410The ticket closed more than 90 days ago and takes no replies
413Over 256 KB. Send the plain text only
429Too 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.

GET/tickets

Scope tickets:read · Plugin listTickets

Your workspace's tickets, newest first. A filter that isn't right is refused with a 400, never ignored.

QueryTypeNotes
statusstring?open (To do), waiting or done. Without it, all three
qstring?The inbox's search: the ticket, its messages and notes. #12 is ticket 12 alone
assigneestring?A person's id (GET /tickets/team), or none for unassigned
buttonstring?A support button's id
tagstring?One tag
kindstring?text, screenshot or video
severitystring?minor or major
sincetime?Filed at or after
updated_sincetime?Changed at or after
limitnumber?50 unless said, 100 at most
beforenumber?The next_before of the page before
json
{ "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.

GET/tickets/:number

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

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

FieldsTypeNotes
statusstring?open, waiting or done
assigneestring | null?The id or email address of someone who works on Support in your workspace; null for nobody
tagsstring[]?All of the ticket's tags, from your workspace's tag list
POST/tickets/:number/messages

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

FieldsTypeNotes
asstringteam (an answer), note, or visitor (their emailed reply)
bodystringPlain text, 5,000 characters at most
thenstring?An answer only: where the ticket goes. waiting unless said; open or done
author_namestring?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.

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

GET/tickets/team

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

json
{ "people": [{ "id": "5b0c...", "name": "Ben", "email": "ben@acme.com", "role": "admin" }], "tags": ["billing", "bug"] }

Blip Reply

POST/blip/events

Scope replies:write, app key · Plugin sendEvent

Tell Blip something happened, like a purchase. The Reply automations you set up decide whether to make invites, and when.

FieldsTypeNotes
event_idstringYour own id, so a retry is never counted twice
typestringLowercase, like purchase.completed
external_idstring?What it's about, like an order id: one invite per Reply per external_id, and what cancel finds
person{name?, email?, phone?, country?}?Who to ask. phone: their mobile, +15550000001 (or read in country, US by default), for a Reply that texts its invites too; one that isn't a real number is left off
dataobject?Fields automations match on. It becomes the invite's meta (with event_type), so its strings and numbers are fill-ins for the Reply's text too, like {product_name}. No payment details; 8 KB at most
json
{ "duplicate": false, "invites": [{ "reply_id": "...", "invite_id": "...", "send_at": "2026-11-12T15:00:00Z" }] }
POST/blip/invites

Scope replies:write (list: replies:read), app key · Plugin createInvite, listInvites

Make one invite yourself. GET /blip/invites?external_id=&email=&limit= lists them (50 by default, 200 at most).

FieldsTypeNotes
reply_iduuidThe Reply; it must be connected to your app
external_idstring?Your id for it
person{name?, email?, phone?, country?}?Who to ask. With phone (E.164, or read in country) and texts on for the Reply, Blip texts the link too; a phone that isn't real is a 400
metaobject?Comes back on every webhook about this invite. Each top-level string or number whose key is a-z, 0-9 and _ (40 at most) is a fill-in for the Reply's text: {"product_name": "the AI Playbook"} fills {product_name}. Blip's own (first_name, name, last_name, email, link, brand, expires, reward) win on a clash
delivery"app" | "link"?app: Blip sends you reply.invite_ready to email it yourself. link: the url comes back now
send_atdatetime?When it's ready
json
{ "invite_id": "...", "url": null, "duplicate": false }
POST/blip/cancel

Scope replies:write, app key · Plugin cancelInvites, erasePerson

Cancel by {external_id} or {invite_id}, for a refund say. Anything of theirs that was published comes down. POST /blip/erase with {email} removes a person's answers and clips everywhere.

json
{ "cancelled": 1, "taken_down": 0 }

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.

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

TypeTypeNotes
video.readyalways / if tickedA 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.reportedif tickedA 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.createdif tickedA 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.repliedif tickedA 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_changedif tickedA 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_checkif tickedAn 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.assignedif tickedA 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_addedif tickedYour 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.taggedif tickedA ticket's tags changed: tags (all of them now), added, removed, by
ticket.transcript_readyif tickedWhat 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.messageif tickedA 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.assignedif tickedA Thread was given to a coach or an assistant: thread (thread.assigned_to), by
thread.answeredif tickedA waiting Thread was answered: thread, by, and message_id (null when it was marked answered without a reply)
thread.person_askedif tickedA student asked for a person instead of the AI: thread (thread.person_asked_at), by. Tell your team
thread.draftif tickedThe 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.tasksif tickedAction items, or a comment, to become tasks: video, project_ref, kind, items [{key, title, description}]. Answer {tasks: [{key, id, ref, url}]}
reply.invite_readyalwaysAn 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.reminderalwaysThe one reminder, with a new url; the old one has stopped
reply.completedalwaysThey finished: reward_code (the same for everyone), reward {label, note, image_url, url, code}, brand, finished_at, answers {videos, texts}
reply.cancelledalwaysAn invite was cancelled
reply.openedif tickedThey opened their link, once
reply.startedif tickedThey answered the first question, once
reply.expiredif tickedTheir link ran out before they finished, once
reply.taggedif tickedYour team tagged their answers: review is use, maybe or no, with previous_review
reply.clip_publishedif tickedA clip went live: clip {code, path, embed_path, wall, ...}
reply.clip_taken_downif tickedThat clip came down, with taken_down_reason
webhook.teston requestFrom POST /deliveries/test, to check your route. Answer 2xx
video.alertset up by BlipOne 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_milestoneset up by BlipSomeone 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_entryset up by BlipSomeone 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 offFieldsNotes
video.alertwatching alertsFor 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_milestonelink.person_name, link.person_emailnull. body says "Someone watched the video ..." or "Someone finished the video ...". link.id, link.created_by, video and milestone stay
video.gate_entryentry.name, entry.emailnull. 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.

GET/deliveries

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

json
{ "deliveries": [{ "id": "...", "type": "reply.completed", "state": "failed", "attempts": 6,
  "last_status": 500, "last_error": "...", "created_at": "...", "sent_at": null,
  "next_attempt_at": "...", "invite_id": "..." }] }
POST/deliveries/test

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

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

shell
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 apps
ts
import { 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);