How to send webhooks from your service
Most webhook guides focus on receiving webhooks from third-party services. But if you are building a platform, API, or SaaS product, you will eventually need to send webhooks to your users. This flips the perspective: instead of consuming events, you are producing them.
Building a reliable webhook sender involves detecting events in your system, constructing payloads, delivering them to customer endpoints, handling failures gracefully, and providing visibility into delivery status. This article walks through each component.
Detecting events worth sending
Not every change in your system deserves a webhook. Start by identifying the events your users actually care about. These typically fall into a few categories: resource lifecycle events like creation, updates, and deletion; status changes like order shipped or payment failed; and threshold alerts like usage approaching limits.
The simplest approach is to trigger webhooks directly from your application code. When you update an order status, you also queue a webhook. This works but scatters webhook logic throughout your codebase.
A cleaner pattern is to use database triggers or change data capture. Your application writes to the database normally, and a separate process watches for changes and generates webhooks. This centralizes webhook logic and ensures you never miss an event, even if application code forgets to trigger one.
# Direct approach: trigger webhook from application code
def complete_order(order_id):
order = db.update_order(order_id, status="completed")
queue_webhook("order.completed", order)
return order
# CDC approach: separate process watches for changes
def process_database_changes(change):
if change.table == "orders" and change.new["status"] == "completed":
queue_webhook("order.completed", change.new)
For high-volume systems, consider batching. Instead of sending a webhook for each individual change, collect changes over a short window and send a single webhook containing multiple events. This reduces HTTP overhead for both you and your customers.
Constructing the webhook request
A webhook request needs several components: the payload containing event data, headers with metadata and authentication, and the destination URL from the customer's subscription.
Structure your payload consistently across all event types. The envelope pattern works well: put metadata like event ID, type, and timestamp at the top level, and nest the actual data inside a data field. This lets customers parse metadata without understanding every event schema.
def build_webhook_payload(event_type, data):
return {
"id": generate_event_id(),
"type": event_type,
"created_at": datetime.utcnow().isoformat(),
"data": data
}
Sign the payload so customers can verify authenticity. HMAC-SHA256 is the standard approach. Compute the signature over the raw payload bytes and include it in a header. Also include a timestamp in the signed data to prevent replay attacks.
def sign_payload(payload_bytes, secret, timestamp):
message = f"{timestamp}.{payload_bytes.decode()}"
signature = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
return signature
Set appropriate headers: Content-Type: application/json, your signature header, a timestamp header, and optionally a user-agent identifying your service. These help customers route and validate incoming webhooks.
Delivering webhooks reliably
Never send webhooks synchronously from your main application. If a customer's endpoint is slow or down, you do not want that to block your API responses. Instead, queue webhooks for asynchronous delivery by a background worker.
Your delivery worker pulls webhooks from the queue and attempts delivery. Set a reasonable timeout, typically 5 to 30 seconds. If the endpoint returns a 2xx status code, mark the webhook as delivered. For any other response or a timeout, mark it for retry.
def deliver_webhook(webhook):
try:
response = requests.post(
webhook.endpoint_url,
data=webhook.payload,
headers=webhook.headers,
timeout=10
)
if 200 <= response.status_code < 300:
mark_delivered(webhook)
else:
schedule_retry(webhook, response.status_code)
except requests.Timeout:
schedule_retry(webhook, "timeout")
except requests.ConnectionError:
schedule_retry(webhook, "connection_error")
Implement exponential backoff for retries. First retry after one minute, then five minutes, then thirty minutes, then a few hours. Add jitter to prevent thundering herds when many webhooks fail simultaneously. After a maximum number of attempts, typically five to ten, stop retrying and move the webhook to a dead letter queue.
Handling endpoint failures
Customer endpoints fail for many reasons. Brief outages, deployment windows, and network issues cause temporary failures that retries will eventually overcome. But some endpoints fail persistently: misconfigured URLs, expired certificates, or abandoned integrations.
Track failure rates per endpoint. If an endpoint fails consistently over a period like 24 hours, consider disabling it automatically. Notify the customer so they can fix the issue, and provide a way to re-enable once they have corrected the problem.
Implement circuit breakers for endpoints that fail rapidly. If you see ten failures in a row, pause deliveries to that endpoint for a few minutes before trying again. This protects both your infrastructure and theirs from wasted requests during an outage.
Providing visibility
Customers need to see what webhooks you are sending them. Build a dashboard showing recent deliveries, their status, and response details. Let customers view payloads for debugging and manually retry failed deliveries.
Log every delivery attempt with timestamps, response codes, and latency. This helps customers debug their integrations and helps you identify patterns in failures. Redact sensitive data from logs to protect privacy.
Expose webhook logs through your API as well as your dashboard. Customers building automated systems need programmatic access to delivery status and history.
Consider offering a test mode where customers can trigger sample webhooks to their endpoint. This helps them verify their integration works before going live. Include clearly marked test payloads so their systems can distinguish tests from real events.