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 rawhttp://localhost:5000/…URL never works. - Both
http://andhttps://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 5000This exposes your local server at a public URL:
https://abc123.ngrok.io -> http://localhost:5000Set 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:
- In the Sent Dashboard, open the webhook's actions menu and select Test Webhook.
- 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.
- 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, orFAILED - 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:
- Tunnel not running or URL stale: restart the tunnel and confirm the webhook's endpoint URL matches its current public URL.
- 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.
- Handler responds too slowly: your endpoint must return
2xxwithin the webhook'stimeout_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 '', 200import "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])
}
}
}Related Pages
- Security: the signature scheme and verification steps
- Handling Retries: idempotency keys, deduplication, and out-of-order events
- Events Reference: payloads, delivery statuses, and the retry schedule
- Production Checklist: what to confirm before going live
Handling Retries
Make your Sent webhook handler idempotent: choose a payload-based dedupe key, handle duplicate and out-of-order events, and understand why retries happen.
Production Checklist
Pre-launch checklist for Sent webhook handlers: HTTPS and signature verification, idempotency, monitoring, error handling, scaling, and a runbook template.