Get pushed a signed HTTP event the moment a message lands in a chat, instead of polling the API.
Two events are available, and both fire for group chats as well as direct chats — a gap most WhatsApp integrations leave out.
| Name | Type | Description |
|---|---|---|
| message.received | event | A customer sent a message into a tracked chat. |
| message.sent | event | A reply went out — from the Nikahsatu inbox composer, from the public API (POST /messages/send), or typed directly from the linked WhatsApp phone. All three paths fire the same event. |
Add an endpoint in Integration → Developers, choosing which event(s) to subscribe to. Your endpoint URL must be:
localhost, a .local/.internal hostname, or any private/ reserved IP (checked both at registration and again at every delivery, since DNS can change after registration).On success you're shown a signing secret — whsec_... — exactly once. Store it; it can only be regenerated afterward, not retrieved again.
Every delivery is a POST with a JSON body and these headers:
| Name | Type | Description |
|---|---|---|
| X-Looply-Event | header | "message.received" or "message.sent". |
| X-Looply-Timestamp | header | ISO 8601 timestamp of the delivery attempt. |
| X-Looply-Signature | header | HMAC-SHA256 of the raw request body, as "sha256=<hex>". See Verifying signatures below. |
The exact shape sent by the "Send test" button in the dashboard — real deliveries use the same shape with real ids:
{
"event": "message.received",
"timestamp": "2026-01-01T00:00:00.000Z",
"message": {
"id": "test-message-00000000",
"wa_message_id": "test-wa-00000000",
"body": "Hello from Nikahsatu — this is a test webhook",
"message_type": "conversation",
"from_me": false,
"sender": { "type": "customer", "phone": "+10000000000", "name": "Test Sender" }
},
"chat": {
"id": "test-chat-00000000",
"jid": "10000000000@s.whatsapp.net",
"name": "Test Chat",
"is_group": false
},
"instance": { "id": "test-instance-00000000", "phone": "+10000000000" }
}| Name | Type | Description |
|---|---|---|
| event | string | "message.received" or "message.sent". |
| timestamp | string (ISO 8601) | When the message occurred. |
| message.id | string | Nikahsatu's internal message id. |
| message.wa_message_id | string | WhatsApp's own message id. |
| message.body | string | null | Message text (null for non-text message types). |
| message.message_type | string | null | WhatsApp message type, e.g. "conversation". |
| message.from_me | boolean | True if sent from your connected number. |
| message.sender.type | string | "customer", "team", or "system". |
| message.sender.phone | string | null | Sender's phone number, when known. |
| message.sender.name | string | null | Sender's display name, when known. |
| chat.id | string | Nikahsatu chat id (matches the REST API's chat_id). |
| chat.jid | string | The chat's WhatsApp JID. |
| chat.name | string | null | Chat name or client label. |
| chat.is_group | boolean | True for a group chat, false for a direct chat. |
| instance.id | string | The connected WhatsApp number this event came through. |
| instance.phone | string | null | That number's phone number. |
Recompute the HMAC-SHA256 of the raw request body bytes (not a re-serialized copy — JSON key order and whitespace matter) using your endpoint's secret, and compare it to X-Looply-Signature with a constant-time comparison so a mismatch can't be timed to leak the secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidSignature(rawBody, signatureHeader, secret) {
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: read the RAW body — express.json() would re-serialize it first.
app.post("/webhooks/looply", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
const signature = req.header("X-Looply-Signature");
if (!isValidSignature(raw, signature, process.env.LOOPLY_WEBHOOK_SECRET)) {
return res.status(401).send("invalid signature");
}
res.status(200).end(); // ack fast, process async
const event = JSON.parse(raw);
// ... handle event
});Any 2xx response counts as success. Your endpoint has 5 seconds to respond — respond fast and process asynchronously. A delivery worker checks for due deliveries every minute; a failure is retried with backoff up to 4 attempts total, then marked failed:
| Attempt | Timing |
|---|---|
| 1 | Immediate |
| 2 | +1 minute after attempt 1 fails |
| 3 | +5 minutes after attempt 2 fails |
| 4 | +30 minutes after attempt 3 fails |
| — | Attempt 4 failing marks the delivery failed — no further retries |
Use the Send test button next to an endpoint in Integration → Developers to fire the sample payload above at it, with a real signature from that endpoint's secret — good for validating your handler before real traffic flows. Every delivery attempt (test or real) is logged, and the dashboard shows the most recent 50 per endpoint: status, HTTP code, response time, and the last error if it failed.
2xx as fast as possible — queue the payload and process it asynchronously rather than doing real work before responding.message.id — retries and the reconcile process can occasionally result in the same event being delivered more than once.