Skip to content

    Arabic or English · ↑↓ to move · Enter to open · Esc to close

    العربية
    On this page

    Webhooks

    Real-time signed events: the event catalog with full examples, signature verification, retries, redelivery, ordering and idempotency.

    Mursilat calls your HTTPS endpoint when something happens: a customer message, a delivery receipt, a button answer, an opt-out, a connection change, a campaign pause. Inbound message content reaches you through the API only this way, live — the API never returns it later (your dashboard inbox keeps it encrypted, for the retention you choose).

    Set up

    • Account webhook — one URL for every number: PUT /v2/webhook. Not available to keys restricted to some numbers.
    • Per-number webhook — PUT /v2/numbers/{numberId}/webhook, or in the dashboard (Numbers → Developer).
    • With both, each event is delivered to both with the same event id.
    • Public https:// URL on port 443 (or 80). Private addresses are refused, redirects never followed. The signing secret is returned once; rotate it any time.

    The request

    http
    POST /your/webhook HTTP/1.1
    Content-Type: application/json
    User-Agent: Wasla-Webhooks/2
    X-Wasla-Event: message.status
    X-Wasla-Delivery: dlv_3c1f0a9b8e7d6c5b4a392817
    X-Wasla-Webhook-Version: 2
    X-Wasla-Signature: t=1760353203,v1=5d2f…(64 hex)
    
    { "id": "evt_…", "type": "message.status", "apiVersion": "2026-10", "createdAt": "…", "sessionId": "…", "number": "201001234567", "data": { … } }
    FieldMeaning
    idMeaningUnique per event. Retries and redeliveries carry the same id — dedupe on it.
    typeMeaningEvent type (catalog below).
    apiVersionMeaning2026-10.
    createdAtMeaningWhen the event happened.
    sessionId / numberMeaningThe number id and its phone digits.
    dataMeaningEvent-specific payload.

    Event catalog

    message.received

    A customer sent a message to your number. Text, captions and signed media links reach you here, live; the webhook payload isn’t kept after the retry window.

    message.received — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "message.received",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "messageId": "3EB0C1D2E3F4A5B6C7D8",
        "from": {
          "phone": "201099887766",
          "lid": null,
          "name": "منى"
        },
        "chat": {
          "type": "direct"
        },
        "type": "text",
        "text": "عايز أعرف السعر",
        "caption": null,
        "media": null,
        "location": null,
        "replyTo": null,
        "timestamp": "2026-10-13T08:00:02.000Z"
      }
    }

    message.status

    A message you sent moved to sent, delivered, read or failed (with the error code).

    message.status — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "message.status",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "messageId": "3EB0A1B2C3D4E5F60718",
        "reference": "order-1042",
        "to": "201099887766",
        "status": "delivered",
        "error": null,
        "timestamp": "2026-10-13T08:00:05.000Z"
      }
    }

    interactive.reply

    A customer answered buttons or a list — tapped, or typed the number / title of an option (fallback). Only the first valid answer per message.

    interactive.reply — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "interactive.reply",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "interactionId": "int_8f2c1d9a7b3e4f56a1b2c3d4",
        "messageId": "3EB0A1B2C3D4E5F60718",
        "reference": "order-1042",
        "from": {
          "phone": "201099887766",
          "lid": null,
          "name": "منى"
        },
        "reply": {
          "id": "confirm",
          "title": "تأكيد",
          "index": 1,
          "source": "button"
        },
        "replyMessageId": "3EB0FFEE0011223344",
        "timestamp": "2026-10-13T08:03:00.000Z"
      }
    }

    poll.vote

    Someone voted on a poll your number sent. selectedOptions is their whole current ballot ([] = vote removed).

    poll.vote — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "poll.vote",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "messageId": "3EB0A1B2C3D4E5F60718",
        "reference": "survey-7",
        "from": {
          "phone": "201099887766",
          "lid": null,
          "name": "منى"
        },
        "selectedOptions": [
          "الأحد"
        ],
        "timestamp": "2026-10-13T08:04:00.000Z"
      }
    }

    contact.opted_out

    A customer sent an opt-out keyword; they are now on your suppression list and won’t receive campaigns or cold messages.

    contact.opted_out — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "contact.opted_out",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "phone": "201099887766",
        "lid": null,
        "keyword": "إلغاء",
        "timestamp": "2026-10-13T08:05:00.000Z"
      }
    }

    contact.opted_in

    You removed a person from the suppression list (sent to every v2 number of the account).

    contact.opted_in — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "contact.opted_in",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "phone": "201099887766",
        "lid": null,
        "keyword": null,
        "timestamp": "2026-10-13T09:00:00.000Z"
      }
    }

    session.status

    The linked-device session of a number connected, disconnected, was logged out or banned.

    session.status — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "session.status",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "status": "disconnected",
        "reason": null,
        "timestamp": "2026-10-13T10:00:00.000Z"
      }
    }

    number.status

    Connection state of a number changed: connected, disconnected, pairing, banned, logged_out, conflict.

    number.status — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "number.status",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "numberId": "cm1abcd2345efgh6789ijkl",
        "status": "connected",
        "reason": null,
        "timestamp": "2026-10-13T08:00:00.000Z"
      }
    }

    number.restricted

    WhatsApp restricted messaging new contacts from the number (or lifted it). until says when it ends.

    number.restricted — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "number.restricted",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "numberId": "cm1abcd2345efgh6789ijkl",
        "restricted": true,
        "until": "2026-10-14T08:00:00.000Z",
        "type": "reachout_timelock",
        "timestamp": "2026-10-13T08:00:00.000Z"
      }
    }

    campaign.status

    A campaign started, paused (with the reason), resumed, completed, failed or was cancelled.

    campaign.status — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "campaign.status",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "campaignId": "cm2camp0001xyz",
        "name": "عرض الجمعة",
        "status": "paused",
        "reason": "AUTO_LOW_DELIVERY",
        "pausedUntil": "2026-10-13T10:00:00.000Z",
        "timestamp": "2026-10-13T09:00:00.000Z"
      }
    }

    otp.verified

    An OTP sent with POST /v2/otp was verified. The code itself is never included.

    otp.verified — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "otp.verified",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "otpId": "otp_8f2c1d9a7b3e4f56a1b2c3d4",
        "to": "2010****4567",
        "verifiedAt": "2026-10-13T08:01:00.000Z"
      }
    }

    conversation.attributed

    A chat started from your website button or a tracked WhatsApp link (page, utm_*, widget / link). Metadata only.

    conversation.attributed — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "conversation.attributed",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "conversationId": "cm5conv0001",
        "contact": {
          "phone": "201099887766",
          "lid": null,
          "name": "منى"
        },
        "messageId": "3EB0C1D2E3F4A5B6C7D8",
        "source": {
          "type": "widget",
          "widgetId": "wgt_k2m7q4x9a3b5c8d2",
          "linkId": null,
          "name": "Main website",
          "agent": "sales",
          "pagePath": "/products/bag",
          "referrerDomain": "facebook.com",
          "utm": {
            "source": "facebook",
            "medium": "cpc",
            "campaign": "summer",
            "content": null,
            "term": null
          }
        },
        "clickedAt": "2026-10-13T07:58:10.000Z",
        "timestamp": "2026-10-13T08:00:02.000Z"
      }
    }

    webhook.test

    Sent by “Send a test event” and the test endpoints. Always delivered, even if not in your event list.

    webhook.test — Full request body
    {
      "id": "evt_5f0c2b7d9e1a4c3b8a6f2d10",
      "type": "webhook.test",
      "apiVersion": "2026-10",
      "createdAt": "2026-10-13T08:00:03.000Z",
      "sessionId": "cm1abcd2345efgh6789ijkl",
      "number": "201001234567",
      "data": {
        "message": "This is a test event from Mursilat",
        "timestamp": "2026-10-13T08:00:00.000Z"
      }
    }

    Verifying the signature

    X-Wasla-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Use the raw request body (before JSON parsing), compare in constant time and reject timestamps more than 5 minutes from now.

    // Node.js + Express. Use the RAW body: JSON.parse → JSON.stringify changes bytes and breaks the signature.
    import crypto from 'node:crypto';
    import express from 'express';
    
    const app = express();
    const SECRET = process.env.WASLA_WEBHOOK_SECRET;
    const seen = new Set(); // use Redis/DB in production
    
    function verifyWaslaSignature(rawBody, header, secret, toleranceSec = 300) {
      if (!header || !secret) return false;
      const parts = {};
      for (const pair of header.split(',')) {
        const i = pair.indexOf('=');
        if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
      }
      const t = parts.t;
      const v1 = parts.v1;
      if (!/^\d+$/.test(t || '') || !/^[0-9a-f]{64}$/i.test(v1 || '')) return false;
      // Reject old (replayed) or far-future timestamps.
      if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
      const expected = crypto
        .createHmac('sha256', secret)
        .update(t + '.')
        .update(rawBody) // the RAW bytes exactly as received, not re-serialized JSON
        .digest();
      const received = Buffer.from(v1, 'hex');
      return received.length === expected.length && crypto.timingSafeEqual(received, expected);
    }
    
    app.post('/webhooks/mursilat', express.raw({ type: 'application/json' }), (req, res) => {
      if (!verifyWaslaSignature(req.body, req.get('X-Wasla-Signature'), SECRET)) {
        return res.status(401).send('invalid signature');
      }
      const event = JSON.parse(req.body.toString('utf8'));
      res.sendStatus(200); // answer fast (within 10s); do the work after
    
      if (seen.has(event.id)) return; // retries reuse the same event id
      seen.add(event.id);
      if (event.type === 'interactive.reply') {
        console.log(event.data.reference, '→', event.data.reply.id);
      }
    });
    
    app.listen(3000);

    Not sure your code is right? Paste a real request into the payload inspector — it checks the signature and the envelope in your browser.

    Retries

    • Answer 2xx within 10 seconds, then do the work asynchronously.
    • Anything else (timeout, network error, 3xx, 4xx, 5xx) is retried: at once, then after 10 s, 1 min, 5 min, 15 min and 30 min — 6 attempts over about 51 minutes. Then the delivery is FAILED.
    • 410 Gone means “stop”: the webhook is disabled at once and pending deliveries are cancelled. Save the URL again to re-enable it.
    • At most 4 concurrent requests per URL. Changing or removing the URL cancels queued retries to the old one.

    Redelivery window

    Mursilat keeps an event body only in memory for the retry window: about an hour after the event, and 10 minutes after a successful delivery. Within that window POST /v2/webhooks/deliveries/{deliveryId}/redeliver sends the same event (same id) again; afterwards it answers 410 DELIVERY_PAYLOAD_EXPIRED — rebuild the state from the API (e.g. GET /v2/messages/{id}).

    Ordering and idempotency

    • Ordering is best effort: retries and parallel deliveries can arrive out of order. Use createdAt / data.timestamp — e.g. ignore delivered after you saw read.
    • Duplicates are expected (retries, redeliveries, both account and number webhooks). Store id and skip events you already handled.
    • The delivery log keeps metadata for 7 days (event, status, HTTP code, timing, error) — never the payload or your response body.