Webhooks
Receive real-time notifications when message status changes. Webhooks are faster and more reliable than polling.
Webhooks vs Polling
| Method | Speed | Reliability | Use Case |
|---|---|---|---|
| Webhooks | Instant | High (your endpoint receives events) | Production systems, real-time processing |
| Polling | Delayed (you check every N seconds) | Depends on frequency | Low-volume, simple integrations |
Recommendation: Use webhooks for production.
Webhook Setup
Register a Webhook Endpoint
You can manage webhooks via the API or dashboard.
API Endpoint: POST /webhooks/delivery-status
Register your webhook:
- cURL
- Python
- Node.js
curl -X POST https://lora-api.agiletelecom.com/api/webhooks/delivery-status \
-H "X-Api-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-domain.com/webhook",
"method": "POST"
}'
import requests
headers = {
"X-Api-Key": "your_api_key_here",
"Content-Type": "application/json"
}
payload = {
"url": "https://your-domain.com/webhook",
"method": "POST"
}
response = requests.post(
"https://lora-api.agiletelecom.com/api/webhooks/delivery-status",
json=payload,
headers=headers
)
print(response.json())
const axios = require('axios');
const headers = {
'X-Api-Key': 'your_api_key_here',
'Content-Type': 'application/json'
};
const payload = {
url: 'https://your-domain.com/webhook',
method: 'POST'
};
axios.post(
'https://lora-api.agiletelecom.com/api/webhooks/delivery-status',
payload,
{ headers }
).then(response => console.log(response.data))
.catch(error => console.error(error));
Important Limitation
One active webhook per account. If you register a new webhook, the previous one is replaced.
Webhook Payload
When a message status changes, Qlara sends a POST request to your endpoint:
{
"messageId": "msg_1234567890",
"customerMessageId": "your_reference_id",
"phoneNumber": "+393901234567",
"status": "DELIVERED",
"timestamp": "2025-04-08T14:30:00Z",
"channel": "sms",
"error": null
}
Payload Fields
| Field | Type | Description |
|---|---|---|
messageId | string | Unique Qlara message ID |
customerMessageId | string | Your reference ID (if provided) |
phoneNumber | string | Recipient phone number |
status | string | Message status (see below) |
timestamp | ISO 8601 | When the status changed |
channel | string | Channel used (sms, rcs, whatsapp) |
error | string or null | Error message if status is ERROR |
Status Values
| Status | Meaning |
|---|---|
SENT | Message left our servers and is being processed |
DELIVERED | Successfully delivered to recipient |
ERROR | Delivery failed (see error field) |
EXPIRED | Message was not delivered within retention period |
Implementing a Webhook Receiver
Your endpoint must:
- Listen for POST requests
- Return HTTP 200 within 5 seconds
- Process the payload asynchronously (don't block the response)
- Be idempotent (handle duplicate events gracefully)
- Validate the request came from Qlara
Example Webhook Receiver
- Python (Flask)
- Node.js (Express)
- PHP (Laravel)
from flask import Flask, request, jsonify
import json
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
# Return 200 immediately
payload = request.get_json()
# Process asynchronously (queue to background worker)
process_delivery_event.delay(payload)
return jsonify({"status": "received"}), 200
def process_delivery_event(payload):
"""Process webhook payload asynchronously"""
message_id = payload.get('messageId')
status = payload.get('status')
phone = payload.get('phoneNumber')
# Update your database
Message.objects(messageId=message_id).update(
status=status,
delivered_at=payload.get('timestamp')
)
# Trigger downstream actions
if status == 'DELIVERED':
send_confirmation_email(phone)
elif status == 'ERROR':
log_delivery_error(message_id, payload.get('error'))
if __name__ == '__main__':
app.run(port=5000)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook', async (req, res) => {
// Return 200 immediately
res.status(200).json({ status: 'received' });
// Process asynchronously
const payload = req.body;
processDeliveryEvent(payload).catch(err => console.error(err));
});
async function processDeliveryEvent(payload) {
const { messageId, status, phoneNumber, timestamp, error } = payload;
// Update your database
await Message.updateOne(
{ messageId },
{
status,
deliveredAt: timestamp
}
);
// Trigger downstream actions
if (status === 'DELIVERED') {
await sendConfirmationEmail(phoneNumber);
} else if (status === 'ERROR') {
await logDeliveryError(messageId, error);
}
}
app.listen(5000, () => console.log('Webhook receiver running on port 5000'));
Route::post('/webhook', function (Request $request) {
// Return 200 immediately
$payload = $request->json()->all();
// Queue for async processing
dispatch(new ProcessDeliveryEvent($payload))->onQueue('webhooks');
return response()->json(['status' => 'received'], 200);
});
class ProcessDeliveryEvent implements ShouldQueue {
public function handle() {
$payload = $this->payload;
$messageId = $payload['messageId'];
$status = $payload['status'];
// Update database
Message::where('message_id', $messageId)
->update(['status' => $status]);
// Trigger actions
if ($status === 'DELIVERED') {
Mail::send('confirmation', [], function ($msg) {
// send confirmation email
});
}
}
}
Webhook Receiver Requirements
HTTPS Required
Your webhook URL must use HTTPS. Unencrypted HTTP is not supported.
Fast Response
Return HTTP 200 within 5 seconds. Don't process the webhook payload before responding.
# GOOD: Return immediately, process later
@app.route('/webhook', methods=['POST'])
def webhook():
queue.enqueue(process_event, request.get_json())
return jsonify({"status": "received"}), 200
# BAD: Blocks before returning
@app.route('/webhook', methods=['POST'])
def webhook():
process_event(request.get_json()) # Takes 10 seconds
return jsonify({"status": "processed"}), 200
Idempotency
Process events idempotently. You may receive the same event twice. Use the messageId to deduplicate:
def process_delivery_event(payload):
message_id = payload['messageId']
# Check if already processed
if Message.objects(messageId=message_id).first():
return # Skip duplicate
# Process event
Message.create(messageId=message_id, status=payload['status'])
Polling Alternative
If you can't use webhooks, poll the status endpoint:
Endpoint: GET /messages/status/{customerMessageId}
- cURL
- Python
- Node.js
curl -X GET https://lora-api.agiletelecom.com/api/messages/status/your_reference_id \
-H "X-Api-Key: your_api_key_here"
import requests
import time
api_key = "your_api_key_here"
customer_message_id = "your_reference_id"
url = f"https://lora-api.agiletelecom.com/api/messages/status/{customer_message_id}"
headers = {"X-Api-Key": api_key}
# Poll every 5 seconds until delivered
max_attempts = 60
attempts = 0
while attempts < max_attempts:
response = requests.get(url, headers=headers)
data = response.json()
status = data.get('status')
if status in ['DELIVERED', 'ERROR', 'EXPIRED']:
print(f"Final status: {status}")
break
attempts += 1
time.sleep(5)
const axios = require('axios');
const apiKey = 'your_api_key_here';
const customerMessageId = 'your_reference_id';
const url = `https://lora-api.agiletelecom.com/api/messages/status/${customerMessageId}`;
async function pollStatus() {
const headers = { 'X-Api-Key': apiKey };
let attempts = 0;
while (attempts < 60) {
const response = await axios.get(url, { headers });
const status = response.data.status;
if (['DELIVERED', 'ERROR', 'EXPIRED'].includes(status)) {
console.log(`Final status: ${status}`);
break;
}
attempts++;
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
pollStatus();
Best Practices
- Use webhooks in production — Faster and more reliable than polling
- Return 200 immediately — Don't block the webhook response
- Process asynchronously — Use a job queue (Celery, Bull, RQ, etc.)
- Handle duplicates — Implement idempotent processing
- Use HTTPS — Webhooks must be encrypted
- Log all events — Keep audit trail of delivery status
- Monitor webhook health — Alert if webhook URL is unreachable
- Validate requests — (Future: webhook signatures coming soon)
Troubleshooting
Webhook not being called?
- Verify endpoint is accessible from the internet
- Check HTTPS certificate validity
- Ensure firewall allows requests from Qlara IPs
- Check application logs for errors
Getting duplicate events?
- Implement idempotent processing with messageId deduplication
- Use database unique constraints as a safety net
Webhook returning errors?
- Return HTTP 200 for all valid payloads
- Log errors and re-throw within async job, not in webhook handler
- Check that endpoint returns within 5 seconds
Need help? Contact support@agiletelecom.com.