Serlion

API reference

REST API v1 · Webhooks · Custom actions · Pro, Scale and Custom plans

Contents
01

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.

02

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 GET route) 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.
Shell
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.

03

Errors and limits

Errors come back as { "error": "message", "code": "CODE" }. The message is written for people; use the code in your logic.

Response · 403
{
  "error": "This API key is read-only. Create a key with read & write access for this.",
  "code": "INSUFFICIENT_SCOPE"
}
CodeStatusMeaning
UNAUTHORIZED401The key is missing, not valid, or revoked.
INSUFFICIENT_SCOPE403A read-only key was used on a route that changes data.
PLAN_FEATURE_UNAVAILABLE403The account is not on a plan that includes the API.
KB_LIMIT_REACHED403The knowledge base is full for this plan. The response also has maxEntries.
NOT_FOUND404No such conversation, entry or route.
BAD_REQUEST400A parameter or body field is missing or invalid. The message says which.
RATE_LIMITED429Too 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.

04

Pagination

  • Lists return { "data": [...], "nextCursor": "..." }, newest first.
  • For the next page, pass ?cursor=<nextCursor> with the same filters. nextCursor: null means there are no more.
  • limit is 1 to 100, default 25.
  • Single objects come back as { "data": { ... } }.
Shell
# 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_..."
05

Endpoints

All paths are relative to https://api.serlion.com/api/v1.

GET/me

Your account and the key in use.

Response · 200
{
  "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.

QueryDescription
limit1 to 100, default 25.
cursorThe nextCursor from the previous page.
sinceISO 8601. Only conversations that started at or after this time.
untilISO 8601. Only conversations that started before this time.
handoffStatusnone, 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.

Response · 200
{
  "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.

Response · 200
{
  "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).

Response · 200
{
  "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.

Response · 200
{
  "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.

Response · 200
{
  "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.

Shell
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." }'
Body · import a page
{ "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.

Body · re-fetch the page
{ "refreshFromUrl": true }

DELETE/knowledge-base/{id}requires a read & write key

Deletes the entry. Returns 204 with no body.

06

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

TypeSent whendata
conversation.startedA visitor sent the first message of a new conversation.{ sessionId, channel, startedAt, firstMessage, visitor | null }
lead.createdA visitor left an email address or phone number.{ id, sessionId, name, email, phone, messageExcerpt, source, createdAt }
handoff.requestedA visitor asked to talk to a person.{ sessionId, requestedAt, withinBusinessHours }
handoff.escalatedA handoff request has had no reply for 5 minutes.{ sessionId, minutesWithoutReply, escalatedAt }
handoff.resolvedSomeone on your team marked a handoff as resolved.{ sessionId, resolvedAt, resolvedBy: { type: 'owner' | 'team_member', name } }
conversation.ratedA visitor rated a conversation.{ sessionId, rating (1–5), comment, ratedAt }
conversation.deletedA visitor deleted their conversation from the chat. Delete any copy you keep.{ sessionId, deletedAt, deletedBy: 'visitor' }
webhook.testSent 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.

Request body
{
  "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-id header to ignore duplicates: it stays the same on every retry of a delivery. Use timestamp in 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:

HeaderValue
webhook-idThe delivery ID. Same on every retry.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signaturev1,<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.

Node · standardwebhooks
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.
Python · standardwebhooks
# 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 "", 204

Without a library, in Node:

Node · manual
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.

07

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.
HeaderValue
acceptapplication/json
user-agentSerlion-Actions/1.0 (+https://serlion.com)
Your auth headerAs configured on the action.
serlion-request-idUnique per request.
serlion-timestampWhen the request was signed, in Unix seconds.
serlion-signaturev1,<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's req.originalUrl, in PHP $_SERVER['REQUEST_URI'].
  • Reject timestamps more than 5 minutes old.
Node · Express
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 404 tells 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

LimitValue
Actions per account10
Parameters per action8
Lookups per conversation10
Lookups per account60 a minute
Call logKept 30 days, without parameter values or response content.
08

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, call Serlion.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.
Your server (Node)
import crypto from 'node:crypto';
const signature = crypto.createHmac('sha256', process.env.SERLION_IDENTITY_SECRET)
  .update(String(user.id))
  .digest('hex');
Your server (PHP)
$signature = hash_hmac('sha256', (string) $user->id, getenv('SERLION_IDENTITY_SECRET'));
Your server (Python)
import hmac, hashlib, os
signature = hmac.new(os.environ["SERLION_IDENTITY_SECRET"].encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
Your web page
<script>
  window.Serlion = window.Serlion || [];
  Serlion.push(['identify', { userId: '{{ user.id }}', signature: '{{ signature }}', name: '{{ user.name }}' }]);
</script>
  • AES-256 Encrypted
  • TLS 1.3 in Transit
  • GDPR-Compliant (EU-Based)
  • 15-365 Day Retention, Disclosed
Serlion

Product

Demos

Support

Legal

© 2026 Serlion. All rights reserved.

Made in Germany