Guide

How to receive webhooks in Go minimal receiver → production-ready processing

Start with a minimal “native” receiver, but don’t stop there. In production, reliable webhook handling means verification, retries, idempotency, and backpressure — which is where Hooque simplifies everything.

Building for a specific provider? Browse provider webhook APIs.

TL;DR

  • Treat “receive webhooks in Go” as an ops problem, not just a route handler.
  • Verify the request before parsing/side effects (use a verifySignature(...) stub, then implement provider verification).
  • Return 2xx quickly; move work to a worker/queue to avoid timeouts and retries.
  • Assume retries and design idempotency (dedupe by event id + unique constraints).
  • Log + store raw payloads for replayable debugging.
  • If you need one workflow across many providers, centralize ingest + standardize consumption.

Deep dives: security, retries, queue migration.

Anti-patterns

  • Doing business logic inline in the Go request handler.
  • Parsing/transforming the body before verification (breaks signing inputs).
  • Returning 2xx before authenticity is proven.
  • Skipping idempotency (retries become double side effects).

If you’re triaging a live incident, use the debugging playbook .

Framework shortcuts

If you’re already using a framework, jump straight to the minimal framework receiver, then reuse the same production guidance and Hooque consumer loops.

  1. Gin
    How to receive webhooks in Gin
  2. Echo
    How to receive webhooks in Echo
  3. Chi
    How to receive webhooks in Chi

Why it's hard in production

A route handler is the easy part. Supporting multiple senders means multiple security models, spikes, and retry semantics.

Verify authenticity + stop replays

Use a verifySignature(...) stub here, then implement real verification + replay defense for each provider.

Read the guide

Assume retries (duplicates are optional)

Treat every delivery as at-least-once and make side effects idempotent (DB constraints, dedupe keys).

Read the guide

Don’t do work in the request path

Ack fast, process async. Otherwise timeouts, deploys, and spikes turn into missed webhooks.

Read the guide

Debug with real payloads

Save the exact body + headers so you can replay deterministically after a fix.

Read the guide

Add monitoring + alerts early

Track delivered vs rejected, processing latency, queue depth, and error rates.

Read the guide

Iterate locally without losing events

Tunnels help, but durable capture + replay removes the “my laptop was asleep” problem.

Read the guide

Minimal standard-library receiver (Go)

This is a minimal starting point. Keep verifySignature(...) as a stub here, then implement provider-specific verification and replay defense in the security guide .

// Go 1.22+ (net/http)
// Run: go run main.go
package main

import (
	"io"
	"log"
	"net/http"
)

func verifySignature(headers http.Header, body []byte) error {
	// don't compromise on security
	// TODO: implement provider-specific signature verification
	return nil
}

func processData(body []byte) error {
	// TODO: your business logic (DB writes, external API calls, etc.)
	return nil
}

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

	body, err := io.ReadAll(r.Body)
	if err != nil {
		log.Printf("Failed to read body: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if err := verifySignature(r.Header, body); err != nil {
		log.Printf("Signature verification failed: %v", err)
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	// What happens if it fails or times out?
	// Most providers retry -> duplicates unless you designed idempotency.
	if err := processData(body); err != nil {
		log.Printf("Failed to process data: %v", err)
	}

	// IMPORTANT: ack fast; do not do real work inline.
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))

	log.Printf("received webhook bytes=%d", len(body))
}

func main() {
	http.HandleFunc("/webhooks", handler)

	log.Println("Starting webhook server on :3000")
	if err := http.ListenAndServe(":3000", nil); err != nil {
		log.Fatalf("Server failed: %v", err)
	}
}

Hooque turns any webhook into a reliable queue.

Non-obvious scenario: you can’t expose a port

In real deployments, the hardest part is often “where does this endpoint run?” (NAT, corporate networks, locked-down environments, short-lived preview deployments). Hooque decouples inbound receiving from processing so your Go app doesn’t need to be the public receiver.

The easy path: receive with Hooque + consume forever

Hooque turns inbound webhooks into a durable queue. Your code becomes a run-forever worker that pulls or streams events and acks/nacks/rejects explicitly.

  • No need to run a public webhook endpoint in every environment (especially for local dev).
  • Durable capture + replay/inspection so “we missed the webhook” becomes debuggable.
  • Explicit Ack / Nack / Reject lifecycle so retries are under your control.
  • Backpressure and spike absorption: buffer now, process at your pace.
  • One consumption pattern across many senders (even if their security/retry rules differ).

Flow

  1. Provider delivers → Hooque ingest endpoint
  2. Hooque persists payload immediately
  3. Your worker pulls (REST) or streams (SSE)
  4. Your worker ack/nack/rejects explicitly

Hooque REST polling loop (runs forever)

Polling is a good default when you want a simple worker loop. It also works in environments where long-lived connections are unreliable.

// Go 1.22+ (net/http)
// Runs forever: poll /next, ack/nack/reject explicitly.
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

type Msg struct {
	Payload any
	Meta    map[string]any
}

func main() {
	nextURL := getenv("HOOQUE_QUEUE_NEXT_URL", "https://app.hooque.io/queues/<consumerId>/next")
	token := getenv("HOOQUE_TOKEN", "hq_tok_replace_me")

	client := &http.Client{Timeout: 30 * time.Second}
	ctx := context.Background()

	log.Println("Starting Hooque REST consumer...")

	for {
		msg, err := getNextMessage(ctx, client, nextURL, token)
		if err != nil {
			log.Printf("Worker connection error: %v", err)
			time.Sleep(2 * time.Second)
			continue
		}

		if msg == nil {
			time.Sleep(1 * time.Second)
			continue
		}

		if err := processData(msg.Payload, msg.Meta); err == nil {
			ack(ctx, client, msg, token)
		} else {
			nack(ctx, client, msg, token, err)
		}
	}
}

func processData(payload any, meta map[string]any) error {
	// Example real-life task: run a script on webhook events.
	msgID := "unknown"
	if id, ok := meta["messageId"]; ok {
		msgID = fmt.Sprintf("%v", id)
	}

	log.Printf("Processing event: %s", msgID)
	// Example: exec.Command("...").Run()
	return nil
}

func getNextMessage(ctx context.Context, client *http.Client, nextURL, token string) (*Msg, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+token)

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("next() fetch error: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNoContent {
		return nil, nil
	}

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("next() failed: status=%d body=%s", resp.StatusCode, string(bodyBytes))
	}

	metaRaw := resp.Header.Get("X-Hooque-Meta")
	if metaRaw == "" {
		metaRaw = "{}"
	}

	meta := make(map[string]any)
	if err := json.Unmarshal([]byte(metaRaw), &meta); err != nil {
		log.Printf("Failed to decode meta header: %v", err)
	}

	contentType := resp.Header.Get("Content-Type")

	// Keep it simple: treat as string; parse JSON if you know content-type is JSON.
	var payload any = string(bodyBytes)
	if strings.Contains(strings.ToLower(contentType), "json") {
		if err := json.Unmarshal(bodyBytes, &payload); err != nil {
			log.Printf("Failed to decode JSON payload: %v", err)
		}
	}

	return &Msg{Payload: payload, Meta: meta}, nil
}

func ack(ctx context.Context, client *http.Client, msg *Msg, token string) {
	if ackURL, ok := msg.Meta["ackUrl"].(string); ok {
		postAck(ctx, client, ackURL, token, "")
	}
}

func nack(ctx context.Context, client *http.Client, msg *Msg, token string, err error) {
	nackURL, ok := msg.Meta["nackUrl"].(string)
	if !ok {
		nackURL, _ = msg.Meta["rejectUrl"].(string)
	}

	if nackURL != "" {
		postAck(ctx, client, nackURL, token, err.Error())
	}
}

func postAck(ctx context.Context, client *http.Client, urlStr, token, reason string) {
	var body io.Reader
	if reason != "" {
		b, err := json.Marshal(map[string]string{"reason": reason})
		if err == nil {
			body = bytes.NewReader(b)
		}
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, body)
	if err != nil {
		log.Printf("Failed to create ack request: %v", err)
		return
	}

	req.Header.Set("Authorization", "Bearer "+token)
	if reason != "" {
		req.Header.Set("Content-Type", "application/json")
	}

	// asynchronously ack the message
	resp, err := client.Do(req)
	if err != nil {
		log.Printf("Ack request failed: %v", err)
		return
	}
	resp.Body.Close()
}

func getenv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

Hooque SSE stream consumer (runs forever)

SSE is great for low-latency processing: keep a connection open, process events as they arrive, and reconnect on disconnects.

// Go 1.22+ — SSE consumer (net/http)
// Runs forever: connect to /stream, handle "message" events, ack/nack/reject explicitly.
package main

import (
	"bufio"
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

type SseMsg struct {
	ContentType string         `json:"contentType"`
	Encoding    string         `json:"encoding"`
	Payload     string         `json:"payload"`
	Meta        map[string]any `json:"meta"`
}

type Msg struct {
	Payload string
	Meta    map[string]any
}

func main() {
	streamURL := getenv("HOOQUE_QUEUE_STREAM_URL", "https://app.hooque.io/queues/<consumerId>/stream")
	token := getenv("HOOQUE_TOKEN", "hq_tok_replace_me")

	client := &http.Client{Timeout: 0} // Stream connection, no timeout
	ctx := context.Background()

	log.Println("Starting Hooque SSE consumer...")

	msgChan := make(chan Msg)
	go getMessageStream(ctx, client, streamURL, token, msgChan)

	for msg := range msgChan {
		if err := processData(msg.Payload, msg.Meta); err == nil {
			ack(ctx, client, &msg, token)
		} else {
			nack(ctx, client, &msg, token, err)
		}
	}
}

func processData(payload any, meta map[string]any) error {
	log.Printf("Processing event: %v", meta["messageId"])
	return nil
}

func getMessageStream(ctx context.Context, client *http.Client, streamURL, token string, msgChan chan<- Msg) {
	for {
		if err := connectAndProcess(ctx, client, streamURL, token, msgChan); err != nil {
			log.Printf("Stream error: %v", err)
			time.Sleep(2 * time.Second)
		}
	}
}

func connectAndProcess(ctx context.Context, client *http.Client, streamURL, token string, msgChan chan<- Msg) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
	if err != nil {
		return fmt.Errorf("failed to create stream request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Accept", "text/event-stream")

	// connect stream, retry automatically on network errors
	resp, err := client.Do(req)
	if err != nil || resp.StatusCode >= 400 {
		if resp != nil && resp.Body != nil {
			resp.Body.Close()
		}
		return fmt.Errorf("request failed: %v", err)
	}
	defer resp.Body.Close()

	scanner := bufio.NewScanner(resp.Body)
	// Increase buffer size to handle larger payloads
	scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)

	event := ""
	var dataLines []string

	// defensive chunk reading to keep loop alive when stream drops
	for scanner.Scan() {
		line := strings.TrimRight(scanner.Text(), "\r")
		if strings.HasPrefix(line, ":") {
			continue
		}

		// Empty line marks the end of an event
		if line == "" {
			if event == "message" && len(dataLines) > 0 {
				var sseMsg SseMsg
				// safe parsing without crashing the stream loop
				if err := json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &sseMsg); err == nil {
					payload := decodePayload(sseMsg)
					msgChan <- Msg{Payload: payload, Meta: sseMsg.Meta}
				}
			}

			// Reset for next event
			event = ""
			dataLines = nil
			continue
		}

		if strings.HasPrefix(line, "event:") {
			event = strings.TrimSpace(line[6:])
			continue
		}

		if strings.HasPrefix(line, "data:") {
			dataLines = append(dataLines, strings.TrimSpace(line[5:]))
			continue
		}
	}

	if err := scanner.Err(); err != nil {
		return fmt.Errorf("scanner error: %w", err)
	}

	return nil
}

func decodePayload(msg SseMsg) string {
	raw := msg.Payload
	if msg.Encoding == "base64" {
		if b, err := base64.StdEncoding.DecodeString(raw); err == nil {
			raw = string(b)
		}
	}
	// For JSON the caller can parse
	return raw
}

func ack(ctx context.Context, client *http.Client, msg *Msg, token string) {
	if ackURL, ok := msg.Meta["ackUrl"].(string); ok {
		postAck(ctx, client, ackURL, token, nil)
	}
}

func nack(ctx context.Context, client *http.Client, msg *Msg, token string, err error) {
	nackURL, ok := msg.Meta["nackUrl"].(string)
	if !ok {
		nackURL, _ = msg.Meta["rejectUrl"].(string)
	}

	if nackURL != "" {
		b, _ := json.Marshal(map[string]string{"reason": err.Error()})
		postAck(ctx, client, nackURL, token, b)
	}
}

func postAck(ctx context.Context, client *http.Client, urlStr, token string, jsonBody []byte) {
	var body io.Reader
	if len(jsonBody) > 0 {
		body = bytes.NewReader(jsonBody)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, body)
	if err != nil {
		log.Printf("Failed to create post request: %v", err)
		return
	}

	req.Header.Set("Authorization", "Bearer "+token)
	if len(jsonBody) > 0 {
		req.Header.Set("Content-Type", "application/json")
	}

	resp, err := client.Do(req)
	if err != nil {
		log.Printf("Post request failed: %v", err)
		return
	}
	resp.Body.Close()
}

func getenv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

FAQ

Quick answers for the questions that come up right before you ship.

What status code should I return for webhooks in Go?

General: Usually return a fast 2xx after validating authenticity and basic schema. Timeouts and 5xx commonly trigger retries.

How Hooque helps: Hooque acknowledges ingest immediately and persists the payload. Your worker acks/nacks/rejects explicitly after processing.

Do I need signature verification in Go?

General: Yes, unless the sender is fully trusted and on a private network. A public endpoint without verification is easy to forge and easy to replay.

How Hooque helps: Hooque can verify at ingest for supported providers or using generic strategies. Either way, your worker receives a normalized meta object and can stay focused on processing.

Why do I see duplicate webhook events in Go?

General: Retries are normal: timeouts, transient network failures, and 5xx responses all produce duplicates. Design idempotency around event ids and side-effect boundaries.

How Hooque helps: Hooque makes delivery outcomes explicit (ack/nack/reject) and provides replay/inspection so you can fix issues without guessing what was received.

How do I test webhooks locally in Go?

General: You can use a tunnel, but local dev still breaks on sleep, VPNs, clock skew, and signature-byte mismatches.

How Hooque helps: With Hooque you can avoid inbound locally: receive events into a durable queue and pull/stream to your laptop, then replay from the UI after changes.

Should I use REST polling or SSE streaming for webhook processing?

General: Use REST polling for simple batch workers and environments without long-lived connections. Use SSE for low-latency “process as it arrives” flows.

How Hooque helps: Hooque supports both: `GET /next` for polling and `GET /stream` for streaming. Both include meta with ready-to-call ack/nack/reject URLs.

Start processing webhooks reliably

Create a webhook endpoint, receive events, then run your worker forever using REST polling or SSE streaming — with explicit ack/nack/reject control.

No credit card required