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
:::tip Test without costs
Add "simulation": true in the request body to validate the flow without actually sending messages and without consuming credit.
:::
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://lora-api.agiletelecom.com/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
{
"id": "wh-7f8a9b0c",
"callbackUrl": "https://api.logisticaexpress.it/webhooks/delivery",
"status": "ACTIVE",
"createdAt": "2026-04-09T09:00:00Z"
}
:::info Only one active webhook Only one delivery-status webhook per account is allowed. Creating a new one automatically replaces the previous configuration. :::
Verify the current configuration
curl -X GET https://lora-api.agiletelecom.com/api/partner-gateway/v1/webhooks/delivery-status \
-H "X-Api-Key: YOUR_API_KEY"
{
"id": "wh-7f8a9b0c",
"callbackUrl": "https://api.logisticaexpress.it/webhooks/delivery",
"status": "ACTIVE",
"createdAt": "2026-04-09T09:00:00Z"
}
Step 2 — Send a test message
Send a message with simulation: true to test the flow without real costs.
curl -X POST https://lora-api.agiletelecom.com/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
},
"rcs": null,
"sms": null
}
}
:::tip 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 POST requests to your callbackUrl for every status change. Here are the payloads for the three main event types.
DELIVERY payload
Delivery (or non-delivery) notification for the message:
{
"eventType": "DELIVERY",
"messageId": "test-webhook-001",
"customerMessageId": "test-webhook-001",
"destination": "+393401234567",
"statusCode": 3,
"description": "Message delivered",
"channel": "WHATSAPP",
"timestamp": "2026-04-09T10:30:00Z"
}
READ payload (WhatsApp/RCS only)
Read notification — the recipient has opened the message:
{
"eventType": "READ",
"messageId": "test-webhook-001",
"destination": "+393401234567",
"channel": "WHATSAPP",
"timestamp": "2026-04-09T10:31:15Z"
}
INBOUND payload
The recipient replied to the message:
{
"eventType": "INBOUND",
"source": "+393401234567",
"destination": "+393209998877",
"text": "Si, confermo l'appuntamento di giovedi alle 15:30",
"channel": "WHATSAPP",
"messageType": "TEXT",
"timestamp": "2026-04-09T10:32:00Z"
}
Status codes
| statusCode | Meaning |
|---|---|
3 | Delivered |
6 | Undeliverable |
8 | Expired |
Step 4 — Update the webhook
Modify the callback URL without deleting and recreating the configuration.
curl -X PUT https://lora-api.agiletelecom.com/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
{
"id": "wh-7f8a9b0c",
"callbackUrl": "https://api.logisticaexpress.it/webhooks/v2/delivery",
"status": "ACTIVE",
"updatedAt": "2026-04-09T11:00:00Z"
}
Step 5 — Revoke the webhook
When you no longer need real-time notifications, delete the configuration.
curl -X DELETE https://lora-api.agiletelecom.com/api/partner-gateway/v1/webhooks/delivery-status \
-H "X-Api-Key: YOUR_API_KEY"
Response — Webhook deleted
{
"deleted": true
}
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());
app.post('/webhooks/delivery', (req, res) => {
const { eventType, messageId, statusCode, channel, destination } = req.body;
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 === 6) {
console.log(`[FAILED] ${messageId} via ${channel} to ${destination}`);
// Handle the failure: retry or notify the operator
// alertService.notify(`Message ${messageId} undeliverable`);
} else if (statusCode === 8) {
console.log(`[EXPIRED] ${messageId} via ${channel}`);
// The message expired before delivery
}
break;
case 'READ':
console.log(`[READ] ${messageId} read by recipient`);
// db.messages.update(messageId, { readAt: req.body.timestamp });
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);
break;
default:
console.log(`[UNKNOWN] Unhandled event: ${eventType}`);
}
// Always respond quickly - 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 verifies that the URL is reachable with a test ping. If the ping fails, the registration is still accepted but you will receive a warning.
- Dispatch: On every message status change (accepted, delivered, read, failed), the system queues an event for your webhook.
- Retry: If your callback URL responds with a code other than
2xx, the system retries up to 5 times with exponential backoff (1s, 5s, 30s, 2min, 10min). - Circuit breaker: If your endpoint fails for over 50 consecutive calls, the webhook is temporarily deactivated and reactivated after 30 minutes.
- Ordering: Events are sent in chronological order for a single message, but events from different messages may arrive in any order.
Best practices
- Use HTTPS — The callback URL must use HTTPS for data security in transit.
- Respond quickly — Return
200 OKwithin a few seconds. Process the payload asynchronously if needed. - Handle retries — The system retries on error. Make processing idempotent using the
messageIdas a key. - Validate the source — Verify that webhook requests actually come from the Qlara Platform API.
- Monitor failures — If your endpoint fails constantly, the circuit breaker will temporarily deactivate it.
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 or is malformed | Use a valid HTTPS URL (no HTTP, no localhost) |
| Webhook not receiving callbacks | Endpoint unreachable or returning non-2xx | Verify the URL is publicly accessible and returns 200 OK |
| Circuit breaker activated | Over 50 consecutive failures on your endpoint | Fix your endpoint; the webhook reactivates after 30 minutes |
Expected result
| Step | Action | Result |
|---|---|---|
| 1 | POST /webhooks/delivery-status | Webhook registered with ACTIVE status |
| 2 | POST /whatsapp/send with simulation: true | Test message accepted |
| 3 | Callback received | DELIVERY, READ, or INBOUND payload |
| 4 | PUT /webhooks/delivery-status | Callback URL updated |
| 5 | DELETE /webhooks/delivery-status | Webhook revoked, 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