API reference
REST API v1 · Webhooks · Custom actions · Pro, Scale and Custom plans
Contents
Introduction
The Serlion API lets you read your chatbot's conversations, leads and analytics, and manage its knowledge base. Webhooks notify your server when things happen. It's available on the Pro, Scale and Custom plans.
Base URL: https://api.serlion.com/api/v1
Requests and responses are JSON. Timestamps are ISO 8601 strings in UTC. Conversations from the widget preview in your dashboard never appear in the API.
Authentication
- Create a key in . The full key is shown once, when you create it, so store it somewhere safe.
- Send it on every request as
Authorization: Bearer serlion_sk_… - A key is either read only (every
GETroute) or read & write (also adds, edits and deletes knowledge base entries). - Keep keys on your server. The API sends no CORS headers, so browsers block requests made from a web page. That keeps keys out of website code.
- Keys don't expire. Revoke a key in the dashboard and it stops working on the next request.
curl https://api.serlion.com/api/v1/me \
-H "Authorization: Bearer serlion_sk_..."GET /me is a good first call: it confirms the key works and tells you which account it belongs to.
Errors and limits
Errors come back as { "error": "message", "code": "CODE" }. The message is written for people; use the code in your logic.
{
"error": "This API key is read-only. Create a key with read & write access for this.",
"code": "INSUFFICIENT_SCOPE"
}| Code | Status | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | The key is missing, not valid, or revoked. |
INSUFFICIENT_SCOPE | 403 | A read-only key was used on a route that changes data. |
PLAN_FEATURE_UNAVAILABLE | 403 | The account is not on a plan that includes the API. |
KB_LIMIT_REACHED | 403 | The knowledge base is full for this plan. The response also has maxEntries. |
NOT_FOUND | 404 | No such conversation, entry or route. |
BAD_REQUEST | 400 | A parameter or body field is missing or invalid. The message says which. |
RATE_LIMITED | 429 | Too many requests. Wait and try again. |
Rate limit: 60 requests per minute per key, and 120 per minute across all keys of one account. A 429 response includes Retry-After: 60.
Pagination
- Lists return
{ "data": [...], "nextCursor": "..." }, newest first. - For the next page, pass
?cursor=<nextCursor>with the same filters.nextCursor: nullmeans there are no more. limitis 1 to 100, default 25.- Single objects come back as
{ "data": { ... } }.
# First page
curl "https://api.serlion.com/api/v1/leads?limit=50" \
-H "Authorization: Bearer serlion_sk_..."
# Next page: pass nextCursor back as cursor
curl "https://api.serlion.com/api/v1/leads?limit=50&cursor=WyIyMDI2LTA5LTI0..." \
-H "Authorization: Bearer serlion_sk_..."Endpoints
All paths are relative to https://api.serlion.com/api/v1.
GET/me
Your account and the key in use.
{
"data": {
"businessId": "biz_4f1c9a2e7b3d5c8e1a6f0b2d",
"companyName": "Northfield",
"plan": "Pro",
"conversationRetentionDays": 90,
"key": { "id": "apikey_9c2e41d07f3a8b15e6d2", "name": "CRM sync", "scope": "read" }
}
}GET/conversations
Your conversations, newest first.
| Query | Description |
|---|---|
limit | 1 to 100, default 25. |
cursor | The nextCursor from the previous page. |
since | ISO 8601. Only conversations that started at or after this time. |
until | ISO 8601. Only conversations that started before this time. |
handoffStatus | none, requested (a visitor asked for a person) or active (someone on your team is replying). |
channel is web, telegram, whatsapp, email, line or discord. visitor is null until the visitor has given a name, email or phone number. rating is null until they rate the conversation.
Conversations from Telegram, WhatsApp, email, LINE and Discord have channel set. For WhatsApp, the visitor's phone is the WhatsApp number. For email, the visitor's email is the sender's address.
{
"data": [
{
"id": "sess_7d2b1f64-3c8e-4a95-b0d1-2e6f9a4c8b13",
"startedAt": "2026-09-24T14:02:11.482Z",
"lastActivityAt": "2026-09-24T14:09:37.105Z",
"messageCount": 8,
"handoffStatus": "none",
"channel": "web",
"visitor": { "name": "Ana Souza", "email": "[email protected]", "phone": null },
"rating": { "stars": 5, "comment": "Quick and clear.", "ratedAt": "2026-09-24T14:10:02.318Z" }
}
],
"nextCursor": "WyIyMDI2LTA5LTI0IDE0OjAyOjExLjQ4MjUxMyswMCIsInNlc3NfN2QyYjFmNjQtM2M4ZS00YTk1LWIwZDEtMmU2ZjlhNGM4YjEzIl0"
}GET/conversations/{id}
One conversation with the same fields as the list, plus every message in order. role is visitor, bot, agent (a person on your team) or system. attachment is { url, type, name } or null.
{
"data": {
"id": "sess_7d2b1f64-3c8e-4a95-b0d1-2e6f9a4c8b13",
"startedAt": "2026-09-24T14:02:11.482Z",
"lastActivityAt": "2026-09-24T14:09:37.105Z",
"messageCount": 8,
"handoffStatus": "none",
"channel": "web",
"visitor": { "name": "Ana Souza", "email": "[email protected]", "phone": null },
"rating": null,
"messages": [
{
"id": 48213,
"role": "visitor",
"content": "Do you ship to Portugal?",
"createdAt": "2026-09-24T14:02:11.482Z",
"attachment": null
},
{
"id": 48214,
"role": "bot",
"content": "Yes. Orders to Portugal arrive in 3 to 5 working days.",
"createdAt": "2026-09-24T14:02:13.907Z",
"attachment": null
}
]
}
}GET/leads
Leads, newest first. Query: limit, cursor, since, until (filtering on when the lead was captured).
source is chat (given in the conversation), prechat (the widget's pre-chat form) or identify (passed in by your website).
{
"data": [
{
"id": "lead_1758722977105_3fa9c2d1",
"conversationId": "sess_7d2b1f64-3c8e-4a95-b0d1-2e6f9a4c8b13",
"name": "Ana Souza",
"email": "[email protected]",
"phone": null,
"messageExcerpt": "Can someone send me a quote for 40 units?",
"source": "chat",
"createdAt": "2026-09-24T14:09:37.105Z"
}
],
"nextCursor": null
}GET/analytics
The last 30 days, the same numbers as the Overview in your dashboard. The volume arrays have one entry per day. satisfactionPercent and handoffRatePercent are null when there is no data yet.
{
"data": {
"periodDays": 30,
"conversationVolume": [
{ "date": "2026-08-26", "count": 14 },
{ "date": "2026-08-27", "count": 9 }
],
"totalConversations": 412,
"leadsVolume": [
{ "date": "2026-08-26", "count": 2 },
{ "date": "2026-08-27", "count": 1 }
],
"totalLeads": 37,
"feedback": { "up": 88, "down": 6, "satisfactionPercent": 94 },
"handoff": { "handoffCount": 21, "totalConversations": 412, "handoffRatePercent": 5 }
}
}Knowledge base
An entry is { id, title, sourceType, sourceUrl, content, createdAt, updatedAt }. sourceType is text, file (uploaded in the dashboard) or url (imported from a web page). Titles can be up to 100 characters and content up to 8,000.
GET/knowledge-base
Every entry, in the order they were added, plus maxEntries for your plan. Not paginated.
{
"data": [
{
"id": "kb_entry_0c7e2a9f41b3d8e6a5f1c2b4",
"title": "Shipping and delivery",
"sourceType": "text",
"sourceUrl": null,
"content": "We ship to every EU country. Orders arrive in 3 to 5 working days...",
"createdAt": "2026-09-01T09:15:42.000Z",
"updatedAt": "2026-09-18T16:03:10.000Z"
}
],
"maxEntries": 25
}GET/knowledge-base/{id}
One entry, as { "data": { ... } }.
POST/knowledge-baserequires a read & write key
Body { "title", "content" } for text, or { "title", "sourceUrl" } to import a web page (send one or the other, not both). Serlion fetches the page and keeps the entry linked to it. Returns 201 with the new entry.
curl -X POST https://api.serlion.com/api/v1/knowledge-base \
-H "Authorization: Bearer serlion_sk_..." \
-H "Content-Type: application/json" \
-d '{ "title": "Returns", "content": "You can return any item within 30 days." }'{ "title": "Pricing page", "sourceUrl": "https://example.com/pricing" }PATCH/knowledge-base/{id}requires a read & write key
Body { "title"?, "content"?, "refreshFromUrl"?: true }. Fields you leave out stay the same. refreshFromUrl fetches the page again and only works for entries imported from a URL. Returns the updated entry.
{ "refreshFromUrl": true }DELETE/knowledge-base/{id}requires a read & write key
Deletes the entry. Returns 204 with no body.
Webhooks
Setup. Add an endpoint (an HTTPS URL) in and choose the events it should get. You can have up to 5 endpoints. Serlion shows the endpoint's signing secret (whsec_…) when you add it, and you can reveal it again there later.
Events
| Type | Sent when | data |
|---|---|---|
conversation.started | A visitor sent the first message of a new conversation. | { sessionId, channel, startedAt, firstMessage, visitor | null } |
lead.created | A visitor left an email address or phone number. | { id, sessionId, name, email, phone, messageExcerpt, source, createdAt } |
handoff.requested | A visitor asked to talk to a person. | { sessionId, requestedAt, withinBusinessHours } |
handoff.escalated | A handoff request has had no reply for 5 minutes. | { sessionId, minutesWithoutReply, escalatedAt } |
handoff.resolved | Someone on your team marked a handoff as resolved. | { sessionId, resolvedAt, resolvedBy: { type: 'owner' | 'team_member', name } } |
conversation.rated | A visitor rated a conversation. | { sessionId, rating (1–5), comment, ratedAt } |
conversation.deleted | A visitor deleted their conversation from the chat. Delete any copy you keep. | { sessionId, deletedAt, deletedBy: 'visitor' } |
webhook.test | Sent by Send test event in the dashboard. | { message } |
visitor in conversation.started is { name, email, phone }, or null if the visitor hasn't given any of them yet. channel is web, telegram, whatsapp, email, line or discord.
When you receive conversation.deleted, delete everything you stored for that sessionId: the visitor asked for it to be erased.
Payload
Every request is a POST with a JSON body in the same envelope. data depends on type.
{
"eventId": "evt_5b0e2c7a9d41f3e86c2a1b7d",
"type": "lead.created",
"timestamp": "2026-09-25T12:00:00.000Z",
"businessId": "biz_4f1c9a2e7b3d5c8e1a6f0b2d",
"data": {
"id": "lead_1758801600000_9e2c4b1a",
"sessionId": "sess_7d2b1f64-3c8e-4a95-b0d1-2e6f9a4c8b13",
"name": "Ana Souza",
"email": "[email protected]",
"phone": null,
"messageExcerpt": "Can someone send me a quote for 40 units?",
"source": "chat",
"createdAt": "2026-09-25T12:00:00.000Z"
}
}Delivery and retries
- Respond with any 2xx status within 10 seconds. Anything else counts as a failure.
- A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (7 attempts in total).
- Redirects are not followed. Use the final URL.
- An endpoint whose deliveries have failed for 3 days is turned off, and you get an email. Fix it, then turn it back on in the dashboard.
- Events can arrive more than once and out of order. Use the
webhook-idheader to ignore duplicates: it stays the same on every retry of a delivery. Usetimestampin the body to put events in order. - The dashboard keeps a delivery log for 30 days, where you can see each attempt and resend a delivery.
Verifying signatures
Serlion follows the Standard Webhooks spec. Each request has three headers:
| Header | Value |
|---|---|
webhook-id | The delivery ID. Same on every retry. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | v1,<base64>. Space-separated when there is more than one (for 24 hours after a secret rotation). |
The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}. The key is the base64-decoded part of your secret after whsec_. Always verify against the raw body exactly as received: parsing and re-serializing the JSON changes it.
The easiest route is an official Standard Webhooks library.
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.SERLION_WEBHOOK_SECRET); // "whsec_…"
// Use the RAW request body, not re-serialized JSON:
const event = wh.verify(rawBody, {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
});
// verify() throws if the signature or timestamp is wrong.# pip install standardwebhooks
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
wh = Webhook(os.environ["SERLION_WEBHOOK_SECRET"]) # "whsec_…"
@app.post("/webhooks/serlion")
def serlion_webhook():
try:
# The raw body, not request.json re-serialized.
event = wh.verify(request.get_data(), {
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
})
except WebhookVerificationError:
return "", 400
# ... handle event["type"] / event["data"] ...
return "", 204Without a library, in Node:
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifySerlionWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader) throw new Error('Missing webhook headers');
// Reject old (or far-future) timestamps, so a captured request can't be replayed later.
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
throw new Error('Timestamp outside the 5-minute window');
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
// Several signatures during a secret rotation: accept if any one matches.
const valid = signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
if (!valid) throw new Error('Invalid signature');
return JSON.parse(rawBody);
}
// Express: keep the body raw for this route.
// app.post('/webhooks/serlion', express.raw({ type: 'application/json' }), (req, res) => {
// const event = verifySerlionWebhook(req.body, req.headers, process.env.SERLION_WEBHOOK_SECRET);
// res.sendStatus(204);
// });Rotating a secret
Rotate an endpoint's secret in . For the next 24 hours every request carries two signatures, one from the old secret and one from the new, so your receiver keeps working while you switch it over. After that, only the new secret signs.
Custom actions
A custom action lets your chatbot call your own API during a conversation and answer with live data. When a visitor asks "Where is my order 1042?", the bot calls GET https://api.shop.com/orders/1042 and answers "It shipped yesterday with DHL." Actions are read-only: every request is a GET.
Requests
- Actions are set up in , on the Pro, Scale and Custom plans.
- Serlion calls your URL with
GET. Placeholders like{orderId}are filled with URL-encoded values the AI collected from the conversation. - Optional query parameters without a value are left out.
- The AI never chooses the address, headers or credentials, only the values of the parameters you define.
- Requests come from Serlion's servers, over HTTPS only, with an 8-second timeout. Redirects aren't followed.
| Header | Value |
|---|---|
accept | application/json |
user-agent | Serlion-Actions/1.0 (+https://serlion.com) |
| Your auth header | As configured on the action. |
serlion-request-id | Unique per request. |
serlion-timestamp | When the request was signed, in Unix seconds. |
serlion-signature | v1,<base64> |
Verifying requests
Each action has its own signing secret (whsec_…). It's shown when you add the action, and you can see it again from the action in the dashboard.
- The signature is HMAC-SHA256 over
{serlion-request-id}.{serlion-timestamp}.{METHOD}.{path and query}. - The key is the base64-decoded part of the action's signing secret after
whsec_. - "Path and query" is exactly the request target your server received, for example
/orders/A%201?email=ana%40x.com. In Express that'sreq.originalUrl, in PHP$_SERVER['REQUEST_URI']. - Reject timestamps more than 5 minutes old.
import crypto from 'node:crypto';
function verifySerlion(req, secret) {
const id = req.get('serlion-request-id');
const ts = req.get('serlion-timestamp');
const sig = req.get('serlion-signature') || '';
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = 'v1,' + crypto.createHmac('sha256', key)
.update(`${id}.${ts}.${req.method}.${req.originalUrl}`)
.digest('base64');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Responses
- Answer with JSON and any 2xx status.
- A
404tells the bot that nothing was found, which it passes on to the visitor. - Any other status, a redirect, a body that isn't JSON, or a body over 256 KB counts as a failure. The bot then says it couldn't check right now and offers to connect the visitor with your team.
- Only the response fields you listed on the action reach the AI. Use dotted paths for nested values (
tracking.url) and[]for every item in a list (items[].name; the first 20 items). With no fields listed, the whole response is passed. - The result is capped at 4,000 characters, and card numbers are masked.
- The AI treats results as information, never as instructions. Still, don't return text you wouldn't want a visitor to read.
Limits
| Limit | Value |
|---|---|
| Actions per account | 10 |
| Parameters per action | 8 |
| Lookups per conversation | 10 |
| Lookups per account | 60 a minute |
| Call log | Kept 30 days, without parameter values or response content. |
Verified visitors
If your website has logged-in customers, your server can sign their user id so actions can safely look up their data with {visitor.userId} in the action's URL. The signature is made on your server with the identity secret from . Never put the secret in your web page.
- The signature is the hex HMAC-SHA256 of the user id, keyed with the identity secret as text.
- Pass both to the widget with
Serlion.identify({ userId, signature }). On logout, callSerlion.identify(null). - An invalid or missing signature is ignored silently: the visitor is simply not verified, and actions using
{visitor.userId}aren't offered to the AI. - Rotating the identity secret takes effect at once. Visitors signed with the old one are treated as not signed in until your server uses the new one.
import crypto from 'node:crypto';
const signature = crypto.createHmac('sha256', process.env.SERLION_IDENTITY_SECRET)
.update(String(user.id))
.digest('hex');$signature = hash_hmac('sha256', (string) $user->id, getenv('SERLION_IDENTITY_SECRET'));import hmac, hashlib, os
signature = hmac.new(os.environ["SERLION_IDENTITY_SECRET"].encode(), str(user.id).encode(), hashlib.sha256).hexdigest()<script>
window.Serlion = window.Serlion || [];
Serlion.push(['identify', { userId: '{{ user.id }}', signature: '{{ signature }}', name: '{{ user.name }}' }]);
</script>