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 CategoryDescription
Message Status UpdatesReal-time updates on message delivery, failures, and read receipts
Template Status UpdatesReal-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 localhost or 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 5000

This exposes your local server at a public URL:

https://abc123.ngrok.io -> http://localhost:5000

Configure your webhook endpoint as https://abc123.ngrok.io/webhooks/sent.

Alternative Tunneling Tools

ToolCostFeaturesBest ForSetup Complexity
Cloudflare TunnelFreePersistent URLs, built-in DDoS protection, custom domains, no bandwidth limitsTeams needing reliability and custom domainsMedium - Requires Cloudflare account
LocalTunnelFreeSimple setup, random URLs, open sourceQuick testing, no account neededVery Low - npm install
serveo.netFreeSSH-based, no installation, custom subdomainsUsers comfortable with SSH, minimal setupLow - SSH command only
VS Code Port ForwardingFreeBuilt into VS Code, GitHub integration, private tunnelsVS Code users, GitHub integrationVery Low - One-click in IDE
Tailscale FunnelFree / PaidSecure mesh network, persistent access, team sharingSecure team development, persistent accessMedium - 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 '', 200
func 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 retry
var 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 e
func 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 '', 200
func 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 SystemTypeBest ForThroughputLanguage SupportKey Features
BullMQRedis-basedNode.js apps, real-time processing10K+ jobs/secNode.js, TypeScriptJob scheduling, retries, priorities, UI dashboard (Bull Board)
CeleryDistributed task queuePython apps, async processing5K+ tasks/secPythonFlexible routing, result backends, periodic tasks, flower monitoring
Amazon SQSCloud-managedAWS infrastructure, serverless3K msgs/sec (standard), unlimited (FIFO)All (via SDK)Fully managed, auto-scaling, dead-letter queues, pay-per-use
RabbitMQMessage brokerEnterprise apps, complex routing20K+ msgs/secAll (AMQP)Advanced routing, clustering, message acknowledgment, management UI
Apache KafkaEvent streamingHigh-throughput, event sourcing1M+ msgs/secAll (via clients)Distributed, log-based, replay capability, stream processing
Redis StreamsIn-memory streamingSimple queuing, low latency50K+ msgs/secAll (via Redis clients)Built into Redis, consumer groups, persistence options
Google Cloud TasksCloud-managedGCP infrastructure, HTTP tasks500 tasks/sec per queueAll (via API)Fully managed, automatic retries, rate limiting, Cloud Functions integration
Azure Service BusCloud-managedAzure infrastructure, enterprise2K msgs/sec (standard)All (via SDK)Message sessions, duplicate detection, dead-lettering, transactions
SidekiqRedis-basedRuby/Rails apps10K+ jobs/secRubySimple API, web UI, scheduled jobs, Active Job integration
Hangfire.NET background jobs.NET apps5K+ jobs/secC#, .NETSQL/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:

AttemptDelayCumulative Time
Initial0s0s
Retry 11s1s
Retry 22s3s
Retry 34s7s
Retry 48s15s
Retry 516s31s
Retry 632s63s
Retry 764s~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 '', 500
func 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.

Security

Reliability

Monitoring

Performance