How to Implement Webhook Handlers in Go

Web
Implement Go webhook handlers by creating an HTTP endpoint that validates signatures and parses JSON payloads securely.

The webhook handshake

You're building a service that reacts to events from another platform. A user signs up on Stripe. A pull request opens on GitHub. A message arrives in Slack. These services don't call your API directly. They push data to an endpoint you expose. That endpoint is a webhook handler.

The challenge isn't just reading JSON. It's proving the request actually came from the provider and not a script kiddie guessing your URL. Webhooks are the internet's way of saying "something happened, here's the data, deal with it." Your job is to verify the sender, parse the payload, and respond quickly so the provider doesn't retry. If you respond slowly or with an error, the provider assumes your server is down and will hammer your endpoint with retries until you acknowledge the event.

How signature verification works

Webhook security relies on a shared secret. When you set up the webhook in the provider's dashboard, you generate a secret key. You keep that key in your server's environment variables. The provider keeps a copy.

Every time the provider sends a webhook, it calculates a cryptographic signature over the request body using that secret. It attaches the signature to the HTTP headers. Your server receives the body and the signature. You calculate the signature yourself using the same secret and the received body. If your calculated signature matches the one in the header, the body hasn't been tampered with, and the request came from someone who knows the secret.

Think of it like a sealed letter with a wax stamp. You check the stamp before you read the letter. If the stamp is broken or doesn't match the seal you recognize, you burn the letter. You never read unverified data.

The standard algorithm is HMAC-SHA256. HMAC stands for Hash-based Message Authentication Code. SHA-256 is the hash function. The result is a 64-character hexadecimal string. Most providers prefix it with sha256= to indicate the algorithm used.

Minimal handler

Here's the simplest webhook handler. It reads the body, checks the signature, and returns a status code.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
)

// WebhookHandler validates the signature and returns a success response.
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
	// Read the entire body first. r.Body is a stream that can only be read once.
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read body", http.StatusBadRequest)
		return
	}

	// Get the signature from the header. Providers use different header names.
	signature := r.Header.Get("X-Signature")
	if signature == "" {
		http.Error(w, "Missing signature", http.StatusUnauthorized)
		return
	}

	// Compute the expected HMAC-SHA256 signature using the shared secret.
	secret := []byte("your-secret-key")
	mac := hmac.New(sha256.New, secret)
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

	// Compare signatures using constant-time comparison to prevent timing attacks.
	if !hmac.Equal([]byte(signature), []byte(expected)) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	// Signature is valid. Process the body.
	fmt.Println("Valid webhook received:", string(body))
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhook", WebhookHandler)
	http.ListenAndServe(":8080", nil)
}

Walkthrough

The handler does three things in a strict order. It reads the body. It verifies the signature. It processes the payload.

Reading the body happens first. r.Body is an io.ReadCloser. It's a stream. Once you read from it, the internal cursor moves to the end. If you try to read it again, you get nothing. This is why we call io.ReadAll immediately and store the result in a []byte variable. You can reuse that byte slice as many times as you need. If you skip this step and pass r.Body to a JSON parser, you won't have the raw bytes available for the signature check.

Signature verification uses hmac.New and sha256.New. You create a new MAC instance with your secret key. You write the body bytes to it. You call Sum(nil) to get the final hash. The nil argument means "append the sum to an empty slice." The result is a byte slice. You encode it to hex and add the sha256= prefix to match the provider's format.

The comparison uses hmac.Equal. This is critical. You cannot use the == operator to compare signatures. The == operator returns false as soon as it finds a mismatched byte. An attacker can measure the response time of your server. If the response is slightly slower when the first byte is correct, they know the first byte. They can guess the signature one byte at a time. hmac.Equal takes the same amount of time regardless of where the mismatch occurs. It prevents timing side-channel attacks.

If the signature is valid, you process the body. In this minimal example, we just print it. In real code, you'd parse JSON and update your database.

Read the body once. Verify before you parse. Trust nothing.

Realistic handler

Real webhooks carry structured data. You need to parse JSON. You need to handle errors. You need to log rejections.

Here's a handler that parses an event struct. It separates verification from processing. It logs errors for debugging.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
)

// Event represents the structure of the incoming webhook payload.
type Event struct {
	ID      string `json:"id"`
	Type    string `json:"type"`
	Created int64  `json:"created_at"`
}

// HandleWebhook processes a verified webhook payload.
func HandleWebhook(w http.ResponseWriter, r *http.Request) {
	// Read body once. Reusing r.Body after this will return an error.
	body, err := io.ReadAll(r.Body)
	if err != nil {
		log.Printf("read body error: %v", err)
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// Verify signature before parsing. Never trust unverified data.
	if !verifySignature(r.Header.Get("X-Signature"), body) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	// Parse JSON only after verification passes.
	var event Event
	if err := json.Unmarshal(body, &event); err != nil {
		log.Printf("json parse error: %v", err)
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Process the event. This is where you'd call your business logic.
	processEvent(event)
	w.WriteHeader(http.StatusOK)
}

// verifySignature checks the HMAC-SHA256 signature against the body.
func verifySignature(signature string, body []byte) bool {
	if signature == "" {
		return false
	}
	secret := []byte("your-secret-key")
	mac := hmac.New(sha256.New, secret)
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	// hmac.Equal prevents timing side-channel attacks.
	return hmac.Equal([]byte(signature), []byte(expected))
}

// processEvent handles the business logic for a verified event.
func processEvent(event Event) {
	log.Printf("Processing event %s of type %s", event.ID, event.Type)
}

The Event struct uses JSON tags. Go struct fields are exported, which means they start with a capital letter. JSON keys are often lowercase or snake_case. The tags bridge the gap. json:"id" tells the parser to map the id key in the JSON to the ID field in the struct.

Error handling follows the Go convention. if err != nil checks the error immediately. The compiler won't force you to handle errors, but ignoring them leads to bugs. If json.Unmarshal fails, the event struct contains zero values. Your code might proceed with empty data. The error message from the compiler or runtime helps you debug. If you pass the wrong type, you get json: cannot unmarshal object into Go value of type string. If the JSON is malformed, you get invalid character 'x' looking for beginning of object key string. Log these errors. They tell you why a webhook failed.

The verifySignature function is extracted for clarity. It returns a boolean. This keeps the handler focused on HTTP mechanics. The verification logic is pure. It takes input and returns a result. This makes it easy to test.

The processEvent function handles business logic. In a real app, this might insert a row into a database or send a message to a queue. Keep this function fast. If it takes too long, the HTTP request hangs. The provider might timeout and retry.

Parse only what you verified. Log what you reject.

Pitfalls and conventions

Webhook handlers have subtle traps. The most common is body reuse. If you pass r to a helper function that reads the body, the main handler gets an empty body. Always read the body into a variable first. Or wrap it in io.NopCloser(bytes.NewReader(body)) if you need to pass a stream.

Signature formats vary. Some providers send sha256=.... Some send just the hex string. Some use X-Hub-Signature-256. Some use X-Signature-Ed25519. Check the provider's documentation. Hardcoding the prefix or algorithm breaks when the provider updates their security.

Timing attacks are rare but real. Always use hmac.Equal. Never use == or strings.EqualFold for cryptographic comparisons.

Go conventions apply here too. The receiver name in methods should be one or two letters matching the type. (h *Handler) ServeHTTP(...) is correct. (this *Handler) is not. Public names start with a capital letter. Private start lowercase. No keywords like public or private.

Error handling is verbose by design. if err != nil { return err } makes the unhappy path visible. Don't try to hide errors. Return them. Log them. The community accepts the boilerplate because it prevents silent failures.

If your handler spawns a goroutine to do work, pass context.Context as the first argument. func processEvent(ctx context.Context, event Event). Context carries deadlines and cancellation signals. If the server shuts down, you can cancel long-running tasks. Context is plumbing. Run it through every long-lived call site.

The worst webhook bug is the one that silently drops events because the body was already read.

When to use webhooks

Webhooks are a push mechanism. The provider initiates the request. You react. This is different from polling, where you repeatedly ask the provider for updates. Webhooks are more efficient. They deliver events instantly. They don't waste bandwidth checking for nothing.

Use a direct HTTP handler when the processing is fast and idempotent, like updating a database row or caching a value. Idempotent means you can run the operation multiple times with the same result. Webhooks might retry. Your handler must handle duplicates.

Use a message queue when the webhook triggers heavy work, like generating a PDF or sending an email. The HTTP request should return quickly. Push the event to a queue. A worker process picks it up and does the work. This decouples the webhook handler from the business logic. It protects your server from slow operations.

Use a worker pool when you need bounded concurrency. If many webhooks arrive at once, you don't want to spawn a goroutine for each one. A pool limits the number of concurrent workers. It prevents resource exhaustion.

Use a retry mechanism when the downstream service might be temporarily unavailable. If your database is down, the webhook handler should fail gracefully. The provider will retry. Don't implement your own retry logic for the provider. Let them handle it. Implement retries for your internal calls.

Webhooks are fire-and-forget from the sender's perspective. Make your handler idempotent.

Where to go next