CastBricks Docs

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

StatusMeaning
sentDelivered to the carrier
deliveredConfirmed delivered to the handset
failedCould not be delivered
undeliveredCarrier 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 express
import 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 dashboard

Next steps