Webhooks and Delivery Notifications
There are two ways to track message delivery: polling and webhooks. Choose the approach that best fits your use case.
Polling
Query the delivery status on demand:
GET /messages/status/{customerMessageId}?channel=SMS
channel is required: SMS, RCS or WHATSAPP. Or check multiple messages of the same channel at once:
GET /messages/status?channel=SMS&ids=id1,id2,id3
Both return the message's deliveryStatus (DELIVERED, UNDELIVERABLE, EXPIRED, ...): the Delivery Statuses table lists every value.
When to use polling:
- Low message volume
- Ad-hoc status checks
- Simple integrations that don't need a public endpoint
Webhooks
Register an HTTPS callback URL to receive delivery status updates in real time, as soon as the carrier reports them.
Configure a webhook
POST /webhooks/delivery-status
Provide your callback URL as {"callbackUrl": "https://..."}. The same URL receives the events of SMS, RCS, and WhatsApp. The API will send HTTP POST requests to this URL when a delivery receipt arrives, when a recipient reads an RCS or WhatsApp message, and when an end user writes to you.
Manage your webhook
| Action | Endpoint | Response |
|---|---|---|
| Get current webhook | GET /webhooks/delivery-status | 200 with companyId and callbackUrl, 404 if none |
| Create webhook | POST /webhooks/delivery-status | 201 with companyId and callbackUrl, 409 if one exists |
| Update webhook URL | PUT /webhooks/delivery-status | 200 with companyId and callbackUrl, 404 if none |
| Revoke webhook | DELETE /webhooks/delivery-status | 204 with no body, 404 if none |
Only one active delivery-status webhook is allowed per account. If one is already configured, POST answers 409 Conflict and leaves it unchanged: change the URL with PUT, or DELETE the webhook first.
Webhook flow
Webhook payload
When a delivery receipt arrives, the API sends a POST request to your callback URL. The body is JSON (Content-Type: application/json), and fields without a value are left out.
Delivery notification example (SMS):
{
"eventType": "DELIVERY",
"channel": "SMS",
"messageId": "order-12345",
"destination": "+393401234567",
"statusCode": 3,
"description": "delivered",
"eventDate": "2026-04-09T11:30:05+02:00",
"price": 0.035,
"totalParts": 1,
"numPart": 1
}
messageId is the id of the message you sent, the same value the polling API calls customerMessageId. An SMS produces one event per part (numPart of totalParts). RCS and WhatsApp DELIVERY events carry the same fields without totalParts and numPart.
Read notification example (WhatsApp/RCS only):
{
"eventType": "READ",
"channel": "WHATSAPP",
"messageId": "e76614d1-4ac1-4d94-89f0-d07f1b5a190c",
"destination": "+393401234567",
"eventDate": "2026-04-09T11:31:15+02:00"
}
A READ event has no statusCode: a read message stays DELIVERED.
Inbound message example (WhatsApp/RCS only):
{
"eventType": "INBOUND",
"channel": "WHATSAPP",
"messageId": "b7e2c9a4-3f1d-4c6e-8a5b-0d9f2e7c4a18",
"source": "+393401234567",
"destination": "+393409876543",
"receivedDate": "2026-04-09T11:35:00+02:00",
"messageType": "TEXT",
"text": "Yes, I confirm my appointment"
}
source is the end user's number and destination is your WhatsApp number (your agent id on RCS). messageId identifies the incoming message in the conversation. Media messages also carry a mediaKey.
The statusCode of a DELIVERY event, with its description:
| Status Code | Description | Channels | Meaning |
|---|---|---|---|
| 1 | accepted | SMS | Handed to the carrier, no receipt yet. Never sent as an event. |
| 2 | rejected | SMS | The carrier refused the message. |
| 3 | delivered | SMS, RCS, WhatsApp | The message reached the handset. |
| 4 | expired | SMS, RCS, WhatsApp | Not delivered within its validity window. |
| 5 | deleted | SMS | Cancelled before delivery. |
| 6 | undeliverable | SMS | The destination cannot receive it (unreachable or invalid number). |
| 9 | general error | RCS, WhatsApp | Delivery failed. |
| 10 | disabled | RCS, WhatsApp | The recipient has the channel switched off. |
| 11 | unsupported | RCS, WhatsApp | The device or number does not support the channel. |
| 12 | conversation closed | The 24-hour customer-service window had closed. | |
| 0 | unknown | SMS, RCS, WhatsApp | No status held for the message. Never sent as an event. |
An event is sent only once the receipt arrives, so every code you receive is final. A message still in flight reads ACCEPTED on SMS and UNKNOWN on RCS and WhatsApp when you poll it, and produces no event. The codes match the deliveryStatus values of the polling API: see Delivery Statuses.
Testing webhooks locally
To test webhooks during development, use ngrok to expose your local server:
# Start your local webhook handler on port 3000
node webhook-handler.js
# In another terminal, expose it via ngrok
ngrok http 3000
# Copy the HTTPS URL (e.g. https://abc123.ngrok.io)
# Register it as your webhook callback:
curl -X POST "https://api.qlara.ai/api/partner-gateway/v1/webhooks/delivery-status" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"callbackUrl": "https://abc123.ngrok.io/webhook"}'
simulation: true validates a send request without sending the message, so it produces no webhook callback. To test your handler end to end, send a real message to a number you own.
Example webhook handler (Node.js)
const express = require('express');
const app = express();
app.use(express.json());
// Final failures: SMS = rejected, deleted, undeliverable
// RCS/WhatsApp = general error, disabled, unsupported, conversation closed
const SMS_FAILURES = [2, 5, 6];
const RCS_WA_FAILURES = [9, 10, 11, 12];
app.post('/webhook', (req, res) => {
const { eventType, messageId, statusCode, channel } = req.body;
// Events can arrive more than once: deduplicate on eventType + messageId (+ numPart on SMS)
switch (eventType) {
case 'DELIVERY':
if (statusCode === 3) {
console.log(`Message ${messageId} delivered via ${channel}`);
// Update your database: mark message as delivered
} else if (statusCode === 4) {
console.log(`Message ${messageId} expired via ${channel}`);
} else if (SMS_FAILURES.includes(statusCode) || RCS_WA_FAILURES.includes(statusCode)) {
console.log(`Message ${messageId} not delivered via ${channel}: ${req.body.description}`);
// Handle failure: retry or notify user
}
break;
case 'READ':
console.log(`Message ${messageId} read by recipient at ${req.body.eventDate}`);
break;
case 'INBOUND':
console.log(`Inbound from ${req.body.source} at ${req.body.receivedDate}: ${req.body.text}`);
// Process reply: auto-respond, forward to CRM, etc.
break;
}
// Always respond quickly (within 5 seconds) — process asynchronously if needed
res.sendStatus(200);
});
app.listen(3000, () => console.log('Webhook handler listening on port 3000'));
Best practices
- Use HTTPS on a public host -- The callback URL is validated when you register it. It must use
httpson port 443 (the default) or 8443, carry no credentials and no#fragment, and its host must resolve to publicly reachable addresses. Loopback, private, link-local, carrier-grade-NAT and unique-local addresses are refused, as is a host that does not resolve. A URL that fails any of these checks is rejected with400. - Respond quickly -- Answer with any
2xxwithin 5 seconds (3 seconds to connect). Process the payload asynchronously if needed. - Handle retries -- If your endpoint is unreachable or does not answer
2xx, the system retries: up to 5 attempts in total, at least 5 minutes apart for SMS and at least 10 minutes apart for RCS and WhatsApp, and only for messages sent in the last 10 days. Events can arrive more than once and out of order, so make your processing idempotent, witheventType+messageId(+numParton SMS) as the key. - Validate the payload -- Callbacks carry no signature header. Treat the body as untrusted input and check that the
messageIdof a DELIVERY or READ event belongs to a message you sent. - Monitor failures -- If your webhook endpoint is consistently failing, check your server logs and ensure the URL is accessible. Events that exhaust their retries are not sent again: recover those statuses by polling.
Choosing between polling and webhooks
| Aspect | Polling | Webhooks |
|---|---|---|
| Latency | Depends on poll frequency | Near real-time |
| Complexity | Simple GET requests | Requires a public HTTPS endpoint |
| Scalability | Increases API calls at scale | Push-based, no extra API calls |
| Best for | Low volume, ad-hoc checks | High volume, real-time dashboards |
See the Webhooks API Reference and Message Delivery Status API Reference for full endpoint documentation.