Webhook Development & Debugging
Overview
Webhooks are HTTP callbacks that enable real-time communication between Sent and your application.
Sent will send the following events to your webhook endpoint:
| Event Category | Description |
|---|---|
| Message Status Updates | Real-time updates on message delivery, failures, and read receipts |
| Template Status Updates | Real-time updates about template status changes |
When events occur in Sent (like message delivery status changes), we send HTTP POST requests to your configured webhook endpoint with event data.
💡 Using Webhooks, you can trigger reactive workflows based on messaging events in real-time. We highly recommend integrating webhook event handlers into your application to unlock Sent's full potential.
Configuring Webhooks
You can configure your webhook endpoint in your Sent Dashboard by clicking on the Add Webhook button.
Local Webhook Setup
Webhooks are outbound HTTP requests initiated by Sent towards your application.
- During development, your app usually runs on
localhostor similar — which is not accessible from the internet. Therefore the Sent webhook provider can’t reach your machine directly, since it’s behind NAT/firewalls and doesn’t have a public IP. - Sent will only send events to secure, public URLs (https://…), not local or unencrypted endpoints.
Because of these factors, when you try to point a webhook to your local dev server, the delivery fails — Sent simply can’t connect due to the aforementioned reasons.
🧩 Solution
To debug and work with webhooks locally, you will need to use a secure tunneling tool that exposes your local server to the internet through a public HTTPS URL.
Below are some popular tunneling tools you can use to debug and work with webhooks locally:
ngrok
# Install ngrok (if not already installed)
npm install -g @ngrok/ngrok
# Start your local server
npm run dev # Running on http://localhost:5000
# In another terminal, create tunnel
ngrok http 5000This exposes your local server at a public URL:
https://abc123.ngrok.io -> http://localhost:5000Configure your webhook endpoint as https://abc123.ngrok.io/webhooks/sent.
Alternative Tunneling Tools
| Tool | Cost | Features | Best For | Setup Complexity |
|---|---|---|---|---|
| Cloudflare Tunnel | Free | Persistent URLs, built-in DDoS protection, custom domains, no bandwidth limits | Teams needing reliability and custom domains | Medium - Requires Cloudflare account |
| LocalTunnel | Free | Simple setup, random URLs, open source | Quick testing, no account needed | Very Low - npm install |
| serveo.net | Free | SSH-based, no installation, custom subdomains | Users comfortable with SSH, minimal setup | Low - SSH command only |
| VS Code Port Forwarding | Free | Built into VS Code, GitHub integration, private tunnels | VS Code users, GitHub integration | Very Low - One-click in IDE |
| Tailscale Funnel | Free / Paid | Secure mesh network, persistent access, team sharing | Secure team development, persistent access | Medium - Requires Tailscale setup |
Local Webhook Testing
We highly recommend testing your webhook handling logic with various webhook events locally before deploying.
Here is how a webhook handler might look like in different languages:
// Test webhook handler with different event types
app.post("/webhooks/sent", express.json(), (req, res) => {
console.log(`Received: ${req.body.event.type}`);
console.log("Data:", JSON.stringify(req.body.data, null, 2));
switch (req.body.event.type) {
case "message.status.updated":
handleMessageStatus(req.body.data);
break;
case "message.inbound.received":
handleInboundMessage(req.body.data);
break;
case "template.approval.updated":
handleTemplateApproval(req.body.data);
break;
default:
console.log(`Unhandled event type: ${req.body.event.type}`);
}
res.sendStatus(200);
});from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
event_data = request.json
event_type = event_data['event']['type']
print(f"Received: {event_type}")
print(f"Data: {event_data['data']}")
if event_type == "message.status.updated":
handle_message_status(event_data['data'])
elif event_type == "message.inbound.received":
handle_inbound_message(event_data['data'])
elif event_type == "template.approval.updated":
handle_template_approval(event_data['data'])
else:
print(f"Unhandled event type: {event_type}")
return '', 200func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Received: %s", event.Event.Type)
log.Printf("Data: %+v", event.Data)
switch event.Event.Type {
case "message.status.updated":
handleMessageStatus(event.Data)
case "message.inbound.received":
handleInboundMessage(event.Data)
case "template.approval.updated":
handleTemplateApproval(event.Data)
default:
log.Printf("Unhandled event type: %s", event.Event.Type)
}
w.WriteHeader(http.StatusOK)
}Webhook Handler Best Practices
When building webhook handlers, following best practices is essential for reliability, security, and maintainability. Webhooks deliver real-time notifications about important events—such as message delivery updates, incoming messages, or template approvals—directly to your system. If your handlers are not robust:
- You risk data loss or duplicate processing: Webhooks may be delivered more than once, or occasionally out of order, due to retries or network issues. Idempotent handlers and duplicate detection prevent incorrect data updates or processing the same event multiple times.
- You may introduce security vulnerabilities: Verifying the integrity and origin of each webhook request (using signatures or secrets) protects your application from unauthorized or malicious requests.
- You could impact performance or user experience: Proper error handling and fast, asynchronous processing prevent slow or blocked endpoints, ensuring you meet response deadlines and trigger necessary business logic promptly.
- You make system monitoring and debugging harder: Logging received events and responses provides clear traces for troubleshooting, auditing, and improving reliability.
Adhering to these practices helps guarantee your webhook integration is secure, reliable, and future-proof—no matter how your scale or traffic volume grows.
Here are some best practices you can follow to build robust webhook handlers:
Idempotency
Sent may deliver the same event more than once due to retries, network issues, or system recovery. Your handler must be idempotent to prevent duplicate processing.
Duplicate Detection
- Store processed event IDs to skip duplicates:
const processedEvents = new Set(); // Use Redis/database in production
app.post("/webhooks/sent", express.json(), (req, res) => {
const eventId = req.body.id;
// Check if already processed
if (processedEvents.has(eventId)) {
console.log(`Duplicate event ${eventId}, skipping`);
return res.sendStatus(200);
}
// Process the event
try {
processWebhookEvent(req.body);
processedEvents.add(eventId);
res.sendStatus(200);
} catch (error) {
console.error("Processing failed:", error);
res.sendStatus(500); // This will trigger a retry
}
});processed_events = set() # Use Redis/database in production
@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
event_data = request.json
event_id = event_data['id']
# Check if already processed
if event_id in processed_events:
print(f"Duplicate event {event_id}, skipping")
return '', 200
# Process the event
try:
process_webhook_event(event_data)
processed_events.add(event_id)
return '', 200
except Exception as error:
print(f"Processing failed: {error}")
return '', 500 # This will trigger a retryvar processedEvents = make(map[string]bool) // Use Redis/database in production
var mu sync.Mutex
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
json.NewDecoder(r.Body).Decode(&event)
mu.Lock()
defer mu.Unlock()
// Check if already processed
if processedEvents[event.ID] {
log.Printf("Duplicate event %s, skipping", event.ID)
w.WriteHeader(http.StatusOK)
return
}
// Process the event
if err := processWebhookEvent(event); err != nil {
log.Printf("Processing failed: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
processedEvents[event.ID] = true
w.WriteHeader(http.StatusOK)
}Database-Based Deduplication
- For production-grade systems, use database storage to store processed event IDs and prevent duplicate processing:
async function handleWebhook(eventData) {
const { id: eventId, event, data } = eventData;
// Check if event already processed
const existing = await db.webhookEvents.findUnique({
where: { eventId },
});
if (existing) {
console.log(`Event ${eventId} already processed`);
return; // Idempotent - already handled
}
// Use transaction to ensure atomicity
await db.$transaction(async (tx) => {
// Record that we're processing this event
await tx.webhookEvents.create({
data: {
eventId,
eventType: event.type,
processedAt: new Date(),
status: "processing",
},
});
// Process the actual business logic
await processBusinessLogic(data);
// Mark as completed
await tx.webhookEvents.update({
where: { eventId },
data: { status: "completed" },
});
});
}from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
async def handle_webhook(event_data: dict, db: Session):
event_id = event_data['id']
event_type = event_data['event']['type']
data = event_data['data']
# Check if event already processed
existing = db.query(WebhookEvent).filter_by(event_id=event_id).first()
if existing:
print(f"Event {event_id} already processed")
return # Idempotent - already handled
# Use transaction to ensure atomicity
try:
# Record that we're processing this event
webhook_event = WebhookEvent(
event_id=event_id,
event_type=event_type,
processed_at=datetime.now(),
status="processing"
)
db.add(webhook_event)
db.flush()
# Process the actual business logic
await process_business_logic(data)
# Mark as completed
webhook_event.status = "completed"
db.commit()
except IntegrityError:
db.rollback()
print(f"Event {event_id} already processed (race condition)")
except Exception as e:
db.rollback()
raise efunc handleWebhook(ctx context.Context, eventData WebhookEvent, db *sql.DB) error {
eventID := eventData.ID
// Check if event already processed
var exists bool
err := db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM webhook_events WHERE event_id = $1)",
eventID,
).Scan(&exists)
if err != nil {
return err
}
if exists {
log.Printf("Event %s already processed", eventID)
return nil // Idempotent - already handled
}
// Use transaction to ensure atomicity
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Record that we're processing this event
_, err = tx.ExecContext(ctx,
"INSERT INTO webhook_events (event_id, event_type, processed_at, status) VALUES ($1, $2, $3, $4)",
eventID, eventData.Event.Type, time.Now(), "processing",
)
if err != nil {
return err
}
// Process the actual business logic
if err := processBusinessLogic(eventData.Data); err != nil {
return err
}
// Mark as completed
_, err = tx.ExecContext(ctx,
"UPDATE webhook_events SET status = $1 WHERE event_id = $2",
"completed", eventID,
)
if err != nil {
return err
}
return tx.Commit()
}Queue-Based Architecture
Webhooks must return 2xx quickly. Don't do heavy work inside the handler. Instead, push the event to a queue for background processing.
Acknowledge Immediately
- Return success status right away to acknowledge the event:
app.post("/webhooks/sent", express.json(), (req, res) => {
// Immediate acknowledgment
res.sendStatus(200);
// Push to queue for background processing
queue.add("webhook-processing", {
eventId: req.body.id,
eventType: req.body.event.type,
eventData: req.body.data,
receivedAt: new Date(),
});
});from celery import Celery
celery_app = Celery('tasks', broker='redis://localhost:6379/0')
@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
event_data = request.json
# Immediate acknowledgment
# Push to queue for background processing
process_webhook.delay(
event_id=event_data['id'],
event_type=event_data['event']['type'],
event_data=event_data['data']
)
return '', 200func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
json.NewDecoder(r.Body).Decode(&event)
// Immediate acknowledgment
w.WriteHeader(http.StatusOK)
// Push to queue for background processing
task := &Task{
EventID: event.ID,
EventType: event.Event.Type,
EventData: event.Data,
ReceivedAt: time.Now(),
}
queue.Enqueue(task)
}Process in Background
- Handle the business logic asynchronously:
// Worker process
queue.process("webhook-processing", async (job) => {
const { eventId, eventType, eventData } = job.data;
try {
switch (eventType) {
case "message.status.updated":
await updateOrderStatus(eventData);
break;
case "message.inbound.received":
await handleCustomerReply(eventData);
break;
}
console.log(`Successfully processed event ${eventId}`);
} catch (error) {
console.error(`Failed to process event ${eventId}:`, error);
throw error; // Will trigger retry based on queue configuration
}
});@celery_app.task(bind=True, max_retries=3)
def process_webhook(self, event_id, event_type, event_data):
try:
if event_type == "message.status.updated":
update_order_status(event_data)
elif event_type == "message.inbound.received":
handle_customer_reply(event_data)
print(f"Successfully processed event {event_id}")
except Exception as error:
print(f"Failed to process event {event_id}: {error}")
raise self.retry(exc=error, countdown=60)// Worker process
func processWebhookWorker(queue *Queue) {
for task := range queue.Tasks {
eventID := task.EventID
eventType := task.EventType
eventData := task.EventData
var err error
switch eventType {
case "message.status.updated":
err = updateOrderStatus(eventData)
case "message.inbound.received":
err = handleCustomerReply(eventData)
}
if err != nil {
log.Printf("Failed to process event %s: %v", eventID, err)
// Will trigger retry based on queue configuration
queue.Retry(task)
} else {
log.Printf("Successfully processed event %s", eventID)
}
}
}Queue Options
Popular queuing solutions for webhook processing:
| Queue System | Type | Best For | Throughput | Language Support | Key Features |
|---|---|---|---|---|---|
| BullMQ | Redis-based | Node.js apps, real-time processing | 10K+ jobs/sec | Node.js, TypeScript | Job scheduling, retries, priorities, UI dashboard (Bull Board) |
| Celery | Distributed task queue | Python apps, async processing | 5K+ tasks/sec | Python | Flexible routing, result backends, periodic tasks, flower monitoring |
| Amazon SQS | Cloud-managed | AWS infrastructure, serverless | 3K msgs/sec (standard), unlimited (FIFO) | All (via SDK) | Fully managed, auto-scaling, dead-letter queues, pay-per-use |
| RabbitMQ | Message broker | Enterprise apps, complex routing | 20K+ msgs/sec | All (AMQP) | Advanced routing, clustering, message acknowledgment, management UI |
| Apache Kafka | Event streaming | High-throughput, event sourcing | 1M+ msgs/sec | All (via clients) | Distributed, log-based, replay capability, stream processing |
| Redis Streams | In-memory streaming | Simple queuing, low latency | 50K+ msgs/sec | All (via Redis clients) | Built into Redis, consumer groups, persistence options |
| Google Cloud Tasks | Cloud-managed | GCP infrastructure, HTTP tasks | 500 tasks/sec per queue | All (via API) | Fully managed, automatic retries, rate limiting, Cloud Functions integration |
| Azure Service Bus | Cloud-managed | Azure infrastructure, enterprise | 2K msgs/sec (standard) | All (via SDK) | Message sessions, duplicate detection, dead-lettering, transactions |
| Sidekiq | Redis-based | Ruby/Rails apps | 10K+ jobs/sec | Ruby | Simple API, web UI, scheduled jobs, Active Job integration |
| Hangfire | .NET background jobs | .NET apps | 5K+ jobs/sec | C#, .NET | SQL/Redis storage, dashboard, recurring jobs, automatic retries |
Example queue setup with configuration:
import { Queue, Worker } from 'bullmq';
const webhookQueue = new Queue("webhook-processing", {
connection: { host: "localhost", port: 6379 },
});
// Add to queue
app.post("/webhooks/sent", express.json(), async (req, res) => {
res.sendStatus(200);
await webhookQueue.add("process-event", req.body, {
attempts: 3,
backoff: {
type: "exponential",
delay: 5000,
},
});
});
// Process from queue
const worker = new Worker(
"webhook-processing",
async (job) => {
const eventData = job.data;
await processWebhookEvent(eventData);
},
{ connection: { host: "localhost", port: 6379 } }
);from celery import Celery
from kombu import Exchange, Queue
# Configure Celery
celery_app = Celery('webhook_processor', broker='redis://localhost:6379/0')
celery_app.conf.task_routes = {
'process_webhook_event': {'queue': 'webhook-processing'}
}
celery_app.conf.task_queues = (
Queue('webhook-processing', Exchange('webhook-processing'), routing_key='webhook'),
)
# Add to queue
@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
event_data = request.json
# Queue with retry configuration
process_webhook_event.apply_async(
args=[event_data],
retry=True,
retry_policy={
'max_retries': 3,
'interval_start': 5,
'interval_step': 5,
'interval_max': 15,
}
)
return '', 200
# Process from queue
@celery_app.task(bind=True, max_retries=3)
def process_webhook_event(self, event_data):
try:
# Process webhook event
handle_event(event_data)
except Exception as exc:
raise self.retry(exc=exc, countdown=5)import (
"github.com/gomodule/redigo/redis"
"encoding/json"
)
// Queue configuration
type WebhookQueue struct {
pool *redis.Pool
}
func NewWebhookQueue() *WebhookQueue {
return &WebhookQueue{
pool: &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) {
return redis.Dial("tcp", "localhost:6379")
},
},
}
}
// Add to queue
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
json.NewDecoder(r.Body).Decode(&event)
w.WriteHeader(http.StatusOK)
// Add to Redis queue
conn := queue.pool.Get()
defer conn.Close()
data, _ := json.Marshal(event)
conn.Do("LPUSH", "webhook-processing", data)
}
// Process from queue
func (q *WebhookQueue) ProcessWorker() {
conn := q.pool.Get()
defer conn.Close()
for {
reply, err := redis.ByteSlices(conn.Do("BRPOP", "webhook-processing", 0))
if err != nil {
log.Printf("Queue error: %v", err)
continue
}
var event WebhookEvent
if err := json.Unmarshal(reply[1], &event); err != nil {
log.Printf("Unmarshal error: %v", err)
continue
}
if err := processWebhookEvent(event); err != nil {
log.Printf("Processing error: %v", err)
// Re-queue with retry logic
conn.Do("LPUSH", "webhook-processing:retry", reply[1])
}
}
}Retries & Error Handling
If your endpoint returns 5xx or times out, Sent retries delivery with exponential backoff.
Retry Strategy
Sent's retry behavior follows an exponential backoff pattern:
| Attempt | Delay | Cumulative Time |
|---|---|---|
| Initial | 0s | 0s |
| Retry 1 | 1s | 1s |
| Retry 2 | 2s | 3s |
| Retry 3 | 4s | 7s |
| Retry 4 | 8s | 15s |
| Retry 5 | 16s | 31s |
| Retry 6 | 32s | 63s |
| Retry 7 | 64s | ~2 minutes |
| Final | - | Event marked as failed |
Handling Temporary Failures
Distinguish between temporary and permanent failures to prevent unnecessary retries:
app.post("/webhooks/sent", express.json(), (req, res) => {
try {
processEvent(req.body);
res.sendStatus(200); // Success
} catch (error) {
if (error.code === "TEMPORARY_FAILURE") {
// Database connection lost, service unavailable, etc.
res.sendStatus(500); // Retry
} else if (error.code === "INVALID_DATA") {
// Malformed event data, business logic error
console.error("Permanent failure:", error);
res.sendStatus(200); // Don't retry
} else {
// Unknown error - safe to retry
res.sendStatus(500);
}
}
});@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
try:
process_event(request.json)
return '', 200 # Success
except Exception as error:
if hasattr(error, 'code') and error.code == "TEMPORARY_FAILURE":
# Database connection lost, service unavailable, etc.
return '', 500 # Retry
elif hasattr(error, 'code') and error.code == "INVALID_DATA":
# Malformed event data, business logic error
print(f"Permanent failure: {error}")
return '', 200 # Don't retry
else:
# Unknown error - safe to retry
return '', 500func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
json.NewDecoder(r.Body).Decode(&event)
if err := processEvent(event); err != nil {
switch err.(type) {
case *TemporaryFailureError:
// Database connection lost, service unavailable, etc.
w.WriteHeader(http.StatusInternalServerError) // Retry
case *InvalidDataError:
// Malformed event data, business logic error
log.Printf("Permanent failure: %v", err)
w.WriteHeader(http.StatusOK) // Don't retry
default:
// Unknown error - safe to retry
w.WriteHeader(http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusOK) // Success
}Production Readiness Checklist
Pre-deployment Checklist
Ensure your webhook handler implementation is production-ready before going live.