Skip to content

Webhooks ​

Webhooks let you receive real-time HTTP notifications when events happen in Looma — new leads and chat messages.

How It Works ​

  1. You create a webhook with a URL and list of events
  2. When an event occurs, Looma sends a POST request to your URL
  3. Your server responds with HTTP 2xx to acknowledge receipt
  4. If delivery fails, Looma retries with exponential backoff

Supported Events ​

EventDescription
lead.createdA new lead was collected from a chat
chat.startedA new chat request was initiated
chat.message.newA new message in a chat session

Creating a Webhook ​

bash
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 ​

http
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...
json
{
  "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.

FieldTypeMeaning
id, lead_idintegerLead ID (both keys carry the same value)
created_atstringISO 8601 with the server's UTC offset
statusstringLead status
name, phone, email, addressstring or nullContacts; phone is exactly as the visitor typed it (no country code is added)
lead_textstring or nullWhat the visitor wanted
custom_fieldsobjectCustom collected fields by their dashboard label
productsarrayUp to 20 products: product_id, title, price, currency, url, image_url
products_textstringOne title — price currency line per product, empty if none
site_id, site_name, site_domaininteger / string / nullThe site
sourcestringwidget, telegram, viber, facebook, instagram, …
channel_id, channel_nameinteger / string / nullThe messaging channel, if any
utm_source, utm_medium, utm_campaign, landing_page, referrerstring or nullVisit data
lead_urlstringLink to the lead in the Looma dashboard
session_id, visitor_idstringChat session and visitor
collected_fieldsobjectRaw collected fields as stored
product_idsarrayProduct 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 ​

php
$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 ​

javascript
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:

AttemptDelay
1st retry30 seconds
2nd retry2 minutes
3rd retry10 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:

bash
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

Looma.chat — AI-powered e-commerce search & chat