Integration guide
Connecting Buddy Assist to your own software — what it can read from you, how to have it call your APIs mid-conversation, and how to message your customers.
For developers wiring Buddy Assist into their own system.
Base URL
https://api.buddyassist.io/api/v1
Every path below is relative to that. Local development defaults to
http://localhost:3000/api/v1 (PORT is configurable).
0. Which part of this guide you actually need
Most businesses on Buddy Assist never open this document. They describe the business, add what they know — a site, a menu, documents, a shop — connect WhatsApp or the widget, and the assistant answers. There is no API and no playbook in that path, and it is the normal one.
This guide is for the case where you also have a live system worth asking. There are three separate APIs here and you almost certainly want one of them. They are not three steps of one setup:
| If you want to… | Use | Section |
|---|---|---|
| Let Buddy answer using your live data | Register your endpoints | 4 |
| Tell your endpoints which signed-in user is chatting | End-user token | 5 |
| Build your own chat interface instead of our widget | Chat API | 8 |
| Create procedures in code rather than the dashboard | Playbook API | 6 |
If you have a backend and want Buddy to use it, you need section 4 and nothing else: get a server key, register your endpoints, verify our signature. Channels are dashboard toggles. Procedures are written in the dashboard when you need them.
Add section 5 to that list if the chat sits inside your own product, behind your own login, and your endpoints need to know which user is asking. If it does not, skip it — every other integration is anonymous by nature and nothing in section 5 applies.
Sections 6 and 8 are for people building on top of Buddy — a white-label host provisioning tenants, or a team shipping their own UI. Skipping them is the normal case, not a shortcut.
1. How Buddy Assist fits together
Buddy Assist sits in three layers. Knowing which one you are working in tells you which half of this guide you need.
| Layer | What it is | Who builds it |
|---|---|---|
| Source | Where answers come from — your documents, and your live APIs | You and us, together. This is the integration. |
| Intelligence | Buddy: retrieval, the orchestrator, playbooks, the decision to call one of your endpoints | Us |
| Distribution | Where the conversation happens — WhatsApp, Instagram, Messenger, Telegram, TikTok, phone, your own app | Toggled in the dashboard |
You integrate once, at the source layer. Every channel is normalised to the same internal conversation before the intelligence ever sees it, so there is no WhatsApp integration and no Instagram integration to write — you connect your data and your endpoints once, and turn channels on in the dashboard.
The source layer works in two directions, and most real deployments use both:
- Passive — you give us content, or a URL we can read. Good for menus, policies, guides, specs. Covered in section 3.
- Active — you expose endpoints, and Buddy calls them mid-conversation to look something up or do something. Good for stock levels, order status, bookings, account data. Covered in section 4. This is the part most people mean when they say "integrate".
So there are two families of endpoints in this guide, and which you need depends on what you are building:
| You want to… | You need | Section |
|---|---|---|
| Let Buddy reach into your system for live answers | Register your routes with us | 4 |
| Tell those routes which signed-in user is chatting | An end-user token | 5 |
| Give Buddy content to answer from | Push documents, or a URL we read | 3 |
| Build your own chat interface on top of Buddy | Channel endpoints + sockets | 8 |
| Write procedures in code rather than the dashboard | Playbook API | 6 |
Most integrations are the first row and stop there. A read-only endpoint works as soon as it is registered — you do not need section 6 to make a lookup fire, and section 6 is only for procedures with a required order or a step that changes data, which are written in the dashboard unless you are provisioning them in bulk.
2. Authentication
Send your API key in a header:
x-api-key: YOUR_API_KEY
The header is the only way. A query parameter such as ?apiKey=... is not
read and the request will be rejected — query strings end up in server logs and
browser history, so keys do not travel that way.
Either key from your dashboard works:
- Your project key — the same one the widget uses. Bound to one project.
- A generated API key — created per integration, named, revocable, rotatable, and optionally given an expiry. Prefer this for anything running on your own server: it can be revoked on its own without disturbing the widget.
Three things to know:
-
A key is bound to exactly one project. The
:projectIdin the URL must be that project. Using a key against a different project returns403. -
No key at all returns
401, with the messageNo API key provided. Send your key in the x-api-key request header.If you see that, the header is not reaching us — check your HTTP client's default headers and any proxy in front of it before you suspect the key itself. -
Dashboard/admin endpoints use a JWT instead:
httpAuthorization: Bearer <your_jwt_token>Get one from
POST /auth/loginwith your email and password. Tokens last 30 days. The endpoints in section 8 accept either a JWT or a key.
Find your Project ID and both keys in the dashboard under your project.
3. Source, passive — getting your content in
Three options, in increasing order of effort.
a. Push documents to us
POST /documentation/:projectId/webhook-ingest
x-webhook-token: YOUR_WEBHOOK_TOKEN
json{ "documents": [ { "title": "How to reset your password", "content": "Navigate to Settings > Security...", "doc_type": "guide", "tags": ["security", "account"], "external_id": "kb-article-42" } ] }
external_id makes it an upsert — send the same id again and the document is
updated rather than duplicated. Get the token from Integrations → Data
Sources → Webhook.
There is also a document-sync API, if you would rather push one at a time or reconcile a whole collection:
POST /sync/:projectId/documents — one document
POST /sync/:projectId/documents/batch — up to 100 per request
GET /sync/:projectId/status — what we currently hold
Each document takes a mode: copy stores the text, index embeds it then
discards the raw text, reference stores neither and fetches live (next
section).
b. Let us pull from you — reference mode
Nothing is stored on our side. You register a document with mode: 'reference'
and a url; we store only the title and metadata, and fetch the body live when
a question matches it. Use this when the content changes often, or when you
would rather it never sat in our database.
Sign these requests so you know the fetch is genuinely ours — see section 7.
Limits you must design around:
- 3 second timeout. If your endpoint is slower we get nothing and the assistant answers without your data — silently. Make it fast, or cache on your side.
- 4,000 character cap. Longer responses are truncated. Return the relevant passage, not an entire page.
- Responses are cached for 5 minutes per document.
text/plain,text/htmlandapplication/jsonare accepted. HTML is stripped to text.
c. Connect a platform
GitBook, Notion, Confluence, Zendesk, Readme, WordPress, Shopify, or a file upload — all configured in the dashboard, no code required.
4. Source, active — letting Buddy call your system
This is the part that makes the assistant useful beyond FAQs. You register your endpoints as connectors. Mid-conversation, the orchestrator decides one of them is needed, fills in the arguments from what the customer said, calls it, and uses the response to answer.
Reference mode is for reading documents. Connectors are for looking things up and doing things — order status, stock, availability, booking, cancellation.
4.1 Registering your routes
You tell us what your endpoints are; we never crawl or guess. What you register becomes the assistant's tool catalog, and it is consulted on every turn, on every channel.
POST /projects/:projectId/integrations/import
x-api-key: YOUR_PROGRAMMATIC_API_KEY
Auth: a programmatic API key (server-to-server, so this can run from a
deploy script or CI) or a dashboard session (Authorization: Bearer <jwt>).
The public project key — the one embedded in your website's widget snippet
— is rejected here on purpose: it is visible to anyone viewing your page, and
must not be able to add endpoints to the assistant's tool catalog.
json{ "payload": { "openapi": "3.0.0", "paths": { } }, "base_url": "https://api.yourcompany.com", "secret": "the-credential-we-should-present", "default_intent_tags": ["ordering"] }
One connector is created per operation, with description, method and
request_schema taken from the spec. base_url overrides the spec's servers
entry, which matters when the spec was written for a different environment;
secret and default_intent_tags are applied to every connector created.
payload is shape-sniffed, so the same endpoint accepts a single tool
descriptor, an array of them, or an array of documents — useful if you would
rather register endpoints explicitly than maintain a spec. Re-importing
upserts by name rather than duplicating, so it is safe to run on every
deploy.
Registering one route explicitly:
json{ "payload": { "name": "get_order", "url": "https://api.yourcompany.com/orders/{{order_number}}", "method": "GET", "description": "Look up an order by its number. Returns status, items, total and estimated delivery date.", "mutates": false } }
The npm package wraps both shapes:
tsimport { BuddyConnector } from 'buddy-assist-connector'; const buddy = new BuddyConnector({ apiKey: process.env.BUDDY_API_KEY, // programmatic key, not the widget key projectId: process.env.BUDDY_PROJECT_ID, }); // One route at a time… await buddy.registerTool({ name: 'get_order', url: 'https://api.yourcompany.com/orders/{{order_number}}', method: 'GET', description: 'Look up an order by its number. Returns status, items, total and estimated delivery date.', }); // …or your whole spec, on every deploy. await buddy.syncOpenAPI(spec, { baseUrl: 'https://api.yourcompany.com', secret: process.env.YOUR_API_CREDENTIAL, });
There is no separate "my data changed" call to make for connectors. We hold no copy of what is behind them — every answer comes from a live call at conversation time, so your stock levels and order statuses are current by construction. Re-register only when the shape changes: a new route, a renamed parameter, a better description. (Documents pushed under section 3 are the opposite: those are copies, and they do need re-pushing when they change.)
4.2 The fields, and which ones matter
| Field | Meaning |
|---|---|
name | Short name. Shown in the dashboard and in the AI's tool list. |
description | What it does. This is the whole interface — see below. |
endpoint_url | Your URL. {{placeholders}} are filled from the conversation. |
method | GET / POST / PUT / PATCH / DELETE. Defaults to POST. |
request_schema | JSON schema of the body or query params, so the AI knows what to send. |
mutates | If true, the customer is asked to confirm before it is called. |
secret | A credential you issue us — see 4.6. |
enabled | Off means invisible to the AI. |
intent_tags | Restricts this tool to matching playbook intents. Empty = always available. |
description is not documentation, it is the interface. It is the text the
model reads when deciding whether this endpoint is the right one to call. Write
it like a function docstring, and say what it returns:
Bad:
Order endpointGood:
Look up a customer's order by order number. Returns status, items, total, and estimated delivery date. Use when the customer asks where their order is or what they ordered.
Vague descriptions are the single most common cause of "the assistant didn't use my endpoint."
4.3 How arguments get filled
{{placeholder}} in endpoint_url or a body template is substituted from the
run's collected fields and prior tool responses. So:
https://api.yourcompany.com/orders/{{order_number}}
collects order_number from the conversation before firing. For POST, PUT
and PATCH the body is your body_template rendered the same way, or the
collected data as JSON when no template is set.
4.4 mutates — the confirmation gate
Set mutates: true on anything that changes state. It forces the assistant to
state what it is about to do and get an explicit yes before calling. On an
OpenAPI import this is derived from the method, so POST/PUT/PATCH/DELETE
arrive as true — check it, because a POST /search does not mutate anything
and the confirmation step will feel bizarre to the customer.
4.5 Timeouts and failure
- 15 second timeout on connector calls.
- A non-2xx response is captured with its status and body and handed back to the model rather than hidden. It will typically tell the customer plainly, or retry with corrected arguments — a 404 on a mistyped order number leads to "I couldn't find that order number, could you check it?" rather than a dead end. Return useful error bodies; they are read.
- Every call stamps
last_called_aton the connector, so you can see which of your endpoints are actually being used and when we last reached them. Failures show up under connector health in the dashboard. - Responses are not cached. Every decision to call your endpoint results in a real request — that is deliberate, since the point of a connector is that the answer is current. Rate-limit on your side accordingly.
4.6 Authentication — the credential you issue us
Connectors are the reverse of reference mode: you give us a credential and we present it to you.
auth.type | Header we send |
|---|---|
bearer | Authorization: Bearer <token> |
api_key | <header_name>: <token> (defaults to X-API-Key) |
basic | Authorization: Basic <base64 of token> |
Scope it narrowly. This credential can reach every endpoint you registered, and nothing else you have. A read-only token for lookups, and a separate one for anything that mutates, is the right shape.
We also sign the request so you can verify it came from us and was not replayed — section 7. The credential proves you issued access; the signature proves this specific call is ours. Verify both.
The credential is per-project and identical on every call, so it says nothing about who is chatting. If your endpoints need that, see section 5.
5. Who is asking — identifying the logged-in user
Section 4 gets Buddy to your endpoint. This section is about the question your endpoint asks next: which of my users is this?
It only comes up in one situation, but it is a common one — you have embedded the chat inside your own product, behind your own login, and the useful answers are per-user: my orders, my invoices, my subscription. The conversation arrives at your endpoint with an order number in it, and nothing that says who the person is.
The obvious workaround is worse than it looks. Putting {{user_id}} in a
connector URL makes the model fill it, and the model fills it from what was
said in the conversation — so the value is whatever the visitor claimed, and a
visitor who says "I'm user 4021, look up my invoices" is not lying to us so
much as filling in a form. Placeholders are for arguments the customer is
allowed to choose. Identity is not one of those.
So identity travels beside the conversation instead of inside it: a token your backend mints, carried in a header, that the model never sees.
5.1 The shape of it
Four steps, three parties.
- Your backend mints a token for the signed-in user — an opaque random
string — and stores
hash(token) -> user_idwith a short expiry. - Your front end sends it with each chat request, as
end_user_token. - Buddy attaches it to every call it makes to your registered endpoints
for that conversation, as
X-BuddyAssist-End-User-Token. - Your endpoint resolves it back to a user — and then still checks that user is allowed to do what was asked.
The token is a pointer to a session on your side, nothing more. It is not a JWT, it carries no claims, and there is nothing in it for us to read. That is the point: we are a courier, and a courier should not be able to open the envelope.
5.2 Minting one
jsimport crypto from 'crypto'; const TTL_SECONDS = 30 * 60; // Called from your own app, on a route that already knows who the user is. app.post('/chat/token', requireLogin, async (req, res) => { const token = crypto.randomBytes(32).toString('base64url'); const hash = crypto.createHash('sha256').update(token).digest('hex'); await redis.setex(`buddy:end_user:${hash}`, TTL_SECONDS, req.user.id); // The raw token goes to the browser. Only the hash is stored. res.json({ end_user_token: token, expires_in: TTL_SECONDS }); });
Two details worth keeping:
- Store the hash, not the token. A dump of that table is then useless to whoever takes it — the same reason you do not store passwords.
- Keep the expiry short. Thirty minutes is a reasonable default. The token is cheap to mint again; mint a new one when the user comes back.
Do not pass the user's own JWT or session cookie. It is the master key to that account — it will authenticate anything, anywhere in your API, for as long as it lives, and the chat needs almost none of that. It would also sit in our systems, which is a place your users' login credentials have no reason to be. Mint something that does one job and dies quickly instead.
5.3 Sending it with the message
end_user_token is a top-level field on the chat endpoints in section 8:
alongside message and session_id on /message and /message/stream, and
as one more form field on /vision.
jsawait fetch(`${BASE}/chat/${projectId}/message`, { method: 'POST', headers: { 'x-api-key': PUBLIC_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Where is my last order?', session_id: sessionId, end_user_token: endUserToken, }), });
| Field | Type | Required | Notes |
|---|---|---|---|
end_user_token | string | no | Sent on every message of the session. Buddy stores the latest one against the session. |
Send it on every message rather than only the first. If you mint a fresh token part-way through — the old one expired, the user re-authenticated — the new value replaces the old one for the rest of the session.
user_context (section 8) is a different thing and still exists: it is display
detail for the answer, it reaches the model, and it is not proof of anything.
end_user_token is proof, and the model cannot see it. Do not use one for the
other's job.
5.4 What your endpoint receives
The token is added to the header block already described in section 7 — it does not change the signature scheme, and it is not part of what is signed.
httpPOST /orders/recent HTTP/1.1 Host: api.yourcompany.com Content-Type: application/json Authorization: Bearer <the credential you issued us> X-BuddyAssist-Project: 65f0a1c39b2e4d0012ab77e1 X-BuddyAssist-Timestamp: 1785480262 X-BuddyAssist-Signature: sha256=9d2f1c0b7a4e... X-BuddyAssist-End-User-Token: 8Kq3ZP1sV7nR0mXcT2hLbA9wJfE4yU6dGiOo5rQvNzk {"limit":3}
It rides along on every call we make to your registered endpoints on behalf of that conversation — connector calls, reference fetches, escalation webhooks — so one middleware covers all of them.
When the header is absent, the caller is anonymous. A conversation from WhatsApp, or from the widget on your public marketing site, has no logged-in user and no token, and the header simply is not there. Treat that as "no user", never as "trusted user", and make sure your per-user routes fail closed.
5.5 Resolving it — and the rule that matters
js// Runs after the signature check from section 7 has already passed. async function resolveEndUser(req, res, next) { const token = req.header('X-BuddyAssist-End-User-Token'); if (!token) { req.endUser = null; return next(); } const hash = crypto.createHash('sha256').update(token).digest('hex'); const userId = await redis.get(`buddy:end_user:${hash}`); // Expired, ended, or never ours. Anonymous, not an error. if (!userId) { req.endUser = null; return next(); } req.endUser = await Users.findById(userId); next(); } // `requireBuddySignature` is the check from section 7, wrapped as middleware. app.post('/orders/recent', requireBuddySignature, resolveEndUser, async (req, res) => { if (!req.endUser) { return res.status(200).json({ error: 'not_signed_in', message: 'Ask the customer to sign in, then try again.', }); } // Identity told you WHO. It did not tell you WHETHER. if (!can(req.endUser, 'read:orders')) { return res.status(200).json({ error: 'not_permitted', message: 'This account cannot view order history.', }); } // The body is still raw bytes — the signature check needs it that way. const { limit = 3 } = JSON.parse(req.body?.toString('utf8') || '{}'); res.json(await Orders.recentFor(req.endUser.id, limit)); });
Identity is not permission. The token tells you which user is on the other end of the conversation. It does not tell you that user may read this record, cancel this booking, or see this invoice — run exactly the same authorisation checks you would run if the request had arrived from your own web app. Buddy decides what to ask for; only you decide what is allowed. A connector that skips the second check is an access-control hole with an AI in front of it.
Note the 200 responses above. Section 4.5 applies here too: a body explaining
what went wrong is read by the model and turned into something the customer can
act on, where a bare 403 becomes "something went wrong."
5.6 What the model can see (nothing)
Stated plainly, because the whole design rests on it:
- The token is carried in headers only. It is never placed in the prompt, never included in the conversation history, and never put into the collected fields the model fills placeholders from.
{{end_user_token}}is not a placeholder. Writing it in a connector URL or body template gets you the literal text, not the token, because the value does not exist in the data placeholders are rendered from.- It is not logged — not in request logs, not in the connector call history in your dashboard, which records that a token was attached, not what it was.
So a visitor cannot talk the assistant into revealing the token, quoting it, or inventing a different one, for the same reason it cannot reveal your connector credential: it is not something the model was ever given.
5.7 Ending the session
A token that outlives the session it belongs to is a loose key. Three things end a session, and your side should treat all three the same way — the token is dead, mint a new one next time.
| How it ends | Trigger |
|---|---|
| You end it | POST /chat/:projectId/sessions/:sessionId/end — call it on logout, or on tab close |
| Inactivity | No message for the configured window. Default 10 minutes |
| A human closes it | An agent resolves the conversation in the Buddy dashboard |
In all three cases we stop sending the token immediately. Nothing further reaches your endpoints with that header.
bashcurl -X POST \ "https://api.buddyassist.io/api/v1/chat/$PROJECT_ID/sessions/$SESSION_ID/end" \ -H "x-api-key: $BUDDY_SERVER_KEY"
json{ "status": "success", "message": "Session ended", "data": { "session_id": "sess_8f3a1c" } }
Auth: a programmatic API key, or a dashboard JWT. The public widget key
— the one in your page's <script> snippet — is rejected here, for the same
reason it is rejected on import (section 4.1): anyone who views your page has
it, and ending sessions is not something a page visitor should be able to do
on your behalf. Call this from your backend.
Only the first ending is yours to trigger. The other two happen without
telling your application, so expire your side on the same clock: give
your stored hash(token) -> user_id entry a TTL matching the inactivity
window, and delete the entry when you call /end. A token we have stopped
sending but you still honour is the dangerous direction of that mismatch —
it is a live session on your side that nobody is watching.
5.8 The settings behind this, and what they cost
Three per-business settings govern the above. They live in the dashboard, under the project's chat settings — there is no API for them, because they are policy, not integration.
| Setting | What it does |
|---|---|
| Inactivity window | How long a silent session stays open before it ends itself. Defaults to 10 minutes |
| What happens to the conversation | Whether the transcript is deleted when the session ends, kept for a set period, or kept until somebody deletes it. Defaults to deleted |
The second is the decision worth making deliberately. Deleting means each new session starts clean: the customer re-states their order number, and nothing from last week is available to this week's conversation. Keeping means the assistant can pick up where it left off, which is what people usually want from a product they are signed into.
Whichever is chosen, identity always ends with the session. Keeping the transcript does not keep the person signed in — the end-user token is cleared the moment the session closes, and the next conversation starts anonymous until a fresh one arrives. Remembering what somebody said and still being authorised to act as them are different things, and only the first is configurable here.
Retained memory is billed. It is carried into later conversations, which means it is loaded and processed on those turns, and that consumes your credits — a longer retention period costs more, on every conversation that uses it. Set it to the shortest window that makes the experience work rather than to the maximum.
6. Playbooks — when to write the procedure down
You do not need a playbook for a lookup. The assistant is handed the read-only endpoints you registered, and calls one when answering needs live data — "where's my order", "do you have this in stock". Register the endpoint and it works.
Two things are deliberately NOT lookups. Questions your documentation already answers — opening hours, prices, policies — are answered from that documentation and never call your API, which is what keeps your bill and your latency down. And anything that changes data is not called on a lookup at all; it needs a playbook.
Write a playbook when the task has a required order or required fields — book a table (party size, date, time, then check availability, then reserve), process a return (order number, then eligibility, then refund). A playbook adds an intent, the fields to collect first, the tools it is allowed to use, and rules that must hold before the mutating call fires.
Two things worth knowing:
- Playbooks can be written in the dashboard or through the API below — the same playbook either way.
intent_tagson a connector restricts it to matching intents. On a project with many endpoints this keeps the tool list focused; leave it empty and the tool is always in scope.
When a playbook answers, sources comes back empty and a
playbook: { run_id, finished } object is included instead — the answer came
from your API, not from a document. Do not render citations unconditionally.
Managing playbooks from your own code
Use these when you want playbooks under version control, generated from your own config, or set up as part of provisioning a customer — anything you would rather not do by hand in the dashboard.
Authentication is the x-api-key header from section 2. These are a separate
surface from the dashboard's own playbook screens, so they can change
independently of it.
| Method | Path | Does |
|---|---|---|
POST | /playbooks/:projectId | Create a playbook |
GET | /playbooks/:projectId | List them |
GET | /playbooks/:projectId/:id | Fetch one |
PUT | /playbooks/:projectId/:id | Update one |
Listing accepts page, pageSize (default 50), enabled, type and
search as query parameters.
Deleting is not available here — it is not part of setting a playbook up, so it stays in the dashboard.
Creating one
Only name and intent are required.
| Field | Notes |
|---|---|
name | Required. What it is called in the dashboard. |
intent | Required. { name, examples[], keywords[] } — how Buddy recognises when to use it. Only name is mandatory, but examples materially improve matching. |
description | Free text explaining the job to the assistant. |
type | action or diagnostic. |
permission | read_only, execute, or ask_before_execute — whether Buddy may fire a mutating call on its own. |
required_fields | What to collect first: { name, label, description, type, prompt }. type is one of string, number, boolean, email, phone, date. |
apis | Endpoints it may call: { name, purpose, method, url, headers, auth, body_template, mutates }. Set mutates: true on anything that writes. |
sequence | Ordered guidance: [{ text, connector_indexes[] }]. A step with no connector is plain conversational guidance, which is normal. |
documents | Uploaded docs it may quote: { id, name, purpose }. |
rules | Conditions that must hold before acting, each with an on_fail_message. |
response_templates | { read_success, execute_success, rules_failed, fallback }. |
enabled | Boolean. Create it switched off, verify, then turn it on. |
httpPOST /api/v1/playbooks/:projectId x-api-key: YOUR_API_KEY Content-Type: application/json
json{ "name": "Check order status", "description": "Look up an order and tell the customer where it is.", "type": "action", "permission": "read_only", "enabled": true, "intent": { "name": "order_status", "examples": ["where is my order", "has my package shipped"], "keywords": ["order", "delivery", "tracking"] }, "required_fields": [ { "name": "order_id", "label": "Order number", "type": "string", "prompt": "What's your order number?" } ], "apis": [ { "name": "getOrder", "purpose": "Fetch one order by id", "method": "GET", "url": "https://api.example.com/orders/{{order_id}}", "auth": { "type": "bearer", "token": "..." }, "mutates": false } ], "sequence": [ { "text": "Ask for the order number if it wasn't given." }, { "text": "Look the order up.", "connector_indexes": [0] } ], "response_templates": { "read_success": "Your order is {{status}}, arriving {{eta}}.", "fallback": "I'll pass this to the team." } }
Updating one
Same field names, all optional. Send only what changes; anything omitted is
left alone. The _id comes from the create response or from the list endpoint.
bashcurl -X PUT \ "https://api.buddyassist.io/api/v1/playbooks/$PROJECT_ID/$PLAYBOOK_ID" \ -H "x-api-key: $BUDDY_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": false, "permission": "ask_before_execute" }'
Errors
Beyond the codes in section 10:
| Code | Means | Fix |
|---|---|---|
400 | Validation failed | Almost always an unrecognised field — the message names it. See the note below. |
401 | No key sent, or the key is invalid, expired, or deactivated | The message says which. |
403 | The key is valid but belongs to a different project | Use a key bound to this projectId. |
404 | No such playbook in this project | Check the id, and that it belongs to this projectId. |
Unknown fields are rejected, not ignored. One misspelled or extra key fails the whole request with a
400naming the offending property. If a call returns 400 and the payload looks right, look for a stray field first.
7. Verifying the request is ours
Every call Buddy Assist makes out to your system — reference fetches, connector calls, escalation webhooks — is signed with one scheme, so you write one verifier and reuse it.
Setting the secret
Where it is read from, in priority order:
BA_REFERENCE_FETCH_SECRET__<PROJECT_ID>— environment, per projectBA_REFERENCE_FETCH_SECRET— environment, all projectsreference_fetch_secret— the project field, set from the dashboard- not set → requests are sent unsigned
What we send
X-BuddyAssist-Timestamp: 1785480262
X-BuddyAssist-Signature: sha256=<hex>
X-BuddyAssist-Project: <your project id>
The signature is:
HMAC-SHA256(secret, "<timestamp>.<METHOD>.<url>.<body>")
hex encoded, prefixed with sha256=. METHOD is upper-case. url is the
full URL including query string. body is the exact bytes we sent, or the
empty string for GET.
Because the method, URL and body are all inside the signature, a signature captured from one endpoint cannot be replayed against another.
Reference fetches also carry User-Agent: BuddyAssist-ReferenceFetcher/1.0.
A fourth header, X-BuddyAssist-End-User-Token, is present when the
conversation came from a signed-in user — section 5. It is not part of what
is signed, so it does not change any of the above.
Verifying it
jsimport crypto from 'crypto'; import express from 'express'; const app = express(); // Keep the raw bytes — re-serialising parsed JSON will not reproduce the digest. app.use(express.raw({ type: '*/*' })); function verifyBuddySignature(req) { const ts = req.header('X-BuddyAssist-Timestamp'); const sig = req.header('X-BuddyAssist-Signature') || ''; const url = req.protocol + '://' + req.get('host') + req.originalUrl; const body = req.body?.length ? req.body.toString('utf8') : ''; // Reject replays — anything older than 5 minutes. if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = 'sha256=' + crypto .createHmac('sha256', process.env.BUDDY_SECRET) .update(`${ts}.${req.method.toUpperCase()}.${url}.${body}`) .digest('hex'); const a = Buffer.from(sig); const b = Buffer.from(expected); // timingSafeEqual throws on a length mismatch, so check that first. if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } app.all('/buddy/*', (req, res, next) => { if (!verifyBuddySignature(req)) return res.status(401).send('bad signature'); next(); });
pythonimport hmac, hashlib, time def verify_buddy_signature(secret, headers, method, url, body=""): ts = headers.get("X-BuddyAssist-Timestamp", "") sig = headers.get("X-BuddyAssist-Signature", "") if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False expected = "sha256=" + hmac.new( secret.encode(), f"{ts}.{method.upper()}.{url}.{body}".encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(sig, expected)
Two things that will bite you:
- Sign the raw body, not a re-serialised one.
JSON.parsethenJSON.stringifyreorders keys and changes whitespace, and the digest will not match. - Use a constant-time comparison.
===leaks timing.
What is signed on each path
| Path | Method | Body signed |
|---|---|---|
| Reference fetch | GET | empty |
| Connector call | as configured | the request body, when there is one |
| Escalation webhook | POST | the JSON event body |
8. Distribution — consuming Buddy from your own code
Skip this if your customers reach Buddy through a channel or the widget.
All endpoints accept x-api-key or a JWT.
| Method | Path | Purpose |
|---|---|---|
GET | /chat/:projectId/config | Widget settings — theme, bot name, welcome message |
POST | /chat/:projectId/message | Send a message, get a reply |
POST | /chat/:projectId/message/stream | Same, streamed token by token (SSE) |
POST | /chat/:projectId/vision | Send an image plus a question |
GET | /chat/:projectId/conversations/:id | Fetch one conversation |
POST | /chat/:projectId/conversations/:id/escalate | Hand over to a human |
POST | /chat/:projectId/sessions/:sessionId/end | End a session now — see section 5.7 |
POST /chat/:projectId/message
bashcurl -X POST \ https://api.buddyassist.io/api/v1/chat/YOUR_PROJECT_ID/message \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "How do I reset my password?", "session_id": "sess_8f3a1c", "visitor_id": "visitor_42" }'
json{ "status": "success", "message": "Response generated", "data": { "conversation_id": "65f1...", "session_id": "sess_8f3a1c", "response": "Go to **Settings → Security → Change Password**...", "sources": [ { "document_id": "65f2...", "title": "Account Security", "doc_type": "guide", "url": "https://yoursite.com/docs/security#change-password", "chunk_id": "65f3..." } ], "escalated": false } }
| Field | Type | Required | Notes |
|---|---|---|---|
message | string | yes | The visitor's message |
session_id | string | no | Strongly recommended — see below |
visitor_id | string | no | Your own identifier for the visitor |
conversation_id | string | no | Round-tripped; continuity uses session_id |
history | array | no | [{ role, content }] if you manage history yourself |
user_context | object | no | Signed-in user details, so answers can be personalised |
sender_name | string | no | Display name for the visitor |
end_user_token | string | no | Which signed-in user this is — section 5 |
user_context shape:
json{ "user": { "name": "Ada Lovelace", "email": "[email protected]" }, "organizations": [{ "name": "Acme Ltd", "id": "org_1" }] }
user_context is read by the model, so treat it as display detail rather than
proof of identity. When your endpoints need to trust who is asking, send
end_user_token as well — section 5.
escalation_suggested: true may also appear when the assistant thinks a human
is needed but has not escalated yet — treat it as a prompt to offer the visitor
a handover.
session_id is the important field
It is what makes a conversation continuous. Generate one per visitor and send the same value on every message. Without it each message is a fresh conversation — the assistant forgets what was just said, and any multi-step procedure (collecting an order number, confirming a booking) cannot continue.
Any stable, unguessable string works. Persist it in sessionStorage for a web
widget, or against the user record server-side.
POST /chat/:projectId/message/stream
Server-Sent Events. Each event is a JSON object on a data: line.
| Event | Shape | Meaning |
|---|---|---|
| Token | { "token": "..." } | Append to what you are displaying |
| Final | { "done": true, "conversation_id": ..., "session_id": ..., "sources": [...] } | Stream finished; render the sources |
| Error | { "error": "..." } | Project not found or inactive |
Stop on done: true — there is no [DONE] sentinel. An error mid-stream
arrives as a token plus done: true, so handle both fields.
jsconst res = await fetch(`${BASE}/chat/${projectId}/message/stream`, { method: 'POST', headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ message, session_id }), }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; outer: for (;;) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Events are separated by a blank line; keep any partial tail in the buffer. const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; for (const part of parts) { const line = part.split('\n').find((l) => l.startsWith('data: ')); if (!line) continue; const evt = JSON.parse(line.slice(6)); if (evt.error) throw new Error(evt.error); if (evt.token) appendToUI(evt.token); if (evt.done) { renderSources(evt.sources ?? []); break outer; } } }
Buffer across reads as shown — a chunk boundary can land mid-event, and parsing each read in isolation will eventually throw on partial JSON.
This endpoint answers from your documentation only. It does not run playbooks, and it will not call your connectors. If your project relies on either, use the non-streaming endpoint.
POST /chat/:projectId/vision
multipart/form-data, not JSON.
| Field | Type | Notes |
|---|---|---|
file | file | The image |
message | string | Optional question about the image |
bashcurl -X POST \ https://api.buddyassist.io/api/v1/chat/YOUR_PROJECT_ID/vision \ -H "x-api-key: YOUR_API_KEY" \ -F "[email protected]" \ -F "message=What does this error mean?"
POST /chat/:projectId/conversations/:id/escalate
Hands the conversation to a human and fires your configured escalation route (Zendesk, Slack, email, webhook, …). If you configured the generic webhook, it arrives signed — section 7.
8.1 Sockets — live conversation, agent takeover, voice
REST is enough to send a message and get an answer. It is not enough to build a real channel interface: you also need to know when a human agent joins, when they type, and when an escalation is resolved. That is what the socket namespaces are for.
Socket.IO, three namespaces on the same host as the API:
| Namespace | For |
|---|---|
/project-chat | Your channel or inbox: live messages, agent takeover, escalation state |
/voice | Streaming audio in and out |
/platform-chat | Buddy Assist's own site widget — not for customer integrations |
/project-chat — the one you want. Join a conversation room, then send and
receive:
jsimport { io } from 'socket.io-client'; const socket = io('https://api.buddyassist.io/project-chat'); socket.emit('join_conv', { conversationId }); socket.on('joined_conv', ({ conversationId }) => { /* ready */ }); // Someone said something — from the visitor, the AI, or a human agent. socket.on('new_message', (msg) => render(msg)); // A human has taken over, or dropped off. socket.on('agent_joined', ({ agentName }) => showTakeover(agentName)); socket.on('agent_disconnected', ({ message }) => showNotice(message)); socket.on('escalation_resolved', (evt) => clearTakeover(evt)); socket.emit('visitor_message', { conversationId, content: 'where is my order?' });
| You emit | Meaning |
|---|---|
join_conv | Subscribe to one conversation |
join_project_watch | Subscribe to every escalation on a project — for an inbox view |
visitor_message | A message from the end user |
agent_join / agent_message | Your human agent taking over and replying |
resolve_escalation | Hand the conversation back to the AI |
| You receive | Meaning |
|---|---|
joined_conv | Subscription confirmed |
new_message | A message was added to the conversation |
agent_joined / agent_disconnected | Human takeover started / dropped |
escalation_resolved | Back under AI control |
/voice streams audio: emit audio with base64 chunks, commit to close
an utterance, interrupt to barge in, text to inject a typed message into a
voice session. The payload ceiling is 5 MB per frame.
Two caveats. The socket namespaces currently accept any origin and do not authenticate the handshake — do not treat a socket connection as proof of identity, and keep your API key out of browser code that also opens one. Conversations are still created and authorised over REST; sockets carry the live updates.
9. Sending messages to your customers
Everything in section 8 is your code asking Buddy Assist a question. This is your code telling Buddy Assist to reach someone.
All of it authenticates with your business API key:
x-api-key: YOUR_BUSINESS_API_KEY
That key is bound to one business. Used against another it returns 403, so
it can only ever reach your own customers.
Base URL: https://api.buddyassist.io/api/v1
9.1 The business ID
Every path below carries it:
POST https://api.buddyassist.io/api/v1/channels/send/6a8309911b139e54d5f88635
└── your business ID ──┘
Find it under API keys in your business, alongside the key itself. It is called the project id in older responses; it is the same value.
Nothing identifying the business goes in the body — the API key is checked against the ID in the path, which is what stops a key working anywhere else.
9.2 Channel names
| Value | Reaches | Can you send to it |
|---|---|---|
whatsapp | yes | |
sms | Text message | yes |
email | yes | |
widget | The chat bubble on your site | no — they are on your page, answer in the inbox |
voice | A phone call | no — a call cannot be answered with text |
facebook | Messenger | inbox only |
instagram | Instagram DM | inbox only |
telegram | Telegram | inbox only |
tiktok | TikTok | no — TikTok exposes no outbound API |
discord, wordpress, api | Other sources | no |
channel on a send accepts whatsapp, sms or email. Anything else
comes back refused against that recipient, with the reason.
audience.channel filters on a customer's own channel, so it accepts any
value in the left column — { "channel": "instagram" } selects people who
came in that way, and with channel left off the send, each is answered
wherever they can be reached.
One wrinkle worth knowing: a phone call is recorded as
voiceon the customer andsipon the conversation. Filter customers withvoice.
9.3 Endpoints at a glance
| Method | Path | Does |
|---|---|---|
POST | /channels/send/:businessId | Send a message on any channel |
GET | /lead/:businessId | List your customers |
GET | /channels/whatsapp/:businessId/templates | List WhatsApp templates |
POST | /channels/whatsapp/:businessId/templates | Create a template |
GET | /channels/whatsapp/:businessId/sending-limit | Remaining daily allowance |
POST | /channels/whatsapp/:businessId/send-template | Send one template to many people |
Use /channels/send unless you specifically need WhatsApp template
mechanics. It handles every channel and picks the right one per person.
9.4 POST /channels/send/:businessId
The one endpoint for reaching a customer.
Request
json{ "to": { "phone": "+2348012345678" }, "audience": { "channel": "whatsapp", "opted_in": true }, "channel": "whatsapp", "text": "Your order is ready.", "subject": "Your order", "template": { "name": "order_ready", "language": "en", "variables": ["Ada", "SK-4821"] } }
| Field | Type | Required | Meaning |
|---|---|---|---|
to | object or array | one of to/audience | Specific people |
audience | object | one of to/audience | A described group |
channel | string | no | whatsapp, sms, email. Omit to use each person's own channel |
text | string | one of text/template | The message |
subject | string | no | Email only. Defaults to a line naming your business |
template | object | one of text/template | WhatsApp only. Required outside the 24-hour window |
to — identify people by whatever you hold. One object, or an array.
json{ "lead_id": "6a83..." } { "phone": "+2348012345678" } { "email": "[email protected]" } { "wa_id": "2348012345678" }
Someone who is not a customer yet still works — the address you give is used directly.
audience — describe a group instead. Capped at 1000 people per call.
| Field | Example | Meaning |
|---|---|---|
all | true | Every customer |
channel | "whatsapp" | Only those who last wrote from there |
stage | "won" | Only that pipeline stage |
tags | ["vip"] | Any of these tags |
opted_in | true | Only those who opted in to WhatsApp |
to wins over audience when both are given — "send to John" must never
widen into "and everyone like John".
Response
200 with per-recipient results. One failure never stops the rest.
json{ "status": "success", "message": "Sent to 2 of 3", "data": { "sent": 2, "total": 3, "results": [ { "ok": true, "channel": "whatsapp", "to": "2348012345678" }, { "ok": true, "channel": "email", "to": "[email protected]" }, { "ok": false, "channel": "whatsapp", "to": "2348099999999", "reason": "WhatsApp only allows plain text within 24 hours of the customer writing to you. Send an approved template instead." } ] } }
| Field | Meaning |
|---|---|
sent | How many went out |
total | How many were attempted |
results[].ok | Whether that person was reached |
results[].channel | Which channel was used for them |
results[].reason | Present only on failure, in plain words |
400 when the request cannot be acted on at all:
json{ "status": "error", "message": "That matched nobody. Name someone with `to`, or describe who with `audience`." }
One call, several channels
Leave channel out and each person is reached where they are — WhatsApp for
those who arrived there, email for those who emailed:
bashcurl -X POST https://api.buddyassist.io/api/v1/channels/send/$BUSINESS_ID \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{ "audience": { "all": true }, "text": "We are closed on Monday." }'
You do not have to know who prefers what. Buddy Assist recorded it the first time each person wrote.
9.5 GET /lead/:businessId
Who your customers are, so your own system can decide who to reach.
?page=1&limit=50&channel=whatsapp&stage=won&search=ada
json{ "status": "success", "data": [ { "_id": "6a83...", "name": "Ada Obi", "phone": "+2348012345678", "email": "[email protected]", "primary_channel": "whatsapp", "stage": "engaged", "tags": ["vip"], "whatsapp_opt_in": true, "last_contacted_at": "2026-08-19T14:02:11.000Z", "conversation_count": 3 } ], "pagination": { "total": 128, "page": 1, "pages": 3 } }
Buddy Assist knows everyone who has contacted this business. It does not
know your app's users who have never written — send to those with an explicit
to, and they become known once they reply.
9.6 WhatsApp templates
WhatsApp only allows a free-form message within 24 hours of the customer writing to you. Outside that window you must send a template Meta has approved. This is Meta's rule, not ours.
Text messages and email have no such window.
GET /channels/whatsapp/:businessId/templates
json{ "status": "success", "data": [ { "name": "order_ready", "language": "en", "category": "UTILITY", "status": "APPROVED", "body": "Hi {{1}}, your order {{2}} is ready." } ] }
Only APPROVED can be sent. New ones sit at PENDING until Meta reviews,
usually within a day. REJECTED carries Meta's reason.
POST /channels/whatsapp/:businessId/templates
json{ "name": "weekly_meal_suggestions", "language": "en_US", "category": "UTILITY", "components": [ { "type": "BODY", "text": "Hello {{1}}, your plan for this week is ready.", "example": { "body_text": [["Ada"]] } }, { "type": "BUTTONS", "buttons": [ { "type": "URL", "text": "See this week's plan", "url": "https://example.com/w/{{1}}", "example": ["https://example.com/w/abc123"] } ] } ] }
| Field | Notes |
|---|---|
name | lowercase, underscores, unique to your business |
category | UTILITY, MARKETING or AUTHENTICATION |
language | e.g. en_US. Defaults to en_US |
components | Meta's own component array, passed straight through |
An earlier version of this guide showed a flat
bodyandexamplepair. The API rejects that — it takescomponents, and returnsproperty body should not exist. Corrected here.
Every {{n}} needs a sample or Meta refuses the template. A BODY carries
them in example.body_text; a URL button carries its own example.
A name is unique per business, and a deleted name stays reserved for a while afterwards — so reusing one immediately after a delete is refused.
POST /channels/whatsapp/:businessId/send-template
One template to many people, with per-person variables.
json{ "template_name": "order_ready", "language": "en", "recipients": [ { "wa_id": "2348012345678", "variables": ["Ada", "SK-4821"] }, { "wa_id": "2348098765432", "variables": ["Chidi", "SK-4822"] } ] }
| Field | Type | Required | Fills |
|---|---|---|---|
template_name | string | yes | The template's name, exactly as Meta approved it |
language | string | no | en, en_US … defaults to en |
recipients | array | yes | Up to 500 per call |
recipients[].wa_id | string | yes | Number, international, no + |
recipients[].variables | string[] | no | The body's {{1}}, {{2}} … in order |
recipients[].button_variables | string[] | no | Dynamic URL buttons, in button order |
Body variables and button variables are not the same thing
This is the one shape you cannot discover by trying it, so it is worth stating plainly: Meta treats a dynamic URL button as a separate parameter from the body. Body values never reach a button.
A template whose button points at https://lounje.ng/w/{{1}} needs:
json{ "template_name": "weekly_meal_suggestions", "language": "en", "recipients": [ { "wa_id": "2348012345678", "variables": ["Ada", "Jollof rice"], "button_variables": ["abc123"] } ] }
That sends the body filled with Ada and Jollof rice, and the button
pointing at https://lounje.ng/w/abc123.
Leave button_variables out on a template that has a dynamic button and
the message still sends — with the placeholder unfilled, so the link is
broken for every recipient. If your template has one, this field is not
optional in practice.
The same field exists on /channels/send:
json{ "to": { "wa_id": "2348012345678" }, "channel": "whatsapp", "template": { "name": "weekly_meal_suggestions", "variables": ["Ada", "Jollof rice"], "button_variables": ["abc123"] } }
wa_id is the number in international form without a +.
GET /channels/whatsapp/:businessId/sending-limit
json{ "status": "success", "data": { "tier": "TIER_250", "limit": 250, "used": 18, "remaining": 232 } }
9.7 What we refuse, and why
A send is refused rather than attempted when:
| Reason | What to do |
|---|---|
| Customer has not opted in | Only send to people who agreed |
| Template not approved | Wait for Meta, or send a different one |
| Daily limit would be breached | Send fewer, or raise the limit |
| Plain text outside the 24-hour window | Send a template instead |
Each refusal comes back against that recipient with its reason. Nothing is dropped silently.
9.8 A worked example, end to end
A meal-planning app sending its users a weekly suggestion over WhatsApp. Unprompted, so it is outside the 24-hour window every time and needs a template.
Once, at setup
- Create the template as
UTILITY— cheaper thanMARKETINGand approved more readily. Wait forAPPROVED. - Verify the business with Meta. That lifts the daily limit from 250 to 2,000 immediately; the alternative takes a month.
Every week
bashcurl -X POST https://api.buddyassist.io/api/v1/channels/send/$BUSINESS_ID \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \ -d '{ "to": { "phone": "+2348012345678" }, "channel": "whatsapp", "template": { "name": "weekly_meal_suggestions", "language": "en", "variables": ["Ada", "Jollof rice"] } }'
Then read results[] and retry or log whatever failed. A 200 means the
request was processed, not that everyone was reached.
When they reply
It arrives in the inbox on the same WhatsApp line, the AI answers from the
guides, and it escalates to a human if asked. Nothing extra to build — and
that person is now a known customer, so audience filters reach them from
then on.
Two things to watch
- Batch under 1000 recipients per call.
- Your own users who have never written are unknown here. Send to them with
an explicit
to; they become known when they answer.
9.9 On limits
The daily limit belongs to your Meta business portfolio, not to Buddy Assist. Every business has its own, starting at 250 people per 24 hours.
It rises to 2,000 for any of three reasons — verifying your business with Meta, partner verification, or sending 2,000 delivered high-quality template messages over 30 days. Verification is immediate; the third takes a month. From 2,000 it scales automatically: 10,000, 100,000, unlimited.
10. Responses and errors
Every response uses the same envelope:
json{ "status": "success", "message": "...", "data": { } }
Errors:
json{ "status": "error", "message": "API key is not authorized for this project" }
| Code | Meaning |
|---|---|
400 | Malformed request — check required fields |
401 | Missing/invalid key, or key used against the wrong project |
404 | Project or conversation not found, or project inactive |
500 | Server error |
11. Practical notes
Keep the key out of the browser where you can. A project key scoped to one project is safe enough for a public widget, but if you are calling from your own backend, keep it server-side.
Handle escalated: true. When it comes back true the conversation has been
handed to a human. Your UI should reflect that rather than continuing to invite
messages into the void.
Show the sources when they are present. Citations are what make an answer
trustworthy.
The assistant only knows what you gave it. If it cannot find an answer it
says so and offers to escalate rather than inventing one. If it says that too
often, the gap is usually a missing connector or a vague description, not the
model.
12. Endpoint reference
The endpoints above are the ones you reach for while integrating. These are the rest of the surface — the widget's own calls, and managing documents through the API rather than the dashboard.
Rate limits
Chat messages are metered per plan.
| Plan | Messages / day |
|---|---|
| Free / Trial | 100 |
| Starter | 1,000 |
| Pro | 10,000 |
| Enterprise | Custom |
Widget endpoints
The drop-in widget calls these itself. You only need them if you are building your own front end and want the same configuration the widget uses. The API key travels in the path here, not a header — these are public reads.
httpGET /widget/:apiKey/init
Returns what to show before the visitor types: the greeting, the bot's name and avatar, and suggested questions.
json{ "status": "success", "data": { "welcome_message": "Hi! How can I help you today?", "bot_name": "Buddy Assist Bot", "bot_avatar": null, "suggested_questions": [ { "question": "How do I get started?", "answer": "Create a project, add your documentation..." } ] } }
Suggested questions come from your published FAQ documents — add more to give visitors more entry points.
httpGET /widget/:apiKey/config
Returns the project name and the widget's appearance settings.
json{ "status": "success", "data": { "project_name": "My Product Docs", "widget_config": { "theme_color": "#007AFF", "position": "bottom-right", "welcome_message": "Hi! How can I help?", "bot_name": "Buddy Assist Bot", "bot_avatar": null } } }
Managing documents through the API
Section 3 covers getting content in via /sync, which is the right choice when
another system owns the content and pushes updates. These endpoints are the
direct equivalent of editing in the dashboard — use them when your code is the
author. They take a JWT, not an API key.
httpGET /documentation/:projectId?page=1&pageSize=20&doc_type=faq&is_published=true POST /documentation/:projectId PUT /documentation/:projectId/:id DELETE /documentation/:projectId/:id PUT /documentation/:projectId/:id/publish PUT /documentation/:projectId/:id/unpublish POST /documentation/:projectId/bulk-import
Creating or updating one:
json{ "title": "How do I reset my password?", "content": "Go to Settings -> Security and click Reset Password...", "doc_type": "faq", "category": "Account", "tags": ["password", "security"], "is_published": true }
Bulk import takes an array under documents, each of the same shape:
json{ "documents": [ { "title": "Doc 1", "content": "...", "doc_type": "faq" }, { "title": "Doc 2", "content": "...", "doc_type": "guide" } ] }
doc_type is one of faq, api_docs, policy, manual, guide,
changelog, troubleshooting, glossary.
13. Just want a chat bubble?
You do not need any of the above:
html<script src="https://buddyassist.io/widget.js" data-project-id="YOUR_PROJECT_ID" data-api-key="YOUR_API_KEY" async ></script>
Use the API when you are building your own interface or calling from a backend. For WhatsApp, Instagram, Messenger, Telegram, TikTok or phone, you do not need the API either — connect the channel in the dashboard.