Webhooks
QuickAuth calls your server when something happens — a message is delivered or fails, a customer replies or taps a button, a user verifies a code — so you never have to poll.
Set up
- In the dashboard open Webhooks and add an endpoint. Enter your HTTPS URL and pick the events (or all events).
- Copy the signing secret (
whsec_…). It is shown only once; if you lose it, regenerate it. - Click Test on the webhook — you will receive a
webhook.testevent.
You can have up to 10 active webhooks, each with a different URL. You may add one extra static header (for example an API key your gateway expects).
What we send
An HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-QuickAuth-Event | Event name, e.g. delivery.delivered |
X-QuickAuth-Delivery-Id | Unique per event and webhook. Stays the same on retries — use it to ignore duplicates. |
X-QuickAuth-Timestamp | Unix seconds when this attempt was sent |
X-QuickAuth-Signature | t=<timestamp>,v1=<signature> |
User-Agent | QuickAuth-Webhook/1.0 |
{
"id": "6c0b8e4e-1f7a-4a0e-9d55-2f1b7c3e9a10",
"type": "delivery.delivered",
"created": 1792143191,
"data": { ... }
}Verify the signature
Always check the signature before trusting a webhook — anyone can POST to your URL. We compute HMAC-SHA256 of <t>.<raw body> using your whole secret (including the whsec_ prefix) as the key, and send it as hex in v1.
- Read
tandv1fromX-QuickAuth-Signature. - Reject the request if
tis more than 5 minutes from your clock. - Compute the HMAC over the raw request bytes — parsing and re-serialising the JSON changes them and the check will fail.
- Compare with
v1using a constant-time comparison.
// npm i express — Node 18+
const crypto = require('crypto')
const express = require('express')
const app = express()
app.post('/quickauth/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.QUICKAUTH_WEBHOOK_SECRET
const header = req.get('X-QuickAuth-Signature') || ''
const parts = Object.fromEntries(header.split(',').map((p) => {
const i = p.indexOf('=')
return [p.slice(0, i).trim(), p.slice(i + 1).trim()]
}))
const { t, v1 } = parts
if (!t || !v1) return res.status(400).send('missing signature')
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).send('stale')
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`)
.update(req.body)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(v1, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('bad signature')
}
const event = JSON.parse(req.body.toString('utf8'))
const deliveryId = req.get('X-QuickAuth-Delivery-Id')
queue.add({ deliveryId, event })
res.sendStatus(200)
})Responding and retries
- Reply with any 2xx within 30 seconds. Save the event and process it afterwards.
- If you return 5xx, time out or can’t be reached, we retry twice: after about 30 seconds and about 5 minutes (3 attempts in all).
- A 4xx is treated as “don’t send this again” and is not retried — return 5xx for temporary problems. Redirects are not followed.
- After 20 failed attempts in a row the webhook is switched off. Fix your endpoint and switch it back on in the dashboard; recent attempts and responses are listed there.
- Events can arrive out of order and, rarely, more than once. Dedupe on
X-QuickAuth-Delivery-Id, and never move a message backwards:SENT→DELIVERED→READ, withFAILEDfinal. - Your URL must be publicly reachable — private and local addresses are refused.
Events
| Event | Sent when |
|---|---|
delivery.delivered | A WhatsApp or SMS message reached the phone. |
delivery.read | A WhatsApp message was opened (or a button/link in it was tapped). |
delivery.failed | A message that WhatsApp or the SMS operator had accepted could not be delivered. (A message refused at send time is reported in the send response, not here.) |
message.received | A customer sent you a WhatsApp message — text, media, a quick-reply tap, or STOP. |
button.clicked | A customer tapped a quick-reply button on a template message. |
link.clicked | A customer opened a tracked link button. Fires on every click; isFirstClick tells you if it is the first. |
otp.initiated | An OTP was requested (also when OneTap skipped the code). |
otp.verified | A user entered the right code. |
otp.failed | A user ran out of attempts. |
delivery.sent is listed in the dashboard but is not sent today: the send response already tells you a message was accepted.
delivery.delivered · delivery.read · delivery.failed
A WhatsApp template message or SMS changed status. Match it to your send with data.messageId, which is the providerMessageId from the send response (the wamid… for WhatsApp). Replies in a conversation do not produce delivery events.
data
messageIdstringWhatsApp’swamid…or the SMS operator’s id. Not the numeric messageId from the WhatsApp send response.statusenumDELIVEREDREADFAILEDchannelenumWHATSAPPSMSrecipientstringoccurredAtstringUTC time we processed the status.reasonstringFAILED only, when known: WhatsApp’s error text.errorCodestringFAILED only, when known: WhatsApp’s error code, e.g.131049.
{
"id": "6c0b8e4e-1f7a-4a0e-9d55-2f1b7c3e9a10",
"type": "delivery.failed",
"created": 1792143191,
"data": {
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSQzA2RjQ5QTJBRDE0RkE4NjBBAA==",
"status": "FAILED",
"channel": "WHATSAPP",
"recipient": "919876543210",
"occurredAt": "2026-10-16T08:33:11.204Z",
"reason": "131049: This message was not delivered to maintain healthy ecosystem engagement.",
"errorCode": "131049"
}
}message.received
A customer messaged one of your WhatsApp numbers. Includes the lead it belongs to and, when we can tell, which of your messages they are replying to — so you can route the reply to the right person.
data
fromstringCustomer’s number, digits with country code.contactNamestringTheir WhatsApp profile name.wabaIdstringWhich of your WhatsApp Business Accounts received it.messageIdstringWhatsApp’s id for the incoming message.typestringtext,image,video,audio,document,sticker,location,button,interactive.textstringMessage text, caption or button label.buttonIdstringButton taps only.mediaIdstringMedia messages only.mediaMimestringMedia messages only.receivedAtstringIndia time (IST), no offset.leadobjectThe team-inbox lead. Omitted if unknown.replyToobjectYour last message to this customer, if known.
{
"id": "1d7f3b0a-9c2e-4f6a-8b1d-3e5c7a9f0b2d",
"type": "message.received",
"created": 1792143300,
"data": {
"wabaId": "104857600123456",
"from": "919876543210",
"contactName": "Asha",
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhggQjI…",
"type": "button",
"text": "Track order",
"buttonId": "track_order",
"receivedAt": "2026-10-16T14:05:00",
"lead": {
"id": 90211,
"source": "CAMPAIGN",
"isNew": false,
"campaignId": "5be0a3c1d2e94f7a8b6c1d2e3f4a5b6c",
"campaignName": "Diwali sale",
"assignedAgentEmail": "ravi@acme.in"
},
"replyTo": {
"sentVia": "CAMPAIGN",
"messageId": "wamid.HBgM…",
"sentAt": "2026-10-16T13:01:02.5",
"templateName": "Diwali Sale",
"campaignId": "5be0a3c1d2e94f7a8b6c1d2e3f4a5b6c",
"campaignName": "Diwali sale"
}
}
}link.clicked
A customer opened a tracked link button (tracked: true on the template). Sent on every click.
data
urlstringWhere they were sent.isFirstClickbooleancontactstringCustomer’s number.buttonTextstringmessageIdstringThewamid…of the message.campaignIdstring | nullcampaignNamestring | nulltemplateNamestringclickedAtstringIndia time (IST), no offset.
{
"id": "…",
"type": "link.clicked",
"created": 1792143400,
"data": {
"url": "https://shop.example.com/orders/A-1029",
"clickedAt": "2026-10-16T14:06:40.12",
"isFirstClick": true,
"buttonId": 512,
"buttonText": "Track order",
"contact": "919876543210",
"messageId": "wamid.HBgM…",
"campaignId": "5be0a3c1d2e94f7a8b6c1d2e3f4a5b6c",
"campaignName": "Diwali sale",
"templateName": "Order Shipped"
}
}otp.initiated · otp.verified · otp.failed
Lifecycle of an OTP verification. Useful for audit logs and fraud monitoring; your login flow should still use the verify response or status endpoint.
data
requestIdstringidentitystringPhone number as submitted.channelstringe.g.SMS,WHATSAPP;autoon otp.initiated when you did not choose one.modestringOTPorOAUTH.orderIdstringotp.verified and otp.failed. Empty string if you did not send one.reasonstringotp.failed only:max_attempts_exceeded.
{
"id": "…",
"type": "otp.verified",
"created": 1792143072,
"data": {
"requestId": "3f9c1a2b7d4e4f0a9b8c6d5e4f3a2b1c",
"orderId": "signup-1029",
"identity": "919876543210",
"channel": "SMS",
"mode": "OTP"
}
}CleverTap and WebEngage formats
A webhook can also be created in CleverTap or WebEngage format, so delivery reports flow straight back into those tools. Those use the partner’s own payload shape and authentication instead of the format above — see CleverTap & WebEngage.
Messages sent through CleverTap or WebEngage
delivery.* event, and a second one whose data is in the partner’s format. Standard events always have data.channel; skip events without it if you only want one copy.