Webhooks
Registering an endpoint
/api/org/webhooksAPI key or session (organization admin)Up to five per organization.
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\"]
}"secretis optional — bring your own, or we mint one. It must be 32 random bytes, base64 encoded, in either the standard or URL-safe alphabet.eventsis 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
Changing a subscription
/api/org/webhooks/:idAPI key or session (organization admin)Accepts url, events, enabled and secret. Passing secret rotates the signing key.
New event types are not added automatically
Every event
Messages
| Event | Fires when | Channels |
|---|---|---|
message.inbound | A customer sent you a message, on either channel. | Both |
message.status | A message you sent moved to sent, delivered, read or failed. | Both |
Leads
| Event | Fires when | Channels |
|---|---|---|
lead.created | The assistant captured a new lead from a conversation. | Both |
lead.updated | Someone already recorded as a lead came back with more detail. | Both |
Campaigns
| Event | Fires when | Channels |
|---|---|---|
campaign.completed | Every recipient of a broadcast has been attempted. | Both |
Templates
| Event | Fires when | Channels |
|---|---|---|
template.status | Meta approved, rejected, paused or disabled one of your templates. | Cloud API |
template.quality | Meta re-scored a template on how recipients are reacting to it. | Cloud API |
template.category | Meta 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.
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
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/webhooksreportslastDeliveryStatusandfailureCount.
Nothing disables a failing webhook
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.
| Field | Type and meaning |
|---|---|
from | string — the customer’s number, digits only |
name | string — their WhatsApp profile name, may be empty |
type | string — text, image, video, document, audio, location, … |
text | string — the body, empty for a media-only message |
messageId | string — the provider’s id (a wamid on Cloud API) |
message.status
A message you sent moved to sent, delivered, read or failed.
| Field | Type and meaning |
|---|---|
messageId | string — the provider id you were given when you sent it |
status | string — sent | delivered | read | failed |
recipient | string — the number it was addressed to |
lead.created
The assistant captured a new lead from a conversation.
| Field | Type and meaning |
|---|---|
phone | string — the lead’s number |
name | string — the name the assistant extracted |
type | string — CALLBACK_REQUESTED, DEMO_REQUESTED, BOOKING, … |
booking | object | null — { date, time, service } when one was booked |
lead.updated
Someone already recorded as a lead came back with more detail.
| Field | Type and meaning |
|---|---|
phone | string — the lead’s number |
name | string — the name the assistant extracted |
type | string — the lead type as re-resolved on this turn |
booking | object | null — { date, time, service } when one was booked |
campaign.completed
Every recipient of a broadcast has been attempted.
| Field | Type and meaning |
|---|---|
campaignId | string |
name | string — the campaign name you gave it |
totalRecipients | number |
sent | number |
failed | number |
template.status
Meta approved, rejected, paused or disabled one of your templates.
| Field | Type and meaning |
|---|---|
name | string — the template name |
language | string — the language code, e.g. en_US |
metaTemplateId | string | null |
status | string — APPROVED | REJECTED | PAUSED | DISABLED |
reason | string — Meta’s reason, populated only on a rejection |
template.quality
Meta re-scored a template on how recipients are reacting to it.
| Field | Type and meaning |
|---|---|
name | string — the template name |
language | string — the language code |
metaTemplateId | string | null |
qualityScore | string — GREEN | YELLOW | RED |
previousQualityScore | string — what it was before, may be empty |
template.category
Meta moved a template between marketing, utility and authentication.
| Field | Type and meaning |
|---|---|
name | string — the template name |
language | string — the language code |
metaTemplateId | string | null |
category | string — MARKETING | UTILITY | AUTHENTICATION |
previousCategory | string — what it was before, may be empty |