API reference · Events

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

  1. In the dashboard open Webhooks and add an endpoint. Enter your HTTPS URL and pick the events (or all events).
  2. Copy the signing secret (whsec_…). It is shown only once; if you lose it, regenerate it.
  3. Click Test on the webhook — you will receive a webhook.test event.

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:

HeaderValue
X-QuickAuth-EventEvent name, e.g. delivery.delivered
X-QuickAuth-Delivery-IdUnique per event and webhook. Stays the same on retries — use it to ignore duplicates.
X-QuickAuth-TimestampUnix seconds when this attempt was sent
X-QuickAuth-Signaturet=<timestamp>,v1=<signature>
User-AgentQuickAuth-Webhook/1.0
Body
{
  "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.

  1. Read t and v1 from X-QuickAuth-Signature.
  2. Reject the request if t is more than 5 minutes from your clock.
  3. Compute the HMAC over the raw request bytes — parsing and re-serialising the JSON changes them and the check will fail.
  4. Compare with v1 using a constant-time comparison.
Verify
// 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, with FAILED final.
  • Your URL must be publicly reachable — private and local addresses are refused.

Events

EventSent when
delivery.deliveredA WhatsApp or SMS message reached the phone.
delivery.readA WhatsApp message was opened (or a button/link in it was tapped).
delivery.failedA 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.receivedA customer sent you a WhatsApp message — text, media, a quick-reply tap, or STOP.
button.clickedA customer tapped a quick-reply button on a template message.
link.clickedA customer opened a tracked link button. Fires on every click; isFirstClick tells you if it is the first.
otp.initiatedAn OTP was requested (also when OneTap skipped the code).
otp.verifiedA user entered the right code.
otp.failedA 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

  • messageIdstring
    WhatsApp’s wamid… or the SMS operator’s id. Not the numeric messageId from the WhatsApp send response.
  • statusenum
    DELIVEREDREADFAILED
  • channelenum
    WHATSAPPSMS
  • recipientstring
  • occurredAtstring
    UTC time we processed the status.
  • reasonstring
    FAILED only, when known: WhatsApp’s error text.
  • errorCodestring
    FAILED only, when known: WhatsApp’s error code, e.g. 131049.
Payload
{
  "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

  • fromstring
    Customer’s number, digits with country code.
  • contactNamestring
    Their WhatsApp profile name.
  • wabaIdstring
    Which of your WhatsApp Business Accounts received it.
  • messageIdstring
    WhatsApp’s id for the incoming message.
  • typestring
    text, image, video, audio, document, sticker, location, button, interactive.
  • textstring
    Message text, caption or button label.
  • buttonIdstring
    Button taps only.
  • mediaIdstring
    Media messages only.
  • mediaMimestring
    Media messages only.
  • receivedAtstring
    India time (IST), no offset.
  • leadobject
    The team-inbox lead. Omitted if unknown.
  • replyToobject
    Your last message to this customer, if known.
Payload
{
  "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"
    }
  }
}

button.clicked

A customer tapped a quick-reply button on a template you sent. (Link buttons send link.clicked instead.) You also get a message.received for the same tap.

data

  • messageIdinteger
    The numeric messageId from Send a template message.
  • tostring
    Customer’s number.
  • buttonTextstring
    The button tapped.
  • clickedAtstring
    India time (IST), no offset.
  • campaignIdinteger | null
    Internal reference — not the campaignId from the API. Null for single sends.
  • templateIdinteger
    Internal reference — not the templateId from the API.
Payload
{
  "id": "…",
  "type": "button.clicked",
  "created": 1792143305,
  "data": {
    "messageId": 482913,
    "to": "919876543210",
    "buttonText": "Talk to sales",
    "clickedAt": "2026-10-16T14:05:05",
    "campaignId": null,
    "templateId": 342
  }
}

link.clicked

A customer opened a tracked link button (tracked: true on the template). Sent on every click.

data

  • urlstring
    Where they were sent.
  • isFirstClickboolean
  • contactstring
    Customer’s number.
  • buttonTextstring
  • messageIdstring
    The wamid… of the message.
  • campaignIdstring | null
  • campaignNamestring | null
  • templateNamestring
  • clickedAtstring
    India time (IST), no offset.
Payload
{
  "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

  • requestIdstring
  • identitystring
    Phone number as submitted.
  • channelstring
    e.g. SMS, WHATSAPP; auto on otp.initiated when you did not choose one.
  • modestring
    OTP or OAUTH.
  • orderIdstring
    otp.verified and otp.failed. Empty string if you did not send one.
  • reasonstring
    otp.failed only: max_attempts_exceeded.
Payload
{
  "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

If you also have a standard webhook, messages that came in through CleverTap or WebEngage produce two deliveries per status change: the normal 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.
Chat with us