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:

:::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

statusCodeMeaning
3Delivered
6Undeliverable
8Expired

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

webhook-handler.js
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

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 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.
  2. Dispatch: On every message status change (accepted, delivered, read, failed), the system queues an event for your webhook.
  3. 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).
  4. Circuit breaker: If your endpoint fails for over 50 consecutive calls, the webhook is temporarily deactivated and reactivated after 30 minutes.
  5. Ordering: Events are sent in chronological order for a single message, but events from different messages may arrive in any order.

Best practices

  1. Use HTTPS — The callback URL must use HTTPS for data security in transit.
  2. Respond quickly — Return 200 OK within a few seconds. Process the payload asynchronously if needed.
  3. Handle retries — The system retries on error. Make processing idempotent using the messageId as a key.
  4. Validate the source — Verify that webhook requests actually come from the Qlara Platform API.
  5. Monitor failures — If your endpoint fails constantly, the circuit breaker will temporarily deactivate it.

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 or is malformedUse a valid HTTPS URL (no HTTP, no localhost)
Webhook not receiving callbacksEndpoint unreachable or returning non-2xxVerify the URL is publicly accessible and returns 200 OK
Circuit breaker activatedOver 50 consecutive failures on your endpointFix your endpoint; the webhook reactivates after 30 minutes

Expected result

StepActionResult
1POST /webhooks/delivery-statusWebhook registered with ACTIVE status
2POST /whatsapp/send with simulation: trueTest message accepted
3Callback receivedDELIVERY, READ, or INBOUND payload
4PUT /webhooks/delivery-statusCallback URL updated
5DELETE /webhooks/delivery-statusWebhook revoked, notifications stopped

Next steps