Reliable Webhook Handling
Handle CastBrick SMS delivery status webhooks correctly — idempotency, retries, and best practices
Reliable Webhook Handling
CastBrick sends a POST request to your webhook URL each time a message status changes. This tutorial shows how to handle those events correctly across different runtimes.
Event payload
{
"event": "sms.delivered",
"messageId": "msg_01JABCDE",
"phone": "+244923000000",
"status": "delivered",
"timestamp": "2026-06-09T14:32:00Z"
}Status values
| Status | Meaning |
|---|---|
sent | Delivered to the carrier |
delivered | Confirmed delivered to the handset |
failed | Could not be delivered |
undelivered | Carrier accepted but handset unreachable |
Register your endpoint
Go to Dashboard → Webhooks → Add endpoint and set your URL (e.g. https://yourapp.com/webhooks/castbrick).
Your endpoint must respond with HTTP 200 within 10 seconds or CastBrick will retry the delivery.
Node.js (Express)
npm install castbrick-js expressimport express from 'express';
const app = express();
app.use(express.json());
// Track processed events to handle retries idempotently
const processed = new Set();
app.post('/webhooks/castbrick', (req, res) => {
// Acknowledge immediately — do heavy work after responding
res.sendStatus(200);
const { event, messageId, phone, status, timestamp } = req.body;
// Deduplicate retries
const key = `${messageId}:${status}`;
if (processed.has(key)) return;
processed.add(key);
switch (event) {
case 'sms.delivered':
console.log(`[${timestamp}] ${messageId} delivered to ${phone}`);
// Mark as delivered in your database
break;
case 'sms.failed':
case 'sms.undelivered':
console.warn(`[${timestamp}] ${messageId} failed for ${phone} — status: ${status}`);
// Trigger fallback (email, push notification, retry logic)
break;
default:
console.log(`Unhandled event: ${event}`);
}
});
app.listen(3000);Python (Flask)
from flask import Flask, request
from threading import Thread
app = Flask(__name__)
processed = set()
@app.post('/webhooks/castbrick')
def handle_webhook():
data = request.get_json(silent=True) or {}
# Respond immediately
Thread(target=process_event, args=(data,), daemon=True).start()
return '', 200
def process_event(data):
event = data.get('event')
message_id = data.get('messageId')
phone = data.get('phone')
status = data.get('status')
timestamp = data.get('timestamp')
key = f"{message_id}:{status}"
if key in processed:
return
processed.add(key)
if event == 'sms.delivered':
print(f"[{timestamp}] {message_id} delivered to {phone}")
# update_db(message_id, 'delivered')
elif event in ('sms.failed', 'sms.undelivered'):
print(f"[{timestamp}] {message_id} failed for {phone}")
# trigger_fallback(phone)C# (ASP.NET Core)
// Program.cs
app.MapPost("/webhooks/castbrick", async (HttpContext ctx) =>
{
var payload = await ctx.Request.ReadFromJsonAsync<WebhookPayload>();
if (payload is null) return Results.BadRequest();
// Acknowledge before processing
_ = Task.Run(() => ProcessWebhookAsync(payload));
return Results.Ok();
});
async Task ProcessWebhookAsync(WebhookPayload payload)
{
switch (payload.Event)
{
case "sms.delivered":
app.Logger.LogInformation("{Id} delivered to {Phone} at {Time}",
payload.MessageId, payload.Phone, payload.Timestamp);
// await db.MarkDeliveredAsync(payload.MessageId);
break;
case "sms.failed":
case "sms.undelivered":
app.Logger.LogWarning("{Id} failed for {Phone}", payload.MessageId, payload.Phone);
// await notificationService.TriggerFallbackAsync(payload.Phone);
break;
}
}
record WebhookPayload(string Event, string MessageId, string Phone, string Status, string Timestamp);Go
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
)
type WebhookPayload struct {
Event string `json:"event"`
MessageID string `json:"messageId"`
Phone string `json:"phone"`
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
var (
processed sync.Map
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Acknowledge immediately
w.WriteHeader(http.StatusOK)
go func() {
key := payload.MessageID + ":" + payload.Status
if _, loaded := processed.LoadOrStore(key, true); loaded {
return // duplicate — already processed
}
switch payload.Event {
case "sms.delivered":
log.Printf("[%s] %s delivered to %s", payload.Timestamp, payload.MessageID, payload.Phone)
case "sms.failed", "sms.undelivered":
log.Printf("[%s] %s failed for %s", payload.Timestamp, payload.MessageID, payload.Phone)
default:
log.Printf("Unhandled event: %s", payload.Event)
}
}()
}
func main() {
http.HandleFunc("/webhooks/castbrick", webhookHandler)
log.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}Best practices
Respond fast, process async. Return 200 before doing database writes, API calls, or anything slow. CastBrick retries after 10 seconds if it doesn't get a response — async processing prevents false retries.
Deduplicate. Use the messageId + status pair as an idempotency key. CastBrick may deliver the same event more than once during retries. In production, store processed keys in Redis or your database instead of in memory.
Use HTTPS. Webhook endpoints must be accessible over HTTPS in production.
Log everything. Keep a log of every received payload. This is your audit trail for delivery issues and debugging.
Test locally with a tunnel. Use a tool like ngrok or cloudflared to expose your local server during development:
ngrok http 3000
# Use the generated URL in the CastBrick dashboardNext steps
- SMS API reference — query message status directly if needed
- Node.js OTP tutorial — combine OTP sending with delivery tracking
- Best practices — sender ID, opt-outs, message length