UC-008 — Delivery Tracking with Webhooks
| Field | Value |
|---|---|
| ID | UC-008 |
| Goal | Register a webhook, receive delivery/read/inbound callbacks, and manage the lifecycle |
| Channel | All (SMS, RCS, WhatsApp) |
| Complexity | Intermediate |
| Estimated time | 20 minutes |
| APIs involved | POST /api/partner-gateway/v1/webhooks/delivery-status, GET /webhooks/delivery-status, PUT /webhooks/delivery-status, DELETE /webhooks/delivery-status |
Real-world scenarios
- LogisticaExpress — Real-time dashboard: The operations panel shows in real time the status of thousands of shipping notifications. Each webhook updates the delivered/failed counter.
- AssicuraPlus — SLA monitoring: The system measures the time between sending and delivery for each channel, generating alerts if the time exceeds the contractual threshold of 60 seconds.
- FarmaOnline — Audit log: Every delivery, read, and reply event is recorded in the database for regulatory compliance and post-campaign analysis.
Prerequisites
Before you begin, make sure you have:
- Active API Key → How to get one
- Sufficient credit → Check in the Qlara Dashboard
- Publicly reachable HTTPS webhook endpoint
Add "simulation": true in the request body to validate a send request without actually sending the message and without consuming credit. A simulated message is never sent, so it produces no webhook callback: to receive the callbacks of Step 3, send a real message to a number you own.
Webhook flow
The diagram illustrates the complete cycle: you register the webhook, send a message, the carrier delivers, and your app receives the notification in real time.
Step 1 — Register the webhook
Register your HTTPS endpoint to receive delivery notifications.
curl -X POST https://api.qlara.ai/api/partner-gateway/v1/webhooks/delivery-status \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{
"callbackUrl": "https://api.logisticaexpress.it/webhooks/delivery"
}'
Response — Webhook registered
The API answers 201 Created:
{
"companyId": 123,
"callbackUrl": "https://api.logisticaexpress.it/webhooks/delivery"
}
Only one delivery-status webhook per account is allowed, and it receives the events of SMS, RCS, and WhatsApp alike. If one is already configured, POST answers 409 Conflict and leaves it unchanged: change the URL with PUT (Step 4), or remove it with DELETE (Step 5) first.
Verify the current configuration
curl -X GET https://api.qlara.ai/api/partner-gateway/v1/webhooks/delivery-status \
-H "X-Api-Key: YOUR_API_KEY"
{
"companyId": 123,
"callbackUrl": "https://api.logisticaexpress.it/webhooks/delivery"
}
If no webhook is configured, the API answers 404.
Step 2 — Send a test message
Send a message with simulation: true to validate the request without real costs.
curl -X POST https://api.qlara.ai/api/message-server/whatsapp/send \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{
"destination": "+393401234567",
"phoneNumberId": 5,
"template": {
"id": 42
},
"placeholders": {
"nome": "Giulia"
},
"enableNotification": true,
"simulation": true,
"messageId": "test-webhook-001"
}'
Response — Test message accepted
{
"messageId": "test-webhook-001",
"simulation": true,
"results": {
"whatsapp": {
"accepted": true
}
}
}
A simulated message is validated but never sent, so it produces no callback. Once the request is accepted, send it again without simulation to a number you own to receive the callbacks of Step 3.
To test webhooks locally, use ngrok to expose your server: ngrok http 3000. Register the generated HTTPS URL as the callback.
Step 3 — Receive the callbacks
The system sends a POST request to your callbackUrl when the delivery receipt arrives, when the recipient reads the message, and when they write back. Here are the payloads for the three main event types. Fields without a value are left out.
DELIVERY payload
Delivery (or non-delivery) notification, sent once the carrier or platform receipt arrives. This one is for an SMS:
{
"eventType": "DELIVERY",
"channel": "SMS",
"messageId": "shipment-48213",
"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 payload (WhatsApp/RCS only)
Read notification — the recipient has opened the message:
{
"eventType": "READ",
"channel": "WHATSAPP",
"messageId": "test-webhook-001",
"destination": "+393401234567",
"eventDate": "2026-04-09T11:31:15+02:00"
}
A READ event has no statusCode: a read message stays DELIVERED.
INBOUND payload (WhatsApp/RCS only)
The recipient replied to the message:
{
"eventType": "INBOUND",
"channel": "WHATSAPP",
"messageId": "b7e2c9a4-3f1d-4c6e-8a5b-0d9f2e7c4a18",
"source": "+393401234567",
"destination": "+393209998877",
"receivedDate": "2026-04-09T11:35:00+02:00",
"messageType": "TEXT",
"text": "Si, confermo l'appuntamento di giovedi alle 15:30"
}
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.
Status codes
The statusCode of a DELIVERY event, with its description:
| statusCode | 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 same numbers map to the deliveryStatus values of the polling API: see Delivery Statuses.
Step 4 — Update the webhook
Modify the callback URL without deleting and recreating the configuration.
curl -X PUT https://api.qlara.ai/api/partner-gateway/v1/webhooks/delivery-status \
-H "Content-Type: application/json" \
-H "X-Api-Key: YOUR_API_KEY" \
-d '{
"callbackUrl": "https://api.logisticaexpress.it/webhooks/v2/delivery"
}'
Response — Webhook updated
The API answers 200 OK (404 if no webhook is configured):
{
"companyId": 123,
"callbackUrl": "https://api.logisticaexpress.it/webhooks/v2/delivery"
}
Step 5 — Revoke the webhook
When you no longer need real-time notifications, delete the configuration.
curl -X DELETE https://api.qlara.ai/api/partner-gateway/v1/webhooks/delivery-status \
-H "X-Api-Key: YOUR_API_KEY"
Response — Webhook deleted
The API answers 204 No Content with an empty body (404 if no webhook is configured).
After revocation, delivery notifications will no longer be sent. You can still check message status via polling.
Example — Node.js webhook handler
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('/webhooks/delivery', (req, res) => {
const { eventType, messageId, statusCode, channel, destination } = req.body;
// Events can arrive more than once: deduplicate on eventType + messageId (+ numPart on SMS)
// const eventKey = `${eventType}:${messageId}:${req.body.numPart ?? ''}`;
// if (db.events.exists(eventKey)) return res.sendStatus(200);
switch (eventType) {
case 'DELIVERY':
if (statusCode === 3) {
console.log(`[DELIVERED] ${messageId} via ${channel} to ${destination}`);
// Update the database: mark the message as delivered
// db.messages.update(messageId, { status: 'delivered', channel });
} else if (statusCode === 4) {
console.log(`[EXPIRED] ${messageId} via ${channel}`);
// The message expired before delivery
} else if (SMS_FAILURES.includes(statusCode) || RCS_WA_FAILURES.includes(statusCode)) {
console.log(`[FAILED] ${messageId} via ${channel}: ${req.body.description}`);
// Handle the failure: retry or notify the operator
// alertService.notify(`Message ${messageId} not delivered: ${req.body.description}`);
}
break;
case 'READ':
console.log(`[READ] ${messageId} read by recipient`);
// db.messages.update(messageId, { readAt: req.body.eventDate });
break;
case 'INBOUND':
console.log(`[INBOUND] From ${req.body.source}: ${req.body.text}`);
// Process the reply: auto-reply, forward to CRM, etc.
// crmService.createTicket(req.body.source, req.body.text, req.body.receivedDate);
break;
default:
console.log(`[UNKNOWN] Unhandled event: ${eventType}`);
}
// Always respond quickly (within 5 seconds) - process asynchronously if needed
res.sendStatus(200);
});
app.listen(3000, () => {
console.log('Webhook handler listening on port 3000');
});
Polling vs Webhook
| Aspect | Polling | Webhook |
|---|---|---|
| Latency | Depends on polling frequency | Near real-time |
| Complexity | Simple (GET requests only) | Requires a public HTTPS endpoint |
| Scalability | Increases API calls at high volume | Push-based, no extra calls |
| API costs | Each poll consumes a call | No additional consumption |
| Reliability | No risk of losing events | Requires retry and idempotency handling |
| Ideal for | Low volume, ad-hoc checks | High volume, real-time dashboards |
Behind the scenes — Webhook delivery mechanism
The webhook system works with an at-least-once delivery model:
- Registration: When you register a webhook, the system validates the URL:
httpson port 443 or 8443, no credentials, no fragment, and a host that resolves to publicly reachable addresses. A URL that fails these checks is refused with400. No test request is sent to it. - Dispatch: The system queues an event when the carrier or platform receipt arrives (
DELIVERY, with the final status code), when the recipient reads an RCS or WhatsApp message (READ), and when an end user writes to you on RCS or WhatsApp (INBOUND). A message still in flight (ACCEPTEDon SMS,UNKNOWNon RCS and WhatsApp) produces no event. - Retry: If your callback URL does not answer
2xxwithin 5 seconds (3 seconds to connect), 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. - Ordering: Events can arrive more than once and out of order. Make processing idempotent, with
eventType+messageId(+numParton SMS) as the key.
Best practices
- Use HTTPS — The callback URL must use HTTPS, on port 443 or 8443, on a publicly reachable host.
- Respond quickly — Answer with any
2xxwithin 5 seconds. Process the payload asynchronously if needed. - Handle retries — The system retries on error, and an event can arrive more than once. Make processing idempotent using
eventType+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 — Events that exhaust their retries are not sent again. If your endpoint was down, recover the missing statuses by polling
GET /messages/status?channel=...&ids=....
Common errors
| Problem | Probable cause | Solution |
|---|---|---|
HTTP 401 | Missing or invalid API Key | Check X-Api-Key header |
accepted: false | Insufficient credit or invalid number | Check credit; verify E.164 format |
HTTP 400 — Invalid callback URL | URL is not HTTPS, uses a port other than 443 or 8443, or points to a non-public host | Use a valid HTTPS URL on a public host (no HTTP, no localhost) |
HTTP 409 on POST /webhooks/delivery-status | A webhook is already configured | Change the URL with PUT, or DELETE the webhook first |
| Webhook not receiving callbacks | Endpoint unreachable or returning non-2xx, or the message was sent with simulation: true | Verify the URL is publicly accessible and returns 2xx; send a real message |
Expected result
| Step | Action | Result |
|---|---|---|
| 1 | POST /webhooks/delivery-status | 201 Created with companyId and callbackUrl |
| 2 | POST /whatsapp/send with simulation: true, then without | Request validated, then message sent |
| 3 | Callback received | DELIVERY, READ, or INBOUND payload |
| 4 | PUT /webhooks/delivery-status | Callback URL updated |
| 5 | DELETE /webhooks/delivery-status | 204 No Content, notifications stopped |
Next steps
- Webhooks Guide: Deep dive into configuration, local testing, and best practices
- UC-005 — Multi-Channel Fallback: Combine multi-channel fallback with webhook tracking
- Channel Overview: Discover which events are available for each channel