Skip to content

Webhooks

A webhook sends your server an HTTPS request when something happens: a customer writes, a message changes status, a number connects. Each request is signed so you can prove it came from Vendrix.

These examples use a placeholder address. Your console shows the exact address of the API for your workspace.

Endpoints#

Add endpoints in the console, in the Webhooks tab of an application. The URL must be public HTTPS on port 443, without credentials. Vendrix refuses localhost, private addresses and redirects. Sandbox applications can have endpoints too: they only receive synthetic events.

An event goes to every active endpoint of the same workspace and the same environment that subscribes to its type. Live events go to live applications, sandbox events to the sandbox application that asked for them.

Events#

Webhook events
EventDescriptionData
message.receivedA customer wrote to one of your connections.message: a message with direction: inbound and status: received.
message.status_updatedA message sent through the API changed status.message: an outbound message with its new status.
connection.connectedA WhatsApp number was connected.connection.
connection.disconnectedA WhatsApp number was disconnected.connection.
developer.application.createdAn application was created in the workspace.application_id and environment.
webhook.testThe test event of the console's Send test event button.message: a fixed sentence.

What a delivery looks like#

HTTP
POST /vendrix/webhook HTTP/1.1
Content-Type: application/json
Vendrix-Event-Id: evt_4f1c2b9a0d7e6f5a4b3c2d1e
Vendrix-Timestamp: 1792000000
Vendrix-Signature: v1=5d2b0c1e…

{…}

The body is JSON with the keys sorted and no spaces, shown here indented. data depends on the event type. livemode is false for sandbox events.

Event body
{
  "api_version": "2026-10-14",
  "created_at": "2026-10-14T09:30:07Z",
  "data": {
    "message": {
      "id": "1b2c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
      "object": "message",
      "direction": "outbound",
      "connection_id": "6f0a3b1c-9d2e-4f50-8a71-3c5e7d9b1a24",
      "to": "+237670000000",
      "from": null,
      "type": "template",
      "text": null,
      "template": {
        "name": "order_confirmation",
        "language": "fr"
      },
      "status": "delivered",
      "error": null,
      "client_reference": "order-1042",
      "created_at": "2026-10-14T09:30:00Z",
      "updated_at": "2026-10-14T09:30:07Z",
      "livemode": true
    }
  },
  "id": "evt_4f1c2b9a0d7e6f5a4b3c2d1e",
  "livemode": true,
  "type": "message.status_updated"
}
Event body
{
  "api_version": "2026-10-14",
  "created_at": "2026-10-14T09:30:07Z",
  "data": {
    "message": {
      "id": "7d6e5f4a-3b2c-4d1e-8f0a-9b8c7d6e5f4a",
      "object": "message",
      "direction": "inbound",
      "connection_id": "6f0a3b1c-9d2e-4f50-8a71-3c5e7d9b1a24",
      "to": null,
      "from": "+237670000000",
      "type": "text",
      "text": "Bonjour, ma commande est-elle arrivée ?",
      "template": null,
      "status": "received",
      "error": null,
      "client_reference": null,
      "created_at": "2026-10-14T09:30:07Z",
      "updated_at": "2026-10-14T09:30:07Z",
      "livemode": true
    }
  },
  "id": "evt_4f1c2b9a0d7e6f5a4b3c2d1e",
  "livemode": true,
  "type": "message.received"
}
Event body
{
  "api_version": "2026-10-14",
  "created_at": "2026-10-14T09:30:07Z",
  "data": {
    "message": "This is a test event from Vendrix."
  },
  "id": "evt_4f1c2b9a0d7e6f5a4b3c2d1e",
  "livemode": true,
  "type": "webhook.test"
}

Verify the signature#

The signature is the hex of an HMAC-SHA256, keyed with your signing secret, over the event id, a dot, the timestamp, a dot, and the raw body:

HMAC_SHA256(secret, "<Vendrix-Event-Id>.<Vendrix-Timestamp>." + raw body)

  1. Read the body as raw bytes, before you parse the JSON. A re-serialised body gives a different signature.
  2. Reject the request if the timestamp is more than 5 minutes away from your clock. That stops a captured request from being replayed later.
  3. Compute the signature and compare it, in constant time, with each v1= value in Vendrix-Signature. Accept the request if any of them matches.
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

type Headers = Record<string, string | undefined>;

/**
 * Returns true when `rawBody` was sent by Vendrix with `secret`.
 * `rawBody` must be the bytes as received, before any JSON parsing.
 * `headers` must have lower-case names, as Node's http module gives them.
 */
export function verifyVendrixWebhook({
  secret,
  rawBody,
  headers,
  now = Date.now(),
}: {
  secret: string;
  rawBody: string | Buffer;
  headers: Headers;
  now?: number;
}): boolean {
  const eventId = headers["vendrix-event-id"];
  const timestamp = headers["vendrix-timestamp"];
  const signature = headers["vendrix-signature"];
  if (!eventId || !timestamp || !signature) return false;

  // Reject old (or far-future) timestamps so a captured request cannot be replayed later.
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(now / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${eventId}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // While a secret is being rotated the header holds two values: "v1=<new>,v1=<old>".
  // Accept the request if any of them matches.
  return signature
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.startsWith("v1="))
    .some((part) => {
      const candidate = Buffer.from(part.slice(3), "hex");
      return candidate.length === expected.length && timingSafeEqual(candidate, expected);
    });
}

Here is how a receiver uses it. Save the function above as verify-vendrix-webhook next to it.

import express from "express";
import { verifyVendrixWebhook } from "./verify-vendrix-webhook";

const app = express();

// Read the body as raw bytes: the signature covers the exact bytes Vendrix sent.
app.post("/vendrix/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const verified = verifyVendrixWebhook({
    secret: process.env.VENDRIX_WEBHOOK_SECRET,
    rawBody: req.body,
    headers: req.headers,
  });
  if (!verified) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  // Delivery is at least once: skip an event.id you have already handled.
  // Answer 2xx quickly, then do the work.
  res.sendStatus(200);
});

Delivery and retries#

  • A response with a 2xx status within 15 seconds counts as delivered. Anything else, or no answer, is a failure.
  • A failure is retried with a growing delay, min(6 hours, 30 seconds × 2^attempt), up to 12 attempts, about 20 hours in all. Then the delivery is failed.
  • You can replay a failed delivery from the Deliveries tab of the application.
  • Delivery is at least once. The same event can arrive twice: deduplicate on Vendrix-Event-Id.
  • Order is not guaranteed. Do not assume message.received arrives before the status of the reply.

Rotating the secret#

Use Rotate secret on an endpoint to create a new signing secret. It is shown once. The old secret keeps working for 24 hours so you can deploy the new one without dropping events.

During those 24 hours every delivery carries two signatures, the new one first. A receiver holding either secret accepts it, because it accepts any matching v1= value.

HTTP
Vendrix-Signature: v1=<signature with the new secret>,v1=<signature with the old secret>

Rotating again during a rotation replaces the new secret and keeps the oldest one until its original expiry.

Testing your endpoint#

  • Send test event on an endpoint sends one webhook.test to that endpoint only.
  • With a sandbox key, POST /sandbox/events sends a synthetic event of any type to your sandbox endpoints.
  • The Deliveries tab shows each attempt: the status, the HTTP code your server answered and the next attempt.