Webhooks

We POST to a URL you control when something happens. Every delivery is signed, retried three times, and carries a payload documented in full below.

Registering an endpoint

POST/api/org/webhooks
Auth: API key or session (organization admin)

Up to five per organization.

cURL
KEY=$(openssl rand -base64 32)

curl -X POST https://api.wpai.co.in/api/org/webhooks \
  -H "X-API-Key: $WPAI_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"url\": \"https://example.com/hooks/wpai\",
    \"secret\": \"$KEY\",
    \"events\": [\"message.inbound\", \"message.status\", \"template.status\"]
  }"
  • secret is optional — bring your own, or we mint one. It must be 32 random bytes, base64 encoded, in either the standard or URL-safe alphabet.
  • events is optional. Omitted, the webhook subscribes to everything available at that moment.
  • The secret is returned once, in the create response, and never shown again.

One key per client if you are a partner

The signing key is per webhook, and a webhook belongs to one organization. If you run many client accounts, mint a key per client — a leaked key then exposes one tenant's deliveries rather than all of them.

Changing a subscription

PUT/api/org/webhooks/:id
Auth: API key or session (organization admin)

Accepts url, events, enabled and secret. Passing secret rotates the signing key.

New event types are not added automatically

A webhook stores its event list at creation. Events added to the platform afterwards are not delivered to it until you add them — with this call, or from the Webhooks screen in Settings.

Every event

Messages

EventFires whenChannels
message.inboundA customer sent you a message, on either channel.Both
message.statusA message you sent moved to sent, delivered, read or failed.Both

Leads

EventFires whenChannels
lead.createdThe assistant captured a new lead from a conversation.Both
lead.updatedSomeone already recorded as a lead came back with more detail.Both

Campaigns

EventFires whenChannels
campaign.completedEvery recipient of a broadcast has been attempted.Both

Templates

EventFires whenChannels
template.statusMeta approved, rejected, paused or disabled one of your templates.Cloud API
template.qualityMeta re-scored a template on how recipients are reacting to it.Cloud API
template.categoryMeta moved a template between marketing, utility and authentication.Cloud API

What a delivery looks like

A POST with Content-Type: application/json and two headers of ours. The body is always the same three keys — the event name, when it happened, and the payload.

Request
POST /hooks/wpai HTTP/1.1
Content-Type: application/json
X-WpAi-Event: message.inbound
X-WpAi-Signature: sha256=6f1c9d…

{
  "event": "message.inbound",
  "timestamp": "2026-08-26T09:14:22.104Z",
  "data": {
    "from": "919876543210",
    "name": "Priya Sharma",
    "type": "text",
    "text": "Is the shop open today?",
    "messageId": "wamid.HBg…"
  }
}

Respond with any 2xx. We do not read your response body. Anything else, or a timeout after ten seconds, counts as a failure.

Verifying a delivery

X-WpAi-Signature is sha256= followed by a hex HMAC-SHA256 of the raw request body, keyed with your signing secret.

Two things people get wrong

Hash the raw bytes, before any JSON parsing — re-serialising changes whitespace and the signature will not match. And use the secret as the string you registered, not its decoded bytes.
Node / Express
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Raw body, not express.json() — the signature covers the exact bytes we sent.
app.post('/hooks/wpai', express.raw({ type: 'application/json' }), (req, res) => {
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', process.env.WPAI_WEBHOOK_SECRET).update(req.body).digest('hex');

  const given = req.get('X-WpAi-Signature') ?? '';
  const ok =
    given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));

  if (!ok) return res.sendStatus(401);

  const { event, data } = JSON.parse(req.body.toString('utf8'));
  // Acknowledge first, work afterwards — we time out after 10 seconds.
  res.sendStatus(200);
  void handle(event, data);
});

Retries and failures

  • Three attempts per event, with roughly 5 and 10 second gaps. We stop after the third.
  • Ten second timeout per attempt.
  • Deliveries are not ordered and not deduplicated. A retry after your handler already succeeded is possible, so make handlers idempotent on messageId.
  • GET /api/org/webhooks reports lastDeliveryStatus and failureCount.

Nothing disables a failing webhook

A dead URL is retried three times for every event, indefinitely. There is no automatic suspension — watch failureCount, or disable the hook yourself with { "enabled": false }.

Payload reference

Every field of every event's data object.

message.inbound

A customer sent you a message, on either channel.

FieldType and meaning
fromstring — the customer’s number, digits only
namestring — their WhatsApp profile name, may be empty
typestring — text, image, video, document, audio, location, …
textstring — the body, empty for a media-only message
messageIdstring — the provider’s id (a wamid on Cloud API)

message.status

A message you sent moved to sent, delivered, read or failed.

FieldType and meaning
messageIdstring — the provider id you were given when you sent it
statusstring — sent | delivered | read | failed
recipientstring — the number it was addressed to

lead.created

The assistant captured a new lead from a conversation.

FieldType and meaning
phonestring — the lead’s number
namestring — the name the assistant extracted
typestring — CALLBACK_REQUESTED, DEMO_REQUESTED, BOOKING, …
bookingobject | null — { date, time, service } when one was booked

lead.updated

Someone already recorded as a lead came back with more detail.

FieldType and meaning
phonestring — the lead’s number
namestring — the name the assistant extracted
typestring — the lead type as re-resolved on this turn
bookingobject | null — { date, time, service } when one was booked

campaign.completed

Every recipient of a broadcast has been attempted.

FieldType and meaning
campaignIdstring
namestring — the campaign name you gave it
totalRecipientsnumber
sentnumber
failednumber

template.status

Meta approved, rejected, paused or disabled one of your templates.

FieldType and meaning
namestring — the template name
languagestring — the language code, e.g. en_US
metaTemplateIdstring | null
statusstring — APPROVED | REJECTED | PAUSED | DISABLED
reasonstring — Meta’s reason, populated only on a rejection

template.quality

Meta re-scored a template on how recipients are reacting to it.

FieldType and meaning
namestring — the template name
languagestring — the language code
metaTemplateIdstring | null
qualityScorestring — GREEN | YELLOW | RED
previousQualityScorestring — what it was before, may be empty

template.category

Meta moved a template between marketing, utility and authentication.

FieldType and meaning
namestring — the template name
languagestring — the language code
metaTemplateIdstring | null
categorystring — MARKETING | UTILITY | AUTHENTICATION
previousCategorystring — what it was before, may be empty