Webhooks
Lalexi API events sent to your URL: which there are, what the body holds, how to check the signature, retries and automatic switch-off.
A webhook is your own HTTPS address that Lalexi sends a POST to when
something happens: a message arrived, a delivery status changed, a
conversation was resolved, a channel was disconnected. Your integration does
not need to poll the API.
A webhook belongs to a person — the one whose token created it — and receives only what that person sees in Lalexi: an agent the events of their own channels, an administrator those of the whole workspace. A member removed from the workspace receives nothing more.
Setting one up
curl https://app.lalexi.com/v1/webhooks \
-H "Authorization: Bearer <your token>" \
-H "Content-Type: application/json" \
-d '{"url": "https://crm.example.com/hooks/lalexi", "events": ["message.created", "conversation.updated"]}'The answer carries a secret like whsec_…. It is shown this once: store
it, you check signatures with it. PATCH /webhooks/{id} with
{"rotate_secret": true} issues a new one — the old one stops signing at once.
- The address must be
https://on the public internet; private and local addresses are refused. - Up to 10 webhooks per person per workspace.
POST /webhooks/{id}/testsends a testpingevent — signed and retried like real ones.- Your webhooks:
GET /webhooks; change the address or the events —PATCH /webhooks/{id}; delete —DELETE /webhooks/{id}.
Events
| Event | When it comes | What data holds |
|---|---|---|
message.created | A message appeared in a conversation you can see: incoming, outgoing (from Lalexi or from the connected phone) or a note. Activity lines ("resolved by …") are not sent. | message, conversation |
message.updated | A message's delivery status changed (delivered, read, failed with error_code), or its text, or it was deleted. | message, conversation |
conversation.created | A conversation you can see was created. | conversation |
conversation.updated | Status, priority, assignee, team, labels, custom fields or the snooze time changed. | conversation, changed |
contact.created | A contact was created. | contact |
contact.updated | A contact's name, e-mail, phone, identifier, custom fields or blocked state changed. | contact, changed |
channel.created | A new number was connected — through POST /channels or in Lalexi. Goes to the person who connected it and to those who work in the channel. | channel |
channel.status_changed | The connection status of a messenger channel you work in changed. | channel, previous_status, status |
ping | The test delivery of POST /webhooks/{id}/test. It cannot be subscribed to. | message |
changed lists the object's fields that changed, for example
["status", "assignee"]. One change is one delivery. The objects in data are
the ones the API returns (GET /conversations/{id}, GET /contacts/{id},
GET /channels/{id}); their fields are in the
reference.
What arrives
POST /hooks/lalexi HTTP/1.1
Content-Type: application/json
User-Agent: Lalexi-Webhooks/1
X-Lalexi-Event: message.created
X-Lalexi-Delivery: 6f1c2b9e-1d2a-4c55-9d7e-3f0a8b7c6d5e
X-Lalexi-Timestamp: 1793093400
X-Lalexi-Signature: sha256=9dfbf0a43511ead4b14f1cd9594bf54a94e01783962dc739a63e0f13d19461ed{
"id": "6f1c2b9e-1d2a-4c55-9d7e-3f0a8b7c6d5e",
"event": "message.created",
"created_at": "2026-10-27T09:30:00Z",
"workspace_id": 7,
"data": {
"message": {
"id": 88213,
"conversation_id": 1042,
"type": "incoming",
"text": "Hello! Has my order shipped yet?",
"deleted": false,
"sender": { "type": "contact", "id": 5120, "name": "Anna Petrova" },
"status": "sent",
"error_code": null,
"attachments": [],
"created_at": "2026-10-27T09:30:00Z",
"updated_at": "2026-10-27T09:30:00Z"
},
"conversation": {
"id": 1042,
"status": "open",
"priority": null,
"channel": { "id": 12, "name": "Sales", "type": "whatsapp" },
"contact": { "id": 5120, "name": "Anna Petrova", "phone": "+79001234567", "email": null },
"assignee": { "id": 3, "name": "Maria" },
"team": null,
"labels": ["order"],
"custom_attributes": {},
"snoozed_until": null,
"created_at": "2026-10-20T08:00:00Z",
"updated_at": "2026-10-27T09:30:00Z",
"last_activity_at": "2026-10-27T09:30:00Z"
}
}
}| Header | What it is |
|---|---|
X-Lalexi-Event | The event, the same as event in the body. |
X-Lalexi-Delivery | The delivery's id, the same as id in the body. It stays the same across retries — deduplicate on it. |
X-Lalexi-Timestamp | When this attempt was signed, in Unix seconds. Part of the signature. |
X-Lalexi-Signature | sha256= and the signature in hex. |
Checking the signature
Check every delivery before you trust it:
- take the
X-Lalexi-Timestampheader and the raw request body — the bytes as they arrived, before any JSON parsing; - compute HMAC-SHA256 of the string
{timestamp}.{body}keyed with the whole secret,whsec_included; hex-encode it; - compare it with what follows
sha256=inX-Lalexi-Signature, in constant time; - refuse a delivery whose signing time is more than 5 minutes away from your clock — it is an old request replayed.
import crypto from 'node:crypto';
import http from 'node:http';
const secret = process.env.LALEXI_WEBHOOK_SECRET; // whsec_…
function verify(rawBody, timestamp, signature) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || !(age <= 300)) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http
.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks);
if (!verify(raw, req.headers['x-lalexi-timestamp'], req.headers['x-lalexi-signature'])) {
res.writeHead(401).end();
return;
}
const delivery = JSON.parse(raw);
// Drop a duplicate by delivery.id, then queue the event for your own processing.
console.log(delivery.event, delivery.id);
res.writeHead(204).end();
});
})
.listen(3000);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["LALEXI_WEBHOOK_SECRET"].encode() # whsec_…
def verify(body: bytes, timestamp: str, signature: str) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/hooks/lalexi")
def lalexi_webhook():
body = request.get_data() # the raw bytes, before any JSON parsing
if not verify(body, request.headers.get("X-Lalexi-Timestamp", ""), request.headers.get("X-Lalexi-Signature", "")):
abort(401)
delivery = request.get_json()
# Drop a duplicate by delivery["id"], then queue the event for your own processing.
print(delivery["event"], delivery["id"])
return "", 204Do not re-serialise the JSON before checking: after parsing and serialising again the bytes differ and the signature will not match.
Answer, retries and switch-off
- Answer with any 2xx within 5 seconds (a delivery is cut off at 15 seconds in all, redirects included). The response body is not read. Do the heavy work after answering, in your own queue.
- Anything else is a failed attempt: another status, a timeout, a dropped connection. The delivery is retried with growing pauses — from seconds to a few minutes, up to 5 attempts in all.
- After 50 failed attempts in a row (retries count) the webhook is switched
off:
active: false. Turn it back on withPATCH /webhooks/{id}and{"active": true}— the failure count is cleared. While it is off, events for it are not kept. - The last attempt's outcome is in
GET /webhooks/{id}:last_delivery_at,last_status,last_error,consecutive_failures. - Order is not guaranteed, and a delivery may rarely arrive twice. Drop
duplicates by
X-Lalexi-Delivery(idin the body) and order by the objects'updated_at.