Webhooks

Get pushed a signed HTTP event the moment a message lands in a chat, instead of polling the API.

Events

Two events are available, and both fire for group chats as well as direct chats — a gap most WhatsApp integrations leave out.

NameTypeDescription
message.receivedeventA customer sent a message into a tracked chat.
message.senteventA 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.

Registering an endpoint

Add an endpoint in Integration → Developers, choosing which event(s) to subscribe to. Your endpoint URL must be:

  • Public HTTPS — plain HTTP is rejected.
  • Not 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.

Delivery headers

Every delivery is a POST with a JSON body and these headers:

NameTypeDescription
X-Looply-Eventheader"message.received" or "message.sent".
X-Looply-TimestampheaderISO 8601 timestamp of the delivery attempt.
X-Looply-SignatureheaderHMAC-SHA256 of the raw request body, as "sha256=<hex>". See Verifying signatures below.

Payload

The exact shape sent by the "Send test" button in the dashboard — real deliveries use the same shape with real ids:

Example payload
{
  "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" }
}
NameTypeDescription
eventstring"message.received" or "message.sent".
timestampstring (ISO 8601)When the message occurred.
message.idstringNikahsatu's internal message id.
message.wa_message_idstringWhatsApp's own message id.
message.bodystring | nullMessage text (null for non-text message types).
message.message_typestring | nullWhatsApp message type, e.g. "conversation".
message.from_mebooleanTrue if sent from your connected number.
message.sender.typestring"customer", "team", or "system".
message.sender.phonestring | nullSender's phone number, when known.
message.sender.namestring | nullSender's display name, when known.
chat.idstringNikahsatu chat id (matches the REST API's chat_id).
chat.jidstringThe chat's WhatsApp JID.
chat.namestring | nullChat name or client label.
chat.is_groupbooleanTrue for a group chat, false for a direct chat.
instance.idstringThe connected WhatsApp number this event came through.
instance.phonestring | nullThat number's phone number.

Verifying signatures

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
});

Retries & timeouts

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:

AttemptTiming
1Immediate
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

Testing & monitoring

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.

Best practices

  • Respond 2xx as fast as possible — queue the payload and process it asynchronously rather than doing real work before responding.
  • Verify the signature on every request before trusting the body.
  • Deduplicate on message.id — retries and the reconcile process can occasionally result in the same event being delivered more than once.