WhatsApp API
Send your first message#
A plain text message. The body is the standard Cloud API payload — we pass it through to Meta untouched.
POST/v26.0/{phone-number-id}/messagesAuthorization: Bearer <DEVICE_KEY>
curl -X POST https://api.sybox.io/v26.0/PHONE_NUMBER_ID/messages \
-H "Authorization: Bearer YOUR_DEVICE_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"text": { "body": "Hello from SyBox" }
}'const res = await fetch('https://api.sybox.io/v26.0/PHONE_NUMBER_ID/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.SYBOX_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to: '15551234567',
type: 'text',
text: { body: 'Hello from SyBox' }
})
});
console.log(res.status, await res.json());import os, requests
r = requests.post(
"https://api.sybox.io/v26.0/PHONE_NUMBER_ID/messages",
headers={"Authorization": "Bearer " + os.environ["SYBOX_KEY"]},
json={
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"text": {"body": "Hello from SyBox"},
},
timeout=30,
)
print(r.status_code, r.json())$ch = curl_init('https://api.sybox.io/v26.0/PHONE_NUMBER_ID/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SYBOX_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'messaging_product' => 'whatsapp',
'to' => '15551234567',
'type' => 'text',
'text' => ['body' => 'Hello from SyBox'],
]),
]);
echo curl_exec($ch);using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient(); // in a real app, keep one HttpClient for the whole app
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SYBOX_KEY"));
string json = """
{
"messaging_product": "whatsapp",
"to": "15551234567",
"type": "text",
"text": { "body": "Hello from SyBox" }
}
""";
var res = await http.PostAsync(
"https://api.sybox.io/v26.0/PHONE_NUMBER_ID/messages",
new StringContent(json, Encoding.UTF8, "application/json"));
Console.WriteLine(await res.Content.ReadAsStringAsync());
The Response#
You get back exactly what Meta returns, with the same HTTP status — so any existing Cloud API client library parses it unchanged.
200 OK · application/json {
"messaging_product": "whatsapp",
"contacts": [{ "input": "15551234567", "wa_id": "15551234567" }],
"messages": [{ "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI…" }]
}
Your system in SyBox
Show your data beside the chat#
When an agent opens a conversation, SyBox calls YOUR endpoint and shows what you return right beside the chat — the customer's orders, balance, tickets. We send the customer's phone, or their email on an email conversation.
Add your endpoint in Settings › Integrations › ERP APIs. Every call is a POST with your key in Authorization and the request in one field, data, holding a JSON string — so either side can add fields later without breaking the other. Answer within 15 seconds.
POST YOUR_ERP_URL
Authorization: Bearer YOUR_ERP_KEY
X-Sybox-Action: lookup
X-Sybox-Lang: en
X-Sybox-Theme: light
X-Sybox-Agent: 6f1c…
X-Sybox-Number: 104857600000000
Content-Type: application/json
{ "data": "{\"phone\":\"15551234567\",\"email\":\"customer@example.com\",\"ref\":\"\"}" }{
"renderAs": "table",
"content": [
{ "Order": "#10432", "Status": "Shipped", "Total": "$1,250.00", "sybox_ref": "10432" },
{ "Order": "#10388", "Status": "Delivered", "Total": "$310.00", "sybox_ref": "10388" }
]
}
Each call also says who is asking: X-Sybox-Lang (the user's language — answer in it), X-Sybox-Theme (light or dark, for an html reply), X-Sybox-Agent (the user's id) and X-Sybox-Number (the line the conversation is on).
We send both phone and email — either may be empty. Match on whichever identifies the customer in your system, and reply with { renderAs, content }.
renderAs#
Set renderAs to tell SyBox how to display your content — one of:
| renderAs | Description |
|---|
table | Rows and columns. content is an array of flat objects (or { rows: [ … ] }); their keys become the column headers. |
cards · kpi | Small tiles, such as a balance or an order count. content is an array of { label, value } (or { cards: [ … ] }). |
feed · list · thread | A timeline of cards. content is an array of { title, text, date, tags, media: [{ type, url, name }] }. |
message | One record laid out as a single message-style card. |
html | Your own HTML, as a string in content. Only for markup you generate yourself. |
sections | Several blocks in one reply: content is { sections: [ { title, renderAs, content } ] }. |
json | The default when renderAs is left out: SyBox shows your content as formatted JSON. |
Add a sybox_ref to any table row or card and it becomes clickable — SyBox calls you again with that ref so you can return the single record's detail.
Product search (X-Sybox-Action: products)#
The same endpoint can also answer product searches. The user types what the customer is asking for, SyBox sends it as "q", and your reply is drawn as product cards next to the chat — with a one-tap Send that puts the product link in the composer.
POST YOUR_ERP_URL
Authorization: Bearer YOUR_ERP_KEY
X-Sybox-Action: products
# the decoded "data":
{ "q": "wireless keyboard", "phone": "15551234567", "email": "customer@example.com", "ref": "" }{
"content": [
{
"name": "Wireless Keyboard K380",
"price": 449,
"oldPrice": 520,
"currency": "USD",
"available": true,
"stock": 12,
"image": "https://cdn.example.com/k380.jpg",
"link": "https://shop.example.com/p/k380",
"note": "Ships in 24h"
}
]
}
| Field | Description |
|---|
namerequired | The only required field. A product with nothing but a name still renders as a tidy card. |
price · oldPrice · currency | Numbers, not formatted strings. Send oldPrice only when there is a genuine previous price — the discount badge is calculated from the pair. |
available · stock | available is true/false; stock is an optional count shown beside it. Omit them entirely if you do not track stock — an absent field draws nothing, and is never read as out of stock. |
image | A direct https image URL. Anything that fails to load falls back to a neutral placeholder. |
link | The product page URL. Without it the card is read-only — Send to chat appears only when there is something to send. |
note | One short free line under the price — delivery time, a variant, a condition. |
Only the endpoint you already built: same URL, same key, same { "data": "…" } envelope. Look at X-Sybox-Action to tell a product search from a customer lookup. Reply with content as a plain array (or { items: [ … ] }).
Events
Webhooks: events on your server#
Add your endpoint in Settings › Integrations › Webhooks, choose the events and the numbers, and SyBox POSTs each event to it as it happens. A WhatsApp event arrives in Meta's own webhook format, so a handler written for Meta works unchanged.
| Event | Description |
|---|
messageIncoming message | A customer's message. |
statusMessage status | Sent, delivered, read or failed — for a message you sent. |
reactionReaction | A customer reacted to a message. |
doneConversation marked done | A conversation was marked done: by a person, by an automation, or on its own after the customer's silence. |
labelLabel added or removed | A label was added to a conversation, or removed from it. |
contactContact created or updated | A contact was created, or its name, email or contact fields changed. |
dealDeal closed | A closed deal was recorded in a conversation. |
caseCRM case opened or closed | A CRM case was opened, or closed (solved, not solved or canceled). |
Request · SyBox → you POST YOUR_WEBHOOK_URL
Content-Type: application/json
X-Hub-Signature-256: sha256=5d1f0c…
x-sybox-signature: sha256=5d1f0c…
x-sybox-event: message
x-sybox-timestamp: 1761400000
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "15550001111", "phone_number_id": "PHONE_NUMBER_ID" },
"contacts": [{ "profile": { "name": "Sara" }, "wa_id": "15551234567" }],
"messages": [{
"from": "15551234567",
"id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQ…",
"timestamp": "1761400000",
"type": "text",
"text": { "body": "Is my order shipped?" }
}]
}
}]
}]
}
Media. Meta's own link needs a Meta token to open, so SyBox replaces it with a signed link to its own copy of the file, valid for 24 hours. A voice note can also carry its words in sybox.transcription, when the webhook asks for them.
Other channels — Messenger, Instagram, Telegram and Live Chat — arrive in SyBox's own format, for the message event only:
{
"object": "sybox",
"event": "message",
"channel": "telegram",
"line": "8123456789",
"workspace": "yourworkspace",
"contact": { "key": "tg:6423115118", "name": "Sara" },
"message": {
"id": "812345",
"timestamp": 1761400000,
"type": "image",
"text": "",
"caption": "The broken part",
"media": { "url": "https://api.sybox.io/…", "mime": "image/jpeg" }
}
}
Conversation events arrive in SyBox's own format, for every channel. event says exactly what happened:
| Event | <code>event</code> |
|---|
| Conversation marked done | conversation.done |
| Label added or removed | label.added · label.removed |
| Contact created or updated | contact.created · contact.updated |
| Deal closed | deal.closed |
| CRM case opened or closed | case.opened · case.closed |
| Test event | test |
{
"object": "sybox",
"event": "conversation.done",
"channel": "whatsapp",
"line": "201001234567",
"workspace": "yourworkspace",
"timestamp": 1761400000,
"contact": {
"key": "201112223344",
"name": "Sara",
"phone": "201112223344",
"email": "",
"channel": "whatsapp",
"labels": [{ "id": 3, "name": "VIP" }],
"fields": [{ "id": 2, "key": "city", "name": "City", "value": "Cairo" }]
},
"data": { "by": "Omar", "note": "Refund sent" }
}{
"object": "sybox",
"event": "case.closed",
"workspace": "yourworkspace",
"timestamp": 1761400000,
"case": {
"id": 8124,
"guid": "5f0c1e9a…",
"subject": "Screen replacement not delivered",
"type": 1,
"status": "solved",
"department": "Support"
},
"customer": { "name": "Sara", "phone": "201112223344" }
}
Signing secret#
| Header | Description |
|---|
X-Hub-Signature-256 | sha256= and the hex HMAC-SHA256 of the raw body, keyed with the webhook's signing secret — the same header and method as Meta's. |
x-sybox-signature | The same value, so an endpoint that also receives Meta's own calls can tell ours apart. |
x-sybox-event | What happened: message, status, reaction, one of the conversation events below (conversation.done, label.added…), or test. |
x-sybox-timestamp | When it was sent, in Unix seconds. |
x-sybox-signature-rsa | A second signature: rsa-sha256= and the base64 RSA-SHA256 (PKCS#1 v1.5) of x-sybox-timestamp, a dot, and the raw body. |
x-sybox-key-id | Which of our keys made it. The public key is at https://api.sybox.io/.well-known/sybox-webhook-key. |
Check the signature on the raw bytes, before you parse them, and compare in constant time:
const crypto = require('crypto');
// express.raw: the signature is over the bytes as sent, so read them before any JSON parser.
app.post('/sybox/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sent = Buffer.from(req.get('X-Hub-Signature-256') || '');
const want = Buffer.from('sha256=' + crypto
.createHmac('sha256', process.env.SYBOX_WEBHOOK_SECRET)
.update(req.body)
.digest('hex'));
if (sent.length !== want.length || !crypto.timingSafeEqual(sent, want)) return res.sendStatus(401);
res.sendStatus(200); // answer first, then do the work
const event = JSON.parse(req.body);
const kind = req.get('x-sybox-event'); // message | status | reaction
});$raw = file_get_contents('php://input');
$want = 'sha256=' . hash_hmac('sha256', $raw, getenv('SYBOX_WEBHOOK_SECRET'));
$sent = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (!hash_equals($want, $sent)) {
http_response_code(401);
exit;
}
http_response_code(200);
$event = json_decode($raw, true);
$kind = $_SERVER['HTTP_X_SYBOX_EVENT'] ?? ''; // message | status | reactionimport hashlib, hmac, os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/sybox/webhook")
def sybox_webhook():
raw = request.get_data()
want = "sha256=" + hmac.new(os.environ["SYBOX_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, request.headers.get("X-Hub-Signature-256", "")):
abort(401)
event = request.get_json()
kind = request.headers.get("x-sybox-event") # message | status | reaction
return "", 200using System.Security.Cryptography;
using System.Text;
app.MapPost("/sybox/webhook", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
byte[] raw = ms.ToArray();
string secret = Environment.GetEnvironmentVariable("SYBOX_WEBHOOK_SECRET")!;
byte[] mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), raw);
byte[] want = Encoding.ASCII.GetBytes("sha256=" + Convert.ToHexString(mac).ToLowerInvariant());
byte[] sent = Encoding.ASCII.GetBytes(req.Headers["X-Hub-Signature-256"].ToString());
if (!CryptographicOperations.FixedTimeEquals(want, sent)) return Results.Unauthorized();
string kind = req.Headers["x-sybox-event"]; // message | status | reaction
return Results.Ok();
});
Rather not keep a shared secret on your server? Check x-sybox-signature-rsa with our public key instead. Refuse a timestamp more than five minutes old either way.
Answer with any 2xx within 15 seconds. Anything else — an error, a timeout, a redirect — is tried again after 1, 2, 4, 8, 16 and 32 minutes, then dropped. An event can arrive more than once: use the message's id to ignore a repeat. The address must be public and https.
A webhook that fails 20 times in a row, for more than an hour, is switched off on its own and the workspace's admin is told. Its waiting events are kept for 24 hours: switch it back on to send them.
To test, press Send test event on the webhook's card. Deliveries, on the same card, lists the last 50 deliveries with your server's answer, and can send one again.
Live Chat
Live Chat#
Put a chat on your website: visitors write there, your team answers in SyBox. Add the website in Settings › Messaging › Channels, on the Live Chat card; the window that opens, Code for your site, gives you the code and the keys below. The three secret keys are shown only once.
| Key | Starts with | What it is for |
|---|
| Public key | lcpk_ | Goes in your pages. Safe to publish: it only names the website, and the chat shows only on your Website addresses. |
| Signing key for signed-in users | lcid_ | Your server only. Signs each signed-in user, so nobody can write as someone else. |
| Server key (for your own chat screen) | lcsk_ | Your server only. Only for a site that builds its own chat screen instead of the ready chat. |
| Reply signature key | lcwh_ | Your server only. Proves that a reply posted to your server came from SyBox. |
The ready chat needs only the code. The signing key is for a site with accounts; the server key and the reply signature key only for a site with its own chat screen.
Switching a website off hides the chat at once. Your server's calls are still accepted and stored.
Add the chat to your site#
Paste the code once into every page, just before the closing body tag. The chat shows only on the addresses in Website addresses, and its colour, position, title and welcome are set in SyBox, not in the code.
<script async src="https://api.sybox.io/lc/w.js" data-key="lcpk_YOUR_PUBLIC_KEY"></script>
WordPress: download our plugin from the same window. Shopify: in SyBox's Shopify app, turn on Live Chat on this store, then SyBox Live Chat under App embeds — a signed-in customer is recognised with nothing more to do.
Signed-in users: the signing key#
When a user is signed in to your site, your server tells the chat who they are with a short token — a JWT, HS256 — signed with the signing key. The user then has one conversation under their name, on every device, and nobody can open it by typing someone else's id.
- After sign-in, your server builds the token from the claims below.
- The page passes it to the chat as
identity, before the chat's code. - After a sign-in without a page reload, call
SyBoxChat('identify', token) instead.
| Field | Description |
|---|
subrequiredstring | Your user's id, up to 120 characters. It may not start with v_. |
exprequirednumber | Expiry, in Unix seconds — no more than one day ahead. Ten minutes is right: the token is read only when the chat opens. |
namestring | The name the conversation shows. |
emailstring | The user's email address. |
phonestring | The user's phone number. |
picturestring | An https address of the user's photo, up to 500 characters. SyBox keeps its own copy, and fetches it again at the next sign-in when the address changes. |
function b64url(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); }
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = b64url(json_encode([
'sub' => (string) $user->id,
'name' => $user->name,
'email' => $user->email,
'exp' => time() + 600, // ten minutes
]));
$signature = b64url(hash_hmac('sha256', "$header.$payload", getenv('SYBOX_LC_SIGNING_KEY'), true));
$token = "$header.$payload.$signature";const crypto = require('crypto');
const b64url = (s) => Buffer.from(s).toString('base64url');
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = b64url(JSON.stringify({
sub: String(user.id),
name: user.name,
email: user.email,
exp: Math.floor(Date.now() / 1000) + 600 // ten minutes
}));
const signature = crypto.createHmac('sha256', process.env.SYBOX_LC_SIGNING_KEY)
.update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;import base64, hashlib, hmac, json, os, time
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64url(json.dumps({
"sub": str(user.id),
"name": user.name,
"email": user.email,
"exp": int(time.time()) + 600, # ten minutes
}).encode())
key = os.environ["SYBOX_LC_SIGNING_KEY"].encode()
signature = b64url(hmac.new(key, f"{header}.{payload}".encode(), hashlib.sha256).digest())
token = f"{header}.{payload}.{signature}"using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
string header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
string payload = B64Url(JsonSerializer.SerializeToUtf8Bytes(new {
sub = user.Id.ToString(),
name = user.Name,
email = user.Email,
exp = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds()
}));
byte[] key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SYBOX_LC_SIGNING_KEY"));
string signature = B64Url(HMACSHA256.HashData(key, Encoding.ASCII.GetBytes(header + "." + payload)));
string token = header + "." + payload + "." + signature;
<script>
window.SyBoxChatConfig = { identity: "TOKEN_FROM_YOUR_SERVER", locale: "en" };
</script>
<script async src="https://api.sybox.io/lc/w.js" data-key="lcpk_YOUR_PUBLIC_KEY"></script>
Make the token on your server only. A signing key in a web page lets anyone write as any of your users. And never put a bare user id in the page in place of the token: that would let anyone choose whose conversation to read.
With Only signed-in users of my site switched on, a visitor without a token does not see the chat.
A visitor who chatted before signing in is asked once, in the same browser, whether to continue that conversation with their account. You do nothing for this.
Control the chat from your page#
The chat's code adds one function to your page, SyBoxChat:
SyBoxChat('open'); // open the chat
SyBoxChat('close'); // close it
SyBoxChat('identify', token); // after a sign-in without a page reload
SyBoxChat('locale', 'fr'); // the chat's language
SyBoxChat('on', 'message', function (m) {
// a reply arrived
});
SyBoxChat('on', 'open', function () { /* the chat opened */ });
SyBoxChat('on', 'close', function () { /* the chat closed */ });
// Before the chat's code, every setting is optional:
window.SyBoxChatConfig = {
identity: 'TOKEN_FROM_YOUR_SERVER', // a signed-in user (see above)
name: 'Sara', email: 'sara@example.com', // a visitor without an account: fills the form for them
locale: 'ar',
serviceWorker: 'auto' // 'auto' | '/your-sw.js' | 'site' | 'off'
};
Notifications after your site is closed#
While a page of your site is open, a reply shows as a notification by itself. To reach a visitor after your site is closed, browsers require a service worker on your own domain: serve this one line as /sybox-lc-sw.js at your site's root, as application/javascript.
try { importScripts('https://api.sybox.io/lc/sw.js'); } catch (e) {}
Choose the mode in SyBox (Notifications when the website is closed) or in the page with serviceWorker: "auto" (the file above, the default), a path of your own, "site" (put the same line at the top of your own service worker) or "off".
The chat never replaces a service worker your site already has, and the file has no fetch handler, so it never sees a request of your site. The WordPress plugin serves the file for you. iPhone Safari shows no notifications for a plain website.
Your own chat screen: the server key#
A site or app with its own chat screen talks to SyBox from its server instead of using the ready chat. Every call carries Authorization: Bearer lcsk_…. Your server names the user by your own id, and is trusted to, because it holds the key.
| Call | Description |
|---|
POST /v1/livechat/messages | Your user writes. client_id makes a retry safe: the same id is stored once ("duplicate": 1). |
POST /v1/livechat/upload | A file, as multipart form data: file, user_id, client_id. Up to 16 MB: images, voice, video, PDF, text and Office documents. |
GET /v1/livechat/messages?user_id=&since=&before=&limit= | The conversation: the messages after since, and agents_read_id, the newest of your user's messages an agent has read. |
POST /v1/livechat/read | Your user has read the replies up to up_to. |
POST /v1/livechat/edit | Your user edits their own message, within 15 minutes. |
POST /v1/livechat/delete | Your user deletes their own message for everyone, within 24 hours. |
POST /v1/livechat/react | A reaction on any message; an empty emoji takes it off. |
POST /v1/livechat/typing | Your user is typing: send on: 1 every few seconds and on: 0 when they stop. |
curl -X POST https://api.sybox.io/v1/livechat/messages \
-H "Authorization: Bearer $SYBOX_LC_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"user": { "id": "u42", "name": "Sara Ali", "email": "sara@example.com", "locale": "ar" },
"message": { "client_id": "a1b2c3", "text": "Hi, is my order on its way?", "reply_to": "" }
}'{ "ok": 1, "id": 981, "guid": "7c9e…", "duplicate": 0 }{ "user_id": "u42", "up_to": 990 } // read
{ "user_id": "u42", "id": 981, "text": "…" } // edit
{ "user_id": "u42", "id": 981 } // delete
{ "user_id": "u42", "id": 990, "emoji": "👍" } // react
{ "user_id": "u42", "on": 1 } // typing
A message, in the history and in replies:
{
"id": 990,
"guid": "b81d…",
"dir": "out", // "in" = your user wrote it, "out" = your team
"type": "image", // text | image | audio | video | file
"text": "Here is the tracking page",
"media": { "url": "https://api.sybox.io/…", "mime": "image/png", "name": "tracking.png", "w": 1080, "h": 720 },
// a voice note or a video also has "secs"
"status": 3, // on your user's messages: 1 received, 3 read by an agent
"reply_to": "7c9e…",
"client": "a1b2c3", // on your user's messages: the client_id you sent
"react_us": "", // your team's reaction on it
"react_me": "👍", // your user's reaction on it
"at": "2026-10-25T09:41:07Z",
"edited": 0
}
The history of a user who has never written answers 404 unknown_user: show an empty conversation.
Media links are signed and work for one hour; ask again for a fresh one. SyBox never fetches a file from an address you send: upload it. Up to 120 calls a minute per website.
Replies on your server: the reply signature key#
Enter Your server's address for replies on the website. When your team replies, edits, deletes or reads, SyBox POSTs the event there, signed with the reply signature key.
| Header | Description |
|---|
x-sybox-signature | t=TIMESTAMP,v1=SIGNATURE — TIMESTAMP in Unix seconds, SIGNATURE the hex HMAC-SHA256 of TIMESTAMP.BODY (the raw body), keyed with the reply signature key. |
x-sybox-event | message.created, message.updated, message.deleted or messages.read. |
x-sybox-delivery | The same on every retry of one event: use it to ignore a repeat. |
The body holds event, line_id, user_id and message — or message_id for a deletion, and read_id for messages.read: your user's messages are read up to that id.
const crypto = require('crypto');
app.post('/sybox/livechat', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries((req.get('x-sybox-signature') || '').split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(401); // older than five minutes
const want = Buffer.from(crypto.createHmac('sha256', process.env.SYBOX_LC_REPLY_KEY)
.update(t + '.').update(req.body).digest('hex'));
const sent = Buffer.from(parts.v1 || '');
if (sent.length !== want.length || !crypto.timingSafeEqual(sent, want)) return res.sendStatus(401);
res.sendStatus(200);
const delivery = req.get('x-sybox-delivery'); // seen it already? ignore
const evt = JSON.parse(req.body); // { event, line_id, user_id, message | message_id | read_id }
});$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_SYBOX_SIGNATURE'] ?? ''), $sig);
$t = (int) ($sig['t'] ?? 0);
$want = hash_hmac('sha256', $t . '.' . $raw, getenv('SYBOX_LC_REPLY_KEY'));
if (abs(time() - $t) > 300 || !hash_equals($want, $sig['v1'] ?? '')) {
http_response_code(401);
exit;
}
http_response_code(200);
$delivery = $_SERVER['HTTP_X_SYBOX_DELIVERY'] ?? ''; // seen it already? ignore
$evt = json_decode($raw, true);import hashlib, hmac, os, time
from flask import abort, request
@app.post("/sybox/livechat")
def sybox_livechat():
raw = request.get_data()
sig = dict(p.split("=", 1) for p in request.headers.get("x-sybox-signature", "").split(",") if "=" in p)
t = int(sig["t"]) if sig.get("t", "").isdigit() else 0 # a malformed header is a 401, not a crash
want = hmac.new(os.environ["SYBOX_LC_REPLY_KEY"].encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if abs(time.time() - t) > 300 or not hmac.compare_digest(want, sig.get("v1", "")):
abort(401)
delivery = request.headers.get("x-sybox-delivery") # seen it already? ignore
evt = request.get_json()
return "", 200using System.Security.Cryptography;
using System.Text;
app.MapPost("/sybox/livechat", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
byte[] raw = ms.ToArray();
var sig = new Dictionary<string, string>();
foreach (var part in req.Headers["x-sybox-signature"].ToString().Split(','))
{
var kv = part.Split('=', 2);
if (kv.Length == 2) sig[kv[0].Trim()] = kv[1].Trim(); // a malformed header is a 401, not a crash
}
if (!long.TryParse(sig.GetValueOrDefault("t"), out long t) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return Results.Unauthorized();
byte[] signedBytes = Encoding.ASCII.GetBytes(t + ".").Concat(raw).ToArray();
string replyKey = Environment.GetEnvironmentVariable("SYBOX_LC_REPLY_KEY")!;
byte[] mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(replyKey), signedBytes);
byte[] want = Encoding.ASCII.GetBytes(Convert.ToHexString(mac).ToLowerInvariant());
byte[] sent = Encoding.ASCII.GetBytes(sig.GetValueOrDefault("v1", ""));
if (!CryptographicOperations.FixedTimeEquals(want, sent)) return Results.Unauthorized();
return Results.Ok();
});
Check the signature on the raw body, refuse a t more than five minutes old, and answer 2xx quickly. Anything else is tried again after 10 s, 30 s, 2 min, 10 min and 30 min, then dropped. SyBox calls public addresses only and never follows a redirect.
Reason codes#
Every Live Chat answer is JSON — {"ok": 1, …}, or {"ok": 0, "error": "CODE"} with a code rather than a sentence:
| Code | Status | Description |
|---|
unauthorized | 401 | The server key is wrong or replaced. |
invalid_json invalid_payload invalid_user_id | 400 | The request is not in the expected shape. |
empty_message no_file empty_file | 400 | No text and no file. |
too_long | 400 | The edited text is over 4,096 characters. |
not_an_emoji | 400 | emoji is not an emoji. |
not_found | 404 | No such message, or not one of this user's. |
too_late | 409 | The time to edit or delete has passed. |
file_type_not_allowed | 400 | This kind of file is not accepted. |
file_too_large | 413 | The file is over 16 MB. |
blocked | 403 | Your team has blocked this user. |
unknown_user | 404 | No conversation for this user id yet. |
rate_limited | 429 | Too many calls: slow down and try again. |
maintenance | 503 | SyBox is updating: try again after Retry-After seconds. |
server_error | 500 | Something failed on our side. |
Replacing a key#
Lost a key, or think it leaked? On the website's card, click New signing key, New server key or New reply signature key. A new server key leaves the old one working for one day, so you can switch over without a gap; the other keys stop at once.