Webhooks — inbound to you

Register a webhook. PUT /webhook/ — give your https URL (not a private address) and which streams to subscribe to. The response hmac_secret signs every delivery to you, so store it. PUT replaces the WHOLE subscription, it is not a partial update: subscribe_templates defaults to false, so repeat subscribe_templates: true in every PUT that needs templates — otherwise the next PUT silently turns the templates stream off:

curl -X PUT https://dialogs.tehprof.kz/api/v1/gw/webhook/ \
  -H "Authorization: Bearer gw_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-service.example/wahub",
    "subscribe_messages": true,
    "subscribe_statuses": true,
    "subscribe_templates": true,
    "channel_ids": [32]
  }'

200 {
  "hmac_secret": "gw_9a8b7c…",
  "url": "https://your-service.example/wahub",
  "subscribe_messages": true, "subscribe_statuses": true,
  "subscribe_templates": true, "channel_ids": ["32"]
}

Subscribing to specific numbers. channel_ids narrows the stream to the listed channels; an empty list (the default) means the tenant’s whole stream, as before. A foreign or inactive channel_id returns 404 rather than a silent subscription to nothing. There is no exclusivity: several consumers may subscribe to one channel and all of them receive the event.

Check and switch off. GET /webhook/ shows the current registration — URL, streams, channels; the secret is not returned there (reading your configuration and being able to forge our deliveries are different privileges). DELETE /webhook/ clears the URL and stops delivery entirely; a repeated DELETE is still 204.

Inbound webhooks. We POST three streams to your URL. The messages stream — new messages. Inbound ones come with direction: "incoming". Operator replies from Dialogs and the CRM, messages sent from the phone and API sends by other keys arrive as message.created with direction: "outgoing" and source (wahub — Dialogs or the API, crm — the CRM, channel_echo — the phone). What your key sent itself via POST /messages/ is not echoed back to it:

{
  "messages": [
    {
      "event": "message.created",
      "message_id": "8841",
      "channel_id": "32",
      "chat_type": "whatsapp",
      "chat_id": "77010009988",
      "occurred_at": "2026-10-20T09:12:44.512000+00:00",
      "direction": "incoming",
      "source": "customer",
      "text": "Hi there!",
      "content_type": "text",
      "media_url": "",
      "file_name": "",
      "file_mimetype": "",
      "reply_to": "",
      "sender_name": "Aigerim",
      "sender_phone": "77010009988",
      "metadata": {},
      "edited": false
    }
  ]
}

direction is incoming or outgoing. source says where the message came from: customer (the person wrote), wahub (sent from Dialogs, including your own sends through this API), channel_echo (sent from the phone itself, bypassing Dialogs), crm (sent from a connected CRM). Filter on source and direction, or a bot answers its own message. There are no empty values: media_url, file_name, file_mimetype, reply_to and sender_name arrive as an empty string, never null.

Media. media_url is NOT a public link — it is a path on our host (/media/…). Prefix it with the API host and fetch it with the same Authorization: Bearer gw_… header; without the header it will not open in a browser. An inbound WhatsApp file is downloaded after the message arrives: first comes event message.created with an empty media_url, and once the file is ready, event message.updated with the same message_id, the link, file_name and file_mimetype. If the file could not be fetched, message.updated arrives with content_type text and an explanation in text. Store messages by message_id and replace the whole message on message.updated. A text edit also arrives as message.updated, with edited: true. If the message is deleted for everyone, event message.deleted arrives with the same message_id and empty text, file and metadata — delete it on your side. Events of a deleted message not yet delivered to you are no longer sent, and history leaves deleted messages out.

sender_phone is who wrote: a group member in a group, the chat itself in a one-to-one chat. metadata is what the message means; a key is present only when it applies: audio {voice, duration} — voice is true for a voice note; video {note, gif, duration} — note is true for a round video; location {lat, lon, name, address}; contact {name, phones, business}; album {id} — shared by the photos of one album; call {kind, outcome, duration_sec}; forwarded {many}; mentions — {id from the text: phone or null}; reaction {emoji, message_id} — our message_id of the message reacted to, when we have it. Check notice {kind} first: when present, this is a service record, not the customer's text — unsupported_type (WhatsApp sent something we do not show) or media_unavailable (the file could not be fetched). Skip keys you do not know: the list will grow.

The statuses stream — delivery updates for messages you sent (sent → delivered → read, or failed). message_id is the one POST /messages/ returned. Deliveries may arrive out of order: apply a status only if its status_rank is higher than the stored one (pending 0, sent 1, delivered 2, read 3, played 4). failed has rank −1 and is a separate final outcome; it carries error: code — our reason code (recipient_not_registered — the number has no WhatsApp, recipient_unreachable, file_too_large, type_unsupported, provider_unavailable, provider_error, unknown), provider_code — the provider code (e.g. 131042 on WhatsApp Cloud means a payment issue at Meta), description — the provider text, reason — the provider refusal, more precise than code (e.g. marketing_per_user_limit, payment_method_problem; an empty string when unclassified; new values may appear). failed messages in GET /messages/ carry the same field. occurred_at is when we recorded the event; X-Wahub-Timestamp is only the time of the HTTP attempt:

{
  "statuses": [
    {
      "message_id": "msg_7f3c",
      "channel_id": "32",
      "chat_type": "whatsapp",
      "chat_id": "77010009988",
      "status": "delivered",
      "status_rank": 2,
      "occurred_at": "2026-10-20T09:13:05.104000+00:00"
    }
  ]
}

The templates stream — catalogue changes (off by default, enabled by subscribe_templates). It arrives on every Meta verdict, so you never poll for one. The change field says WHAT changed: status (approved/rejected), components (body edit), quality (quality rating) or category — react to it without diffing. status is always the current one. One event arrives per channel this WABA serves:

{
  "templates": [
    {
      "template_id": "123",
      "channel_id": "32",
      "change": "status",
      "name": "order_shipped",
      "language": "en",
      "status": "APPROVED",
      "category": "UTILITY",
      "quality_rating": "GREEN",
      "rejection_reason": ""
    }
  ]
}

Verify the signature. Every delivery carries X-Wahub-Timestamp (Unix seconds), X-Wahub-Delivery-ID (the delivery UUID) and X-Wahub-Signature-V2: sha256=<hmac_sha256(hmac_secret, timestamp + "." + delivery_id + "." + RAW body)>. Compute the HMAC over the raw request bytes — before any JSON parsing, otherwise the signature will not match. Reject deliveries whose timestamp is more than 5 minutes off your clock, so a captured request cannot be sent again. Split the header on the comma and accept if ANY part matches: while a secret rotation is in flight there are two values (see below), normally one. The older X-Wahub-Signature header (HMAC over the body only, no timestamp) is still sent for compatibility; new handlers verify V2.

import hashlib, hmac, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers["X-Wahub-Timestamp"]
    delivery_id = headers["X-Wahub-Delivery-ID"]
    if abs(time.time() - int(ts)) > 300:
        return False
    signed = f"{ts}.{delivery_id}.".encode() + raw_body
    expected = "sha256=" + hmac.new(
        secret.encode(), signed, hashlib.sha256
    ).hexdigest()
    sent = headers["X-Wahub-Signature-V2"].split(",")
    return any(hmac.compare_digest(expected, s) for s in sent)
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, headers, secret) {
  const ts = headers["x-wahub-timestamp"];
  const deliveryId = headers["x-wahub-delivery-id"];
  if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
  const expected =
    "sha256=" +
    createHmac("sha256", secret)
      .update(`${ts}.${deliveryId}.`)
      .update(rawBody)
      .digest("hex");
  const want = Buffer.from(expected);
  return (headers["x-wahub-signature-v2"] ?? "").split(",").some((sent) => {
    const got = Buffer.from(sent);
    return got.length === want.length && timingSafeEqual(got, want);
  });
}

Rotating the secret without downtime. POST /webhook/rotate-secret/ issues a new hmac_secret and keeps the previous one valid for another 24 hours. During that window every delivery is signed with both secrets and both signature headers carry two comma-separated values — so you can roll out the new secret whenever suits you without dropping a single webhook. The Idempotency-Key header is required: pick a unique value for this rotation and repeat it on retries — a retry returns the same secret instead of rotating twice. Call it the moment a secret may have leaked, and on whatever schedule your policy requires. Rotating again inside the window replaces the secret you rotated last, so a leaked one never outlives its window. The new secret is shown in this response only; GET /webhook/ will not return it.

curl -X POST https://dialogs.tehprof.kz/api/v1/gw/webhook/rotate-secret/ \
  -H "Authorization: Bearer gw_xxx" \
  -H "Idempotency-Key: rotate-2026-10-20"

200 {
  "hmac_secret": "gw_new1a2b…",
  "previous_secret_valid_until": "2026-08-09T09:12:44+00:00"
}

# while the window is open:
X-Wahub-Signature-V2: sha256=<new>,sha256=<previous>

Retries and duplicates. Answer 2xx fast and do the heavy work afterwards. If your server does not answer 2xx within 10 seconds, Dialogs retries 9 more times over about 27 hours (after ~30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h and 12 h) and honours a longer Retry-After. Only a 410 Gone stops retries at once. After that, take the event from the subscription feed and replay it by hand (see "Catch up on what you missed"). A retry carries the same X-Wahub-Delivery-ID — deduplicate on it: remember the IDs you accepted and answer 2xx to a repeat. Do not deduplicate on message_id: message.updated and message.deleted arrive with the same message_id and carry the new state of the message.