Local Development & Debugging

This guide shows you how to receive Sent webhooks on a local development server, send test events on demand, debug failed deliveries, and keep your handler fast with queue-based processing.

Prerequisites:

  • A configured webhook endpoint (see Webhook Setup)
  • A handler that verifies request signatures (see Security)

For idempotency and duplicate-delivery handling, refer to Handling Retries; this page links to those rules rather than restating them.

Expose Your Local Server

Webhooks are outbound HTTP requests initiated by Sent towards your app, so Sent must be able to reach your handler:

  • During development your app usually runs on localhost, which sits behind NAT or a firewall and has no public IP, so the Sent webhook delivery service cannot reach it directly.
  • Sent rejects URLs that resolve to private/loopback IP ranges (for example, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) at registration and delivery time, so a raw http://localhost:5000/… URL never works.
  • Both http:// and https:// schemes are accepted by the platform, but use HTTPS in production so the signed payload cannot be intercepted in transit.

To receive webhooks locally, use a secure tunneling tool that exposes your local server to the internet through a public HTTPS URL.

Create a tunnel with 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

Set your webhook's Endpoint URL to https://abc123.ngrok.io/webhooks/sent, either when adding the webhook or by editing an existing one in the Sent Dashboard.

If your tunnel URL changes after a restart, update the webhook's endpoint URL to match. Deliveries to the stale URL show up as FAILED in the webhook's Events table.

Alternative tunneling tools

If ngrok does not fit your workflow, other options include:

  • Cloudflare Tunnel: persistent URLs and custom domains
  • LocalTunnel: installs from npm, no account required
  • serveo.net: SSH-based, nothing to install
  • VS Code port forwarding: built into the editor
  • Tailscale Funnel: exposes a server from an existing Tailscale network

Send Test Events

You do not need to send real messages to exercise your handler. Sent can deliver a sample payload for any event your webhook subscribes to:

  1. In the Sent Dashboard, open the webhook's actions menu and select Test Webhook.
  2. The dialog lists the events the webhook subscribes to. Click Test next to an event, and Sent sends a sample payload for it to your endpoint.
  3. The result appears immediately, and the delivery is recorded in the webhook's Events table like any other delivery.

To trigger the same delivery from scripts or CI, call the test endpoint:

curl -X POST "https://api.sent.dm/v3/webhooks/{webhook_id}/test" \
  -H "x-api-key: $SENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "message.sent"}'

The test endpoint is on the sensitive rate tier (10 requests per minute), so keep automated test-delivery loops slow.

Debug Failed Deliveries

Every delivery attempt is recorded. In the dashboard, open a webhook to inspect its Events table; each delivery shows:

  • the delivery status: PENDING, RETRYING, DELIVERED, or FAILED
  • the number of delivery attempts
  • the HTTP status code and the first portion of the response body your endpoint returned
  • the error message, plus processing start and completion timestamps

The same data is available over the API via GET /v3/webhooks/{id}/events. The retry schedule, retry budget, and auto-disable threshold are stated canonically in Delivery Statuses & Retries.

When a local delivery fails, check these causes in order:

  1. Tunnel not running or URL stale: restart the tunnel and confirm the webhook's endpoint URL matches its current public URL.
  2. Signature verification rejects the request: verification must run on the raw request body; a body parsed and re-serialized by your framework produces a different signature. See Security for the scheme and raw-body capture patterns per framework.
  3. Handler responds too slowly: your endpoint must return 2xx within the webhook's timeout_seconds (5–120 s, default 30 s). Move slow work out of the request path with queue-based processing.

Queue-Based Processing

Webhooks must return 2xx quickly. Don't do heavy work inside the handler. Instead, push the event to a queue for background processing.

The examples import verifyWebhookSignature and idempotencyKey from a shared webhook-utils module: implement them from the signature scheme and the idempotency key rules.

Acknowledge Immediately

  • Verify signature, then return success status right away:
import { verifyWebhookSignature, idempotencyKey } from './webhook-utils.js';

// Use raw body parser
app.use('/webhooks/sent', express.raw({ type: 'application/json' }));

app.post("/webhooks/sent", (req, res) => {
  // Extract headers for signature verification
  const signature = req.get('x-webhook-signature');
  const webhookId = req.get('x-webhook-id');
  const timestamp = req.get('x-webhook-timestamp');
  const webhookSecret = process.env.SENT_WEBHOOK_SECRET;
  const rawBody = req.body.toString();

  if (!verifyWebhookSignature(rawBody, signature, webhookId, timestamp, webhookSecret)) {
    return res.status(401).send('Unauthorized');
  }

  // Parse event
  const event = JSON.parse(rawBody);

  // Immediate acknowledgment
  res.sendStatus(200);

  // Push to queue for background processing with a payload-derived
  // idempotency key (see the Handling Retries page)
  queue.add("webhook-processing", {
    eventId: idempotencyKey(event),
    field: event.field,
    payload: event.payload,
    timestamp: event.timestamp,
  });
});
from celery import Celery
from webhook_utils import verify_webhook_signature, idempotency_key

celery_app = Celery('tasks', broker='redis://localhost:6379/0')

@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
    # Extract headers for signature verification
    signature = request.headers.get('x-webhook-signature')
    webhook_id = request.headers.get('x-webhook-id')
    timestamp = request.headers.get('x-webhook-timestamp')
    webhook_secret = os.environ.get('SENT_WEBHOOK_SECRET')
    raw_body = request.get_data()

    if not verify_webhook_signature(raw_body, signature, webhook_id, timestamp, webhook_secret):
        return 'Unauthorized', 401

    # Parse event
    event = request.get_json()

    # Immediate acknowledgment
    # Push to queue for background processing with a payload-derived
    # idempotency key (see the Handling Retries page)
    process_webhook.delay(
        event_id=idempotency_key(event),
        field=event['field'],
        payload=event['payload'],
        timestamp=event['timestamp']
    )

    return '', 200
import "yourapp/webhook"

// WebhookEvent maps the envelope: Field `json:"field"`, Event `json:"event"`,
// Timestamp `json:"timestamp"`, Payload `json:"payload"` (json.RawMessage)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    // Extract headers for signature verification
    signature := r.Header.Get("x-webhook-signature")
    webhookID := r.Header.Get("x-webhook-id")
    timestamp := r.Header.Get("x-webhook-timestamp")
    webhookSecret := os.Getenv("SENT_WEBHOOK_SECRET")

    body, _ := io.ReadAll(r.Body)

    if !webhook.VerifyWebhookSignature(body, signature, webhookID, timestamp, webhookSecret) {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Parse event
    var event WebhookEvent
    json.Unmarshal(body, &event)

    // Immediate acknowledgment
    w.WriteHeader(http.StatusOK)

    // Push to queue for background processing with a payload-derived
    // idempotency key (idempotencyKey implements the Handling Retries rules)
    task := &Task{
        EventID:   idempotencyKey(event),
        Field:     event.Field,
        Payload:   event.Payload,
        Timestamp: event.Timestamp,
    }

    queue.Enqueue(task)
}

Process in Background

  • Handle the business logic asynchronously:
// Worker process
queue.process("webhook-processing", async (job) => {
  const { field, payload, timestamp } = job.data;

  try {
    if (field === 'message') {
      await handleMessageEvent(payload);
    } else if (field === 'templates') {
      await handleTemplateEvent(payload);
    }

    console.log(`Successfully processed ${field} event`);
  } catch (error) {
    console.error(`Failed to process ${field} event:`, error);
    throw error; // Will trigger retry based on queue configuration
  }
});

async function handleMessageEvent(payload) {
  const { message_id, message_status } = payload;

  if (message_status === 'DELIVERED') {
    // Update order status, send notification, etc.
    await updateOrderStatus(message_id, 'delivered');
  } else if (message_status === 'FAILED') {
    await notifyDeliveryFailure(message_id);
  }
}

async function handleTemplateEvent(payload) {
  const { template_id, status } = payload;

  if (status === 'APPROVED') {
    await enableTemplate(template_id);
  }
}
@celery_app.task(bind=True, max_retries=3)
def process_webhook(self, event_id, field, payload, timestamp):
    try:
        if field == 'message':
            handle_message_event(payload)
        elif field == 'templates':
            handle_template_event(payload)

        print(f"Successfully processed {field} event")
    except Exception as error:
        print(f"Failed to process {field} event: {error}")
        raise self.retry(exc=error, countdown=60)

def handle_message_event(payload):
    message_id = payload['message_id']
    message_status = payload['message_status']

    if message_status == 'DELIVERED':
        # Update order status, send notification, etc.
        update_order_status(message_id, 'delivered')
    elif message_status == 'FAILED':
        notify_delivery_failure(message_id)

def handle_template_event(payload):
    template_id = payload['template_id']
    template_name = payload['template_name']
    status = payload['status']

    if status == 'APPROVED':
        enable_template(template_id)
// Worker process
func processWebhookWorker(queue *Queue) {
    for task := range queue.Tasks {
        field := task.Field
        event := task.Event

        var err error
        switch field {
        case "message":
            err = handleMessageEvent(event)
        case "templates":
            err = handleTemplateEvent(event)
        }

        if err != nil {
            log.Printf("Failed to process %s event: %v", field, err)
            // Will trigger retry based on queue configuration
            queue.Retry(task)
        } else {
            log.Printf("Successfully processed %s event", field)
        }
    }
}

func handleMessageEvent(event WebhookEvent) error {
    var payload MessagePayload
    if err := json.Unmarshal(event.Payload, &payload); err != nil {
        return err
    }

    if payload.MessageStatus == "DELIVERED" {
        // Update order status, send notification, etc.
        return updateOrderStatus(payload.MessageID, "delivered")
    } else if payload.MessageStatus == "FAILED" {
        return notifyDeliveryFailure(payload.MessageID)
    }

    return nil
}

func handleTemplateEvent(event WebhookEvent) error {
    var payload TemplatePayload
    if err := json.Unmarshal(event.Payload, &payload); err != nil {
        return err
    }

    return syncTemplate(payload.TemplateID)
}

Queue options

Pick whatever queue your stack already runs: for example, BullMQ for Node.js, Celery for Python, or a managed service such as Amazon SQS. Example setup with retry configuration:

import { Queue, Worker } from 'bullmq';
import { idempotencyKey } from './webhook-utils.js';

const webhookQueue = new Queue("webhook-processing", {
  connection: { host: "localhost", port: 6379 },
});

// Add to queue (use raw body parser to preserve the raw bytes for signature verification)
app.use('/webhooks/sent', express.raw({ type: 'application/json' }));
app.post("/webhooks/sent", async (req, res) => {
  res.sendStatus(200);

  const event = JSON.parse(req.body.toString());
  await webhookQueue.add("process-event", {
    eventId: idempotencyKey(event),
    ...event,
  }, {
    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
from webhook_utils import idempotency_key

# 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.get_json()

    # Queue with retry configuration and a payload-derived idempotency key
    process_webhook_event.apply_async(
        kwargs={
            'event_id': idempotency_key(event_data),
            'field': event_data['field'],
            'payload': event_data['payload'],
            'timestamp': event_data['timestamp'],
        },
        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
type QueuedEvent struct {
    EventID string `json:"event_id"` // payload-derived idempotency key (see the Handling Retries page)
    WebhookEvent
}

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 with a payload-derived idempotency key
    conn := queue.pool.Get()
    defer conn.Close()

    queued := QueuedEvent{EventID: idempotencyKey(event), WebhookEvent: event}
    data, _ := json.Marshal(queued)
    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])
        }
    }
}

On this page