Webhooks
Webhooks let you receive real-time HTTP notifications when events happen in Looma — new leads and chat messages.
How It Works
- You create a webhook with a URL and list of events
- When an event occurs, Looma sends a POST request to your URL
- Your server responds with HTTP 2xx to acknowledge receipt
- If delivery fails, Looma retries with exponential backoff
Supported Events
| Event | Description |
|---|---|
lead.created | A new lead was collected from a chat |
chat.started | A new chat request was initiated |
chat.message.new | A new message in a chat session |
Creating a Webhook
curl -X POST https://api.looma.chat/v1/webhooks \
-H "Authorization: Bearer lm_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/looma",
"events": ["lead.created", "chat.message.new"]
}'The response includes a secret that is shown only once. Save it securely — you'll need it to verify webhook signatures.
Subscribing to an event needs the scope that reads its data, besides webhooks:manage: leads:read for lead.created, chats:read for chat.started and chat.message.new (otherwise 403 scope_denied). site_ids is either omitted/null (all sites) or a non-empty list of site IDs; an empty list is rejected with 422. A key limited to some sites subscribes only within them.
Payload Format
POST /webhooks/looma HTTP/1.1
Content-Type: application/json
X-Looma-Event: lead.created
X-Looma-Delivery: 25
X-Looma-Timestamp: 1700000000
X-Looma-Signature: sha256=a1b2c3d4e5f6...{
"id": 451,
"lead_id": 451,
"created_at": "2026-01-15T10:00:00+02:00",
"status": "new",
"name": "Olena",
"phone": "067 123 45 67",
"email": "[email protected]",
"address": null,
"lead_text": "Customer interested in Nike Air Max",
"custom_fields": { "City": "Lviv" },
"products": [
{
"product_id": "1024",
"title": "Nike Air Max 90",
"price": 4200.0,
"currency": "UAH",
"url": "https://shop.ua/air-max-90",
"image_url": "https://shop.ua/air-max-90.jpg"
}
],
"products_text": "Nike Air Max 90 — 4200 UAH",
"site_id": 8,
"site_name": "My Shop",
"site_domain": "shop.ua",
"source": "telegram",
"channel_id": 12,
"channel_name": "@shop_bot",
"utm_source": null,
"utm_medium": null,
"utm_campaign": null,
"landing_page": null,
"referrer": null,
"lead_url": "https://app.looma.chat/leads?id=451",
"session_id": "s_9f2c41a8",
"visitor_id": "v_58e51ff36a",
"collected_fields": { "name": "Olena", "phone": "067 123 45 67", "custom_1729000000000": "Lviv" },
"product_ids": [1024, 2048]
}The body of lead.created is one flat record: every key is always present (null, [] or {} when there is no value), so a receiver can map fields without checking for absence. The same object is returned by GET /v1/leads?format=flat.
lead.created is a snapshot taken when the lead is created — usually at the visitor's first contact. Fields added to the same lead later in the conversation (an email, more products, more text) are not sent again; GET /v1/leads?format=flat returns the lead as it is now, so a later read can hold more than the event did.
| Field | Type | Meaning |
|---|---|---|
id, lead_id | integer | Lead ID (both keys carry the same value) |
created_at | string | ISO 8601 with the server's UTC offset |
status | string | Lead status |
name, phone, email, address | string or null | Contacts; phone is exactly as the visitor typed it (no country code is added) |
lead_text | string or null | What the visitor wanted |
custom_fields | object | Custom collected fields by their dashboard label |
products | array | Up to 20 products: product_id, title, price, currency, url, image_url |
products_text | string | One title — price currency line per product, empty if none |
site_id, site_name, site_domain | integer / string / null | The site |
source | string | widget, telegram, viber, facebook, instagram, … |
channel_id, channel_name | integer / string / null | The messaging channel, if any |
utm_source, utm_medium, utm_campaign, landing_page, referrer | string or null | Visit data |
lead_url | string | Link to the lead in the Looma dashboard |
session_id, visitor_id | string | Chat session and visitor |
collected_fields | object | Raw collected fields as stored |
product_ids | array | Product IDs exactly as stored (numbers stay numbers) |
Backward compatible
lead_id, site_id, session_id, visitor_id, channel_id, lead_text, collected_fields, product_ids and created_at keep their names and types from before the flat format — existing receivers keep working. The other fields were added on top. If the flat record cannot be built for a lead, Looma sends the original short body instead.
The other events (chat.started, chat.message.new) keep their own payloads.
Deduplicating
X-Looma-Delivery is the ID of the delivery and changes on every retry, so it cannot identify a lead. Deduplicate lead.created by the lead id.
Verifying Signatures
Every webhook request is signed with HMAC-SHA256. Always verify the signature to ensure the request came from Looma.
The signature is computed as:
HMAC-SHA256(timestamp + "." + payload, secret)PHP Example
$payload = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_LOOMA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_LOOMA_SIGNATURE'] ?? '';
// The header carries a `sha256=` prefix — strip it before comparing.
$signature = str_starts_with($signature, 'sha256=') ? substr($signature, 7) : $signature;
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// Optional: reject requests older than 5 minutes
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(401);
exit('Timestamp too old');
}
$data = json_decode($payload, true);
// Process the event...JavaScript Example
const crypto = require('crypto');
// The signature covers the RAW body. express.json() would reparse it and
// re-serialising with JSON.stringify changes key order and Unicode escaping,
// so the HMAC would never match. Capture the raw bytes instead.
app.post('/webhooks/looma', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString('utf8');
const timestamp = req.headers['x-looma-timestamp'];
const signature = (req.headers['x-looma-signature'] || '').replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(timestamp + '.' + payload)
.digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) {
return res.status(401).send('Invalid signature');
}
// The event name lives in the header, not in the body.
const event = req.headers['x-looma-event'];
const data = JSON.parse(payload);
console.log(`Received: ${event}`, data);
res.status(200).send('OK');
});Retry Policy
If your server doesn't respond with HTTP 2xx, Looma retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 2 minutes |
| 3rd retry | 10 minutes |
After 3 failed retries, the delivery is marked as failed. After 10 consecutive failures across different deliveries, the webhook is automatically disabled.
You can re-enable a disabled webhook from the API or the dashboard.
Answering 410 Gone
If your endpoint answers HTTP 410 Gone, Looma treats it as "unsubscribed": the webhook is deleted immediately, with no retries. This is how Zapier tells Looma that a Zap was turned off or removed. Use 410 only when you really want the webhook gone; any other non-2xx status follows the retry policy above.
Testing
Use the test endpoint to send a sample payload:
curl -X POST https://api.looma.chat/v1/webhooks/5/test \
-H "Authorization: Bearer lm_your_key"This sends a test event to your webhook URL and returns the response status from your server.
Limits
- Maximum 20 webhooks per account
- Delivery timeout: 10 seconds
- Maximum payload size: ~64 KB

