Skip to main content

UC-008 — Delivery Tracking with Webhooks

FieldValue
IDUC-008
GoalRegister a webhook, receive delivery/read/inbound callbacks, and manage the lifecycle
ChannelAll (SMS, RCS, WhatsApp)
ComplexityIntermediate
Estimated time20 minutes
APIs involvedPOST /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:

Test without costs

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 active webhook

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.

Local testing with ngrok

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:

statusCodedescriptionChannelsMeaning
1acceptedSMSHanded to the carrier, no receipt yet. Never sent as an event.
2rejectedSMSThe carrier refused the message.
3deliveredSMS, RCS, WhatsAppThe message reached the handset.
4expiredSMS, RCS, WhatsAppNot delivered within its validity window.
5deletedSMSCancelled before delivery.
6undeliverableSMSThe destination cannot receive it (unreachable or invalid number).
9general errorRCS, WhatsAppDelivery failed.
10disabledRCS, WhatsAppThe recipient has the channel switched off.
11unsupportedRCS, WhatsAppThe device or number does not support the channel.
12conversation closedWhatsAppThe 24-hour customer-service window had closed.
0unknownSMS, RCS, WhatsAppNo 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​

webhook-handler.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('/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​

AspectPollingWebhook
LatencyDepends on polling frequencyNear real-time
ComplexitySimple (GET requests only)Requires a public HTTPS endpoint
ScalabilityIncreases API calls at high volumePush-based, no extra calls
API costsEach poll consumes a callNo additional consumption
ReliabilityNo risk of losing eventsRequires retry and idempotency handling
Ideal forLow volume, ad-hoc checksHigh volume, real-time dashboards
Behind the scenes — Webhook delivery mechanism

The webhook system works with an at-least-once delivery model:

  1. Registration: When you register a webhook, the system validates the URL: https on 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 with 400. No test request is sent to it.
  2. 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 (ACCEPTED on SMS, UNKNOWN on RCS and WhatsApp) produces no event.
  3. Retry: If your callback URL does not answer 2xx within 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.
  4. Ordering: Events can arrive more than once and out of order. Make processing idempotent, with eventType + messageId (+ numPart on SMS) as the key.

Best practices​

  1. Use HTTPS — The callback URL must use HTTPS, on port 443 or 8443, on a publicly reachable host.
  2. Respond quickly — Answer with any 2xx within 5 seconds. Process the payload asynchronously if needed.
  3. Handle retries — The system retries on error, and an event can arrive more than once. Make processing idempotent using eventType + messageId (+ numPart on SMS) as the key.
  4. Validate the payload — Callbacks carry no signature header. Treat the body as untrusted input and check that the messageId of a DELIVERY or READ event belongs to a message you sent.
  5. 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​

ProblemProbable causeSolution
HTTP 401Missing or invalid API KeyCheck X-Api-Key header
accepted: falseInsufficient credit or invalid numberCheck credit; verify E.164 format
HTTP 400 — Invalid callback URLURL is not HTTPS, uses a port other than 443 or 8443, or points to a non-public hostUse a valid HTTPS URL on a public host (no HTTP, no localhost)
HTTP 409 on POST /webhooks/delivery-statusA webhook is already configuredChange the URL with PUT, or DELETE the webhook first
Webhook not receiving callbacksEndpoint unreachable or returning non-2xx, or the message was sent with simulation: trueVerify the URL is publicly accessible and returns 2xx; send a real message

Expected result​

StepActionResult
1POST /webhooks/delivery-status201 Created with companyId and callbackUrl
2POST /whatsapp/send with simulation: true, then withoutRequest validated, then message sent
3Callback receivedDELIVERY, READ, or INBOUND payload
4PUT /webhooks/delivery-statusCallback URL updated
5DELETE /webhooks/delivery-status204 No Content, notifications stopped

Next steps​