# Webhooks

## Overview

Outbound webhooks let WaDesk push events out to your own systems in real time — a CRM, a data warehouse, Zapier, Make, n8n, or any HTTPS endpoint. Whenever an event you've subscribed to happens in your workspace, WaDesk sends a JSON request to your URL, optionally signed so you can verify it really came from WaDesk.

Manage them under More → Webhooks. Webhooks are shared across the whole workspace: every member sees every endpoint, no matter who created it. When an event happens, WaDesk delivers it to every active endpoint in that workspace that subscribed to the event (or to the wildcard `*`).

> Plan limits. Outbound webhooks are a paid-plan feature, and each plan allows a set number of endpoints. If your plan doesn't include the feature, or you've hit your limit, creating a new endpoint is blocked with an upgrade prompt. You can still edit and pause/resume the endpoints you already have.

> Two different directions — don't mix them up. This page is about webhooks WaDesk sends to you. The webhook that Meta or Twilio sends to WaDesk (incoming messages, delivery receipts) is a single endpoint set up once by your administrator — see Inbound vs Outbound at the bottom.

## Creating an Endpoint

Click Add a webhook on the index and fill in the form. The right rail shows a live sample payload and a Test fire action so you can wire up and validate your receiver before any real event arrives.

Field | Required | Notes
--- | --- | ---
Webhook URL | Yes | The address WaDesk sends to. Must be a valid URL — use HTTPS. Stored encrypted.
Method | No | The HTTP method used to deliver — `POST` (the default) or `PUT`.
Internal name | No | A label to recognise the endpoint, e.g. “Production CRM relay”. Up to 191 characters.
Environment | No | A free-text tag for organising endpoints (defaults to `Production`).
Events | Yes | Pick at least one event. See Event Types.
Signing secret | No (recommended) | Used to sign each delivery so you can verify it. Stored encrypted. See Signing & Verification.
Status | No | Whether the endpoint is active (receiving events) when you save. On by default.

> Everything sensitive is encrypted. The URL, the secret, and the event list are all encrypted in the database and only unlocked in memory at the moment a delivery is sent — so a database leak won't expose your endpoints or secrets.

## Event Types

These are the events you can subscribe an endpoint to. Subscribe to any subset — an endpoint only receives the events in its list. The event name is what appears in the `event` / `eventType` fields of every payload.

Event | Fires when
--- | ---
`message_received` | An inbound message is received from a contact.
`message_sent` | An outbound message is accepted for sending.
`message_delivered` | An outbound message is delivered to the recipient's device.
`message_read` | An outbound message is read (recipient has read receipts on).
`message_failed` | An outbound message fails to send — payload carries the failure reason.
`broadcast_created` | A broadcast is created.
`broadcast_status_updated` | A broadcast's overall status changes.
`broadcast_message_status_updated` | A single broadcast recipient's message changes status.
`campaign_created` | A campaign is created.
`campaign_status_updated` | A campaign's status changes.
`campaign_contact_status_updated` | A campaign contact's status changes.
`campaign_contact_clicked` | A campaign contact clicks a tracked link.
`campaign_contact_replied` | A campaign contact replies.
`contact_opt_in` | A contact's subscription state changes — opt-in or opt-out (a STOP keyword, or the unsubscribe toggle on a contact). The payload carries `opted_in` (boolean), an `action` of `unsubscribed` / `resubscribed`, and a `source`.
`contact_updated` | A contact record is edited (from the contact detail screen).
`device_status_updated` | A device's connection status changes (connected / disconnected / pairing).

> Every event above is live. All sixteen fire the moment the action happens. The delivery to your endpoint is sent after the action finishes, so a slow receiver on your end never delays a send, an incoming message, or a status update inside WaDesk.

> Wildcard. Subscribe an endpoint to `*` to receive every event type, including any added in future releases. Use it as a catch-all; use an explicit list when your receiver only handles specific events.

## Delivery Payload

Every delivery is a JSON body with the same outer "envelope" for all events; the event-specific fields live under `data`. The request is sent with `Content-Type: application/json`.

### Envelope

Key | Type | Meaning
--- | --- | ---
`id` | string (UUID) | A unique ID for this delivery. Use it for idempotency / de-duplication.
`event` | string | The event name, e.g. `message_delivered`.
`eventType` | string | The same value as `event` (provided for receivers that key on either name).
`created` | string (ISO-8601) | When WaDesk built the payload, e.g. `2026-05-29T18:32:08+00:00`.
`timestamp` | integer (Unix) | Event time in seconds. Mirrors the upstream event time when one is available, else the build time.
`data` | object | The event-specific payload (fields vary by event).

### Example — `message_delivered`

A real delivery looks like this. The `data` block for the `message_*` family carries the message identity, the recipient, the WhatsApp message id (`wamid`), and — on failure — the typed error code and reason:

    POST /your/endpoint HTTP/1.1
    Host: api.yourbrand.example
    Content-Type: application/json
    X-WaDesk-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

    {
      "id": "3a8f7e2c-91b5-4d0f-a7a1-d6c8e4b9f2c0",
      "event": "message_delivered",
      "eventType": "message_delivered",
      "created": "2026-05-29T18:32:08+00:00",
      "timestamp": 1780000328,
      "data": {
        "workspace_id": 42,
        "user_id": 7,
        "message_id": 918273,
        "wamid": "wamid.HBgLOTE5ODc2NTQzMjEwFQIAERgSM0E...",
        "recipient": "+919876543210",
        "status": "delivered",
        "timestamp": 1780000328,
        "error_code": null,
        "error_reason": null,
        "pricing": { "billable": true, "category": "marketing" },
        "conversation": { "id": "abc123...", "origin": { "type": "marketing" } }
      }
    }

> On a failed send (`message_failed`) the same `data` shape carries `status: "failed"` with `error_code` and `error_reason` populated from the provider. Broadcast events add a `broadcast_id` / `broadcast_name` and an `aggregate` block with running `sent / delivered / read / failed / clicked / total` counters. Always code defensively — treat unknown keys as optional.

## Signing & Verification

If you set a signing secret on an endpoint, every delivery includes this header:

    X-WaDesk-Signature: <hex>

The value is `HMAC-SHA256` computed over the exact JSON request body (the whole envelope, not just `data`), using your endpoint's secret as the key, encoded as lowercase hex. If no secret is set, the header is omitted entirely.

### Verifying on your side

Recompute the HMAC over the raw request body (before any JSON parsing or re-serialisation) and compare it to the header in constant time:

    # Pseudo-code — works in any language
    secret   = "the secret you configured on the endpoint"
    raw_body = read_raw_request_body()          # bytes, exactly as received
    expected = hex( hmac_sha256(secret, raw_body) )
    given    = request.header("X-WaDesk-Signature")

    if not constant_time_equals(expected, given):
        return 401                              # reject — do not process
    process(json_parse(raw_body))               # safe to handle now

    // Node.js (Express, raw body required)
    const crypto = require('crypto');
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(req.rawBody)                       // Buffer of the raw body
      .digest('hex');
    const given = req.get('X-WaDesk-Signature') || '';
    const ok = expected.length === given.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given));
    if (!ok) return res.status(401).end();

    // PHP (mirrors how WaDesk signs it)
    $expected = hash_hmac('sha256', $rawBody, $secret);
    $given    = $_SERVER['HTTP_X_WADESK_SIGNATURE'] ?? '';
    if (!hash_equals($expected, $given)) { http_response_code(401); exit; }

> Verify before you parse. Always compute the HMAC on the raw body bytes — re-serialising the parsed JSON can reorder keys or change whitespace and break the signature. Compare in constant time. Treat a missing or mismatched signature as a failed request and discard it. Keep the secret confidential: anyone who has it can forge valid-looking deliveries.

## Delivery, Retries & Health

Each delivery is a single HTTP request with a 10-second timeout. A delivery counts as successful on any `2xx` response; anything else (a 4xx, a 5xx, a timeout, a DNS failure, or a connection error) counts as a failure. For every attempt, WaDesk records the status code, how long it took, the response body, and any error text, and updates the endpoint's success and failure counts plus when it last fired.

An endpoint is automatically marked as failing after three failures in a row. It keeps receiving events while failing — the flag is just a health warning, not a pause. To actually stop deliveries, pause the endpoint.

State | Meaning
--- | ---
active | Enabled and not flagged — delivering normally.
failing | Enabled but recent deliveries are erroring. Still receives events.
paused | Disabled — receives no events at all.

> Design your receiver to be idempotent and fast. Respond `2xx` immediately and do heavy work asynchronously on your side. De-duplicate on the envelope `id` — the same logical event can be re-delivered, and inbound provider retries upstream can produce more than one notification. A slow endpoint inflates your p95 latency and risks the failing flag.

## Monitoring & Analytics

The Webhooks index gives you a live health overview across all endpoints in the workspace:

- Endpoints — total configured, plus active / paused counts.
- Events fired (24h) — deliveries in the last day.
- Success rate — percentage of `2xx` responses over the last 24h.
- Latency p95 — 95th-percentile delivery latency.
- Event mix — the top events by volume over 24h.
- Recent deliveries — a feed of the latest attempts with their status codes.

Opening a single endpoint shows the last 7 days of deliveries: a daily success-vs-failure chart, status-code buckets (`2xx` / `4xx` / `5xx` / other), p95 latency, retry and failure counts, and a table of recent deliveries with their response details. You can filter the index by state (all / active / paused / failing) and search by URL, name, or event.

## Managing & Test-firing Endpoints

For each endpoint you can:

- Test fire — send a sample payload (a `test_fire` event with a friendly greeting in `data.message`) and see the live status code and latency the same way a real event would record them. It signs the request with your secret too, so it validates the full path including verification.
- Toggle — pause or resume the endpoint.
- Edit — change the URL, events, secret, method, name, or environment.
- Delete — remove the endpoint and stop all deliveries to it.

> Tip. After editing an endpoint, run Test fire to confirm your receiver still accepts the payload and the signature before relying on live events. A successful test fire records a normal delivery row, so it also appears in the analytics.

## Inbound vs Outbound

These are two completely different things — keep them straight:

| Outbound webhooks (this page) | Inbound platform webhook
--- | --- | ---
Direction | WaDesk → your systems | Meta / Twilio → WaDesk
Who configures | You, per workspace, under More → Webhooks | The platform administrator, once
What it carries | Your subscribed lifecycle events | Inbound WhatsApp messages + delivery / read / failed receipts from the provider
Endpoint | Any URL you own | A single fixed WaDesk URL (below)

The inbound platform webhook is one shared endpoint that both Meta and Twilio post to (WaDesk detects which provider by the request shape):

    https://YOUR-DOMAIN/webhooks/whatsapp/inbound

- Meta (Cloud API / WABA). Paste this URL in your Meta app dashboard under WhatsApp → Configuration → Callback URL, set the Verify token to the value saved in admin settings, and subscribe to the `messages` and `message_status` fields. Meta first does a `GET` verification handshake (it echoes back its challenge only if the verify token matches), then signs every `POST` with `X-Hub-Signature-256`, which WaDesk validates against the configured Meta App Secret.
- Twilio. Paste the same URL as the inbound / status callback on your Twilio Messaging Service. Twilio signs each request with `X-Twilio-Signature`, which WaDesk validates against your Twilio Auth Token.

> Where to set the provider side. The verify token, Meta App Secret, the Unofficial API Server URL setting, and Twilio credentials all live on the admin System Settings → WhatsApp provider configuration page, which also displays this exact callback URL with a copy button. You do not need an outbound webhook to receive inbound messages — that is what the inbound endpoint is for.