Skip to main content

How to build your first webhook endpoint

Receiving webhooks is one of the most common integration tasks in backend development. Whether you are processing payments, reacting to repository events, or handling notifications from any third-party service, you need an HTTP endpoint that can accept POST requests and process them correctly.

This guide walks through building a webhook receiver in three popular stacks: Node.js with Express, Python with Flask, and Go with the standard library. Each example follows the same pattern: accept the request, verify it came from a trusted source, process the payload, and return an appropriate response.

What a webhook endpoint needs to do

Before writing code, let's understand what happens when a webhook arrives. The sender makes an HTTP POST request to your endpoint with a JSON body containing event data. Your server needs to parse that JSON, verify the request's authenticity using a signature, do something useful with the data, and return a 2xx status code to acknowledge receipt.

The acknowledgment part is important. Most webhook senders expect a response within a few seconds. If your endpoint takes too long or returns an error, the sender will retry, potentially multiple times. This means your processing logic should be fast, and any heavy work should happen asynchronously after you have acknowledged the webhook.

Webhook requests typically include standard headers for verification. Providers following the Standard Webhooks spec use webhook-id for a unique identifier, webhook-timestamp for when the webhook was sent, and webhook-signature for the cryptographic signature you use to verify authenticity. While the examples below show manual verification, Svix and many other webhook providers offer SDKs that handle signature verification for you, reducing boilerplate and the risk of implementation errors.

Building a webhook receiver in Node.js with Express

Express makes it straightforward to handle incoming webhooks. The key detail is accessing the raw request body for signature verification while also parsing it as JSON.

const express = require('express');
const crypto = require('crypto');

const app = express();

// Store the raw body for signature verification
app.use('/webhooks', express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(payload, timestamp, signature, secret) {
const signedContent = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('base64');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}

app.post('/webhooks', (req, res) => {
const webhookId = req.headers['webhook-id'];
const webhookTimestamp = req.headers['webhook-timestamp'];
const webhookSignature = req.headers['webhook-signature'];

if (!webhookId || !webhookTimestamp || !webhookSignature) {
return res.status(401).send('Missing headers');
}

// Verify the webhook came from a trusted source
if (!verifySignature(req.rawBody, webhookTimestamp, webhookSignature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}

const event = req.body;
console.log(`Received event ${webhookId}: ${event.type}`);
console.log(`Invoice ${event.data.id} was paid`);

// Respond quickly to acknowledge receipt
res.status(200).json({ received: true });
});

app.listen(3000, () => {
console.log('Webhook receiver listening on port 3000');
});

The verify option in the JSON middleware gives us access to the raw body buffer, which we need for signature verification. We use crypto.timingSafeEqual to compare signatures because a regular string comparison can leak information through timing differences, making it vulnerable to attack.

Building a webhook receiver in Python with Flask

Flask's simplicity makes it an excellent choice for webhook endpoints. Here's the equivalent implementation in Python.

import hmac
import hashlib
import base64
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', '')

def verify_signature(payload, timestamp, signature, secret):
signed_content = f"{timestamp}.{payload.decode()}"
expected = base64.b64encode(
hmac.new(secret.encode(), signed_content.encode(), hashlib.sha256).digest()
).decode()
return hmac.compare_digest(signature, expected)

@app.route('/webhooks', methods=['POST'])
def handle_webhook():
webhook_id = request.headers.get('webhook-id')
webhook_timestamp = request.headers.get('webhook-timestamp')
webhook_signature = request.headers.get('webhook-signature')

if not all([webhook_id, webhook_timestamp, webhook_signature]):
return jsonify({'error': 'Missing headers'}), 401

if not verify_signature(request.data, webhook_timestamp, webhook_signature, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401

event = request.get_json()
print(f"Received event {webhook_id}: {event.get('type')}")
print(f"Invoice {event['data']['id']} was paid")

return jsonify({'received': True}), 200

if __name__ == '__main__':
app.run(port=3000)

Flask provides request.data for the raw body bytes, which we use for signature verification. The hmac.compare_digest function provides timing-safe comparison in Python.

Building a webhook receiver in Go

Go's standard library has everything you need to build a robust webhook receiver without external dependencies.

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)

var webhookSecret = os.Getenv("WEBHOOK_SECRET")

type WebhookEvent struct {
Type string `json:"type"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}

func verifySignature(payload []byte, timestamp, signature, secret string) bool {
signedContent := fmt.Sprintf("%s.%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedContent))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

webhookId := r.Header.Get("webhook-id")
webhookTimestamp := r.Header.Get("webhook-timestamp")
webhookSignature := r.Header.Get("webhook-signature")

if webhookId == "" || webhookTimestamp == "" || webhookSignature == "" {
http.Error(w, "Missing headers", http.StatusUnauthorized)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()

if !verifySignature(body, webhookTimestamp, webhookSignature, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}

var event WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

log.Printf("Received event %s: %s", webhookId, event.Type)
log.Printf("Invoice %s was paid", event.Data.ID)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func main() {
http.HandleFunc("/webhooks", webhookHandler)
log.Println("Webhook receiver listening on port 3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}

Go's hmac.Equal function handles timing-safe comparison. The standard library's HTTP server is production-ready and handles concurrent requests efficiently without additional frameworks.

Common patterns across all implementations

Each example follows the same structure: read the raw body first, verify the signature before doing anything else, parse the JSON only after verification passes, process the event, and respond immediately with a success status.

In production, you would add a few more pieces. Store processed event IDs (using the webhook-id header) to handle duplicate deliveries. Queue events for asynchronous processing instead of handling them inline. Add structured logging for debugging failed deliveries.

The signature verification step is critical. Without it, anyone who discovers your webhook URL could send fake events to your system. The shared secret between you and the webhook sender is what proves authenticity.

Testing your endpoint locally

Webhook senders need a publicly accessible URL, which makes local development tricky. Tools like ngrok or the Svix CLI can tunnel traffic to your local machine. Run your server locally, start the tunnel, and use the tunnel URL when registering your webhook endpoint with the sender. This lets you test the full flow without deploying anything.

Once your endpoint is working locally, deploy it to a server with a stable URL, update your webhook registration, and you are ready to receive events in production.