Skip to main content

How to build a webhook sender

To send a webhook, your service makes an HTTP POST to a URL the receiver registered, carrying the event as a JSON body and an HMAC-SHA256 signature over the timestamp and body in a header. A 2xx response means delivered. Anything else, including a timeout, is retried with exponential backoff from a background queue.

This guide builds that up in Python, starting from a naive sender, adding the signing, retries, and asynchrony a production sender needs, and finishing with what changes under heavy load.

Sending webhooks?
Svix is the enterprise-ready webhook sending service. It handles signing, retries, and delivery observability, so you can ship a reliable webhook platform in minutes instead of months. Start sending webhooks with Svix.

A naive webhook sender

First, let's create a simple webhook sender using Python and the requests library.

import json
import requests

def send_webhook(url, payload):
response = requests.post(url, json=payload)
print(f"Webhook sent to {url}, status: {response.status_code}")

if __name__ == '__main__':
webhook_url = 'https://example.com/your-webhook-listener-url'
payload = {'foo': 'bar'}

send_webhook(webhook_url, payload)

This script is pretty basic. It defines a send_webhook function that sends a POST request with a JSON payload to a specified URL.

Problems with the naive implementation

Three things are missing, and each one costs you in production. The receiver cannot tell your request from a forged one, because nothing carries a webhook signature, so anyone who learns the URL can post whatever they like. A failed delivery is simply gone: the receiver returns a 503 mid-deploy and the event is never sent again. And the call is synchronous, so a receiver that takes 30 seconds to answer holds a worker in your own application for 30 seconds.

Best practices for sending webhooks

  1. Sign your webhooks: Compute an HMAC-SHA256 over the timestamp and the raw body using a per-endpoint secret, and send it in a header alongside the timestamp. The receiver recomputes it and rejects anything that does not match or is older than a few minutes, which blocks both forgery and replay.
  2. Handle errors and retries: Retry any non-2xx response and any timeout with exponential backoff, spreading attempts over hours rather than seconds, and stop after a fixed number so a permanently dead endpoint does not accumulate work forever. Move exhausted deliveries to a dead letter queue instead of dropping them.
  3. Send webhooks asynchronously: Enqueue the delivery and return from the request that triggered the event. A receiver that takes 30 seconds to respond should never hold up your own API.
  4. Set a timeout: Give each attempt a few seconds at most. Without one, a slow receiver ties up a worker until the socket dies.
  5. Monitor and log webhook activity: Record every attempt with its response code and latency, so you can tell a broken receiver from a broken sender.
  6. Send a unique event ID: Retries mean a receiver will eventually see the same event twice, so include an ID it can store and skip on. That is what makes idempotency possible on the other side.

Scaling your webhook sender

  1. Use a message queue: Integrate a message queue like RabbitMQ or Apache Kafka to handle webhook payloads, distributing the load across multiple workers.
  2. Implement rate limiting: If you're sending webhooks to third-party services, implement rate limiting to avoid exceeding their API limits or triggering denial of service protections.
  3. Distribute webhook processing: Deploy multiple webhook sender instances behind a load balancer or use container orchestration systems like Kubernetes to distribute the workload. Webhook scalability covers what breaks first as volume grows.

The upgraded webhook sender

Now, let's implement these best practices and improvements in our webhook sender.

import json
import requests
import hmac
import hashlib
import time
from threading import Thread

def generate_signature(payload, secret_key):
mac = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256)
return mac.hexdigest()

def send_webhook(url, payload, secret_key):
body = json.dumps(payload)
timestamp = str(int(time.time()))
headers = {
'Content-Type': 'application/json',
'X-Timestamp': timestamp,
'X-Signature': generate_signature(f"{timestamp}.{body}", secret_key)
}
response = requests.post(url, headers=headers, data=body, timeout=5)
return response.status_code

def send_webhook_with_retry(url, payload, secret_key, retries=3, backoff_factor=2):
for i in range(retries):
status_code = send_webhook(url, payload, secret_key)
if 200 <= status_code < 300:
print(f"Webhook sent to {url}, status: {status_code}")
break
else:
print(f"Webhook failed with status: {status_code}. Retrying...")
time.sleep(backoff_factor ** i)

def send_webhook_async(url, payload, secret_key):
thread = Thread(target=send_webhook_with_retry, args=(url, payload, secret_key))
thread.start()

if __name__ == '__main__':
webhook_url = 'https://example.com/your-webhook-listener-url'
payload = {'foo': 'bar'}
secret_key = 'your-secret-key'

send_webhook_async(webhook_url, payload, secret_key)

In this upgraded version, generate_signature computes an HMAC-SHA256 over the timestamp and the body joined by a dot, and send_webhook sends both as headers. Signing the timestamp rather than the body alone is what stops a captured request from being replayed weeks later, and the five-second timeout keeps a stalled receiver from pinning a worker.

The send_webhook_with_retry function wraps our webhook sender with a retry mechanism, utilizing an exponential backoff strategy to avoid overwhelming the receiving server. It counts any 2xx as delivered, since receivers legitimately answer with 201 or 204.

We wrapped everything in the send_webhook_async function, which starts a new thread to send the webhook without blocking other tasks.

That gives you a sender that signs, retries, and does not block. The threading here is illustrative; in production the retry state belongs in a queue that survives a process restart, since an in-memory thread loses every pending delivery when the service redeploys.

Before committing to building and maintaining all of this yourself, it's worth weighing the full cost against a hosted service. Our webhooks build vs. buy comparison breaks down when each approach makes sense.

Ready to send webhooks?

Svix handles signing, retries, rate limiting, and delivery observability for the webhooks you send to your users, so your team can stay focused on your product.