Lalexi
API

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}/test sends a test ping event — signed and retried like real ones.
  • Your webhooks: GET /webhooks; change the address or the events — PATCH /webhooks/{id}; delete — DELETE /webhooks/{id}.

Events

EventWhen it comesWhat data holds
message.createdA 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.updatedA message's delivery status changed (delivered, read, failed with error_code), or its text, or it was deleted.message, conversation
conversation.createdA conversation you can see was created.conversation
conversation.updatedStatus, priority, assignee, team, labels, custom fields or the snooze time changed.conversation, changed
contact.createdA contact was created.contact
contact.updatedA contact's name, e-mail, phone, identifier, custom fields or blocked state changed.contact, changed
channel.createdA 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_changedThe connection status of a messenger channel you work in changed.channel, previous_status, status
pingThe 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"
    }
  }
}
HeaderWhat it is
X-Lalexi-EventThe event, the same as event in the body.
X-Lalexi-DeliveryThe delivery's id, the same as id in the body. It stays the same across retries — deduplicate on it.
X-Lalexi-TimestampWhen this attempt was signed, in Unix seconds. Part of the signature.
X-Lalexi-Signaturesha256= and the signature in hex.

Checking the signature

Check every delivery before you trust it:

  1. take the X-Lalexi-Timestamp header and the raw request body — the bytes as they arrived, before any JSON parsing;
  2. compute HMAC-SHA256 of the string {timestamp}.{body} keyed with the whole secret, whsec_ included; hex-encode it;
  3. compare it with what follows sha256= in X-Lalexi-Signature, in constant time;
  4. 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 "", 204

Do 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 with PATCH /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 (id in the body) and order by the objects' updated_at.

On this page