What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

A Go webhook receiver for match events has five jobs: accept only the expected request, read the raw request body, verify the sender’s signature with a constant-time comparison, record a stable delivery ID so redeliveries are harmless, and return a 2xx response before the sender gives up. This guide does not assume a particular match-data provider. The code uses the webhook contract that GitHub publishes as a worked example, because it is documented in detail. Your provider’s own documentation decides the final header names, signed bytes, event fields, payload limit and retry behavior.

Confirm the provider contract before writing code

A receiver can only be compatible with a sender after you have read that sender’s webhook documentation. Collect these details first and write them down, because each one changes a line of the handler:

  • Signature header and algorithm. The header name, the hash function, the encoding of the digest and any prefix such as sha256=.
  • Signed bytes. Whether the signature covers the raw payload only, or payload plus a timestamp or other fields.
  • Event type and action. The header or body field that names the event, and the field that names what happened within it.
  • Delivery identifier. A header or field that stays the same when the sender redelivers the same event.
  • Maximum payload size. The documented cap, which sets your body limit.
  • Response expectations. Which status codes count as success, the timeout window, and whether a non-2xx response triggers a retry.
  • Retry and ordering rules. How long retries continue, and whether events arrive in order. Do not assume ordering unless the provider guarantees it.

GitHub’s guidance on webhook practice is at Best practices for using webhooks. Treat it as a model for what to look for, not as the contract for a match-data sender.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Build the handler in the right order

The order of operations matters more than the framework. Each step below should finish before the next one begins:

  1. Register a narrow route. Use net/http with a method-qualified pattern such as POST /webhooks/matches. This pattern syntax requires Go 1.22 or later; the mux then rejects other methods with 405 without your code running.
  2. Bound the body. Wrap r.Body with http.MaxBytesReader using the provider’s documented maximum.
  3. Read the raw bytes. Call io.ReadAll before writing any response. Go’s net/http documentation warns that reading a request body after the handler has written to the ResponseWriter may not work reliably across clients, protocol versions and intermediaries.
  4. Verify the signature over those exact bytes. Reject missing, malformed or mismatched signatures before anything else happens.
  5. Check the event type and action, then parse the verified bytes with encoding/json.
  6. Record the delivery in durable storage, keyed by the delivery identifier, and enqueue the work in the same transaction.
  7. Return a 2xx once that transaction commits.

The complete handler

This example targets PostgreSQL-style SQL and the standard database/sql package. Replace the event names and header names with the values from your provider’s contract. The secret comes from the environment, so it never appears in source control.

package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
)

const maxPayloadBytes = 1 << 20 // set to the sender's documented maximum

// Only these event/action pairs are processed. Other deliveries get a 2xx and are ignored.
var wantedActions = map[string]map[string]bool{
	"match": {"completed": true, "updated": true},
}

type receiver struct {
	secret []byte
	db     *sql.DB
}

func (rc *receiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, maxPayloadBytes)
	body, err := io.ReadAll(r.Body)
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}

	if !validSignature(rc.secret, body, r.Header.Get("X-Hub-Signature-256")) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	deliveryID := r.Header.Get("X-GitHub-Delivery")
	eventType := r.Header.Get("X-GitHub-Event")
	if deliveryID == "" || eventType == "" {
		http.Error(w, "missing delivery headers", http.StatusBadRequest)
		return
	}

	action, err := actionOf(body)
	if err != nil {
		http.Error(w, "malformed JSON", http.StatusBadRequest)
		return
	}
	if !wantedActions[eventType][action] {
		w.WriteHeader(http.StatusNoContent)
		return
	}

	inserted, err := enqueueOnce(r.Context(), rc.db, deliveryID, eventType, body)
	if err != nil {
		log.Printf("record delivery %s: %v", deliveryID, err)
		http.Error(w, "temporary failure", http.StatusInternalServerError)
		return
	}
	if !inserted {
		w.WriteHeader(http.StatusOK) // already accepted earlier
		return
	}
	w.WriteHeader(http.StatusAccepted)
}

func validSignature(secret, body []byte, header string) bool {
	const prefix = "sha256="
	if !strings.HasPrefix(header, prefix) {
		return false
	}
	got, err := hex.DecodeString(strings.TrimPrefix(header, prefix))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write(body)
	return hmac.Equal(got, mac.Sum(nil))
}

func actionOf(body []byte) (string, error) {
	var p struct {
		Action string `json:"action"`
	}
	err := json.Unmarshal(body, &p)
	return p.Action, err
}

func enqueueOnce(ctx context.Context, db *sql.DB, deliveryID, eventType string, body []byte) (bool, error) {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return false, err
	}
	defer tx.Rollback()

	res, err := tx.ExecContext(ctx,
		`INSERT INTO webhook_deliveries (delivery_id, event_type, status)
		 VALUES ($1, $2, 'queued')
		 ON CONFLICT (delivery_id) DO NOTHING`,
		deliveryID, eventType)
	if err != nil {
		return false, err
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, err
	}
	if n == 0 {
		return false, nil // duplicate delivery
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO webhook_jobs (delivery_id, payload) VALUES ($1, $2)`,
		deliveryID, body); err != nil {
		return false, err
	}
	return true, tx.Commit()
}

func main() {
	secret := os.Getenv("MATCH_WEBHOOK_SECRET")
	if secret == "" {
		log.Fatal("MATCH_WEBHOOK_SECRET is not set")
	}
	db := mustOpenDB() // your PostgreSQL pool setup
	mux := http.NewServeMux()
	mux.Handle("POST /webhooks/matches", &receiver{secret: []byte(secret), db: db})
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Two details in this handler are deliberate. The body is parsed with json.Unmarshal only after the signature check has passed, and the original byte slice is what gets stored. The receiver never decodes and re-encodes the JSON before verifying it, because re-encoding changes whitespace, key order and number formatting, and the signature will no longer match.

Verify the signature over the raw payload

In the GitHub contract, the digest is an HMAC-SHA256 computed with the webhook secret over the payload, sent hex-encoded in X-Hub-Signature-256 with a sha256= prefix. The validSignature function above implements that check. Three points matter:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Compare with hmac.Equal. Go’s crypto/hmac package provides hmac.Equal, which compares two MACs in constant time. GitHub also says to avoid ordinary string comparison for this check. The standard library covers the pattern, so a third-party cryptography package is not required for an HMAC-SHA256 check of this kind.
  • Verify before any side effect. A missing or malformed header should fail the same way as a wrong one, and none of them should write to the database or queue.
  • Keep the secret out of code. GitHub advises generating a high-entropy secret, storing it securely and never hardcoding or committing it. The handler reads it from MATCH_WEBHOOK_SECRET, and the process refuses to start without it.

Test the check locally with openssl and curl

You can exercise the verification path without the sender. Save a sample payload to payload.json, then compute the expected header value:

SIG=$(openssl dgst -sha256 -hmac "$MATCH_WEBHOOK_SECRET" payload.json | awk '{print $NF}')

curl -i -X POST http://localhost:8080/webhooks/matches 
  -H "Content-Type: application/json" 
  -H "X-GitHub-Event: match" 
  -H "X-GitHub-Delivery: test-delivery-0001" 
  -H "X-Hub-Signature-256: sha256=$SIG" 
  --data-binary @payload.json

Expected results: the first request returns 202 Accepted. Sending the same request again returns 200 OK and creates no new job. Changing one byte in payload.json without recomputing the signature returns 401 Unauthorized. Use --data-binary rather than -d, because -d strips newlines from files and would change the signed bytes.

Check the event type and action

Subscribe the sender only to the events you need, and filter again in the receiver. GitHub places the event name in X-GitHub-Event and may place the action in the payload, but other providers may use different header names or put both values in the body. The wantedActions map in the handler is the single place to change that mapping. Unwanted events receive a 2xx, so the sender does not record a failure for something you chose to ignore.

Deduplicate with a durable delivery record

A sender may deliver the same event more than once. GitHub uses X-GitHub-Delivery as the delivery identifier, and a redelivery carries the original value. Use that identifier, or the provider’s equivalent, as a unique key in durable storage.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Why the record and the job belong in one transaction

The check-then-process approach fails in two ways. If you enqueue the job and then insert the delivery row, a crash between the two steps can leave work without a record, so a retry processes it again. If you insert the row and then crash before enqueueing, the retry sees a duplicate and the event is silently lost. Writing both rows in one transaction, as enqueueOnce does, removes both gaps: either the record and the job both exist, or neither does.

The schema the handler assumes looks like this:

CREATE TABLE webhook_deliveries (
    delivery_id TEXT PRIMARY KEY,
    event_type  TEXT NOT NULL,
    status      TEXT NOT NULL,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE webhook_jobs (
    id          BIGSERIAL PRIMARY KEY,
    delivery_id TEXT NOT NULL REFERENCES webhook_deliveries (delivery_id),
    payload     BYTEA NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

The primary key does the duplicate detection. Without it, the ON CONFLICT clause has nothing to match, and duplicates pass through.

A worker reads from webhook_jobs, performs the match-event side effects, and marks the delivery as done. Those effects must be idempotent as well, because a worker can crash after its side effect and before its status update. Making the worker’s writes idempotent is a property of your business logic and storage, so the exact design depends on what the event changes.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Acknowledge within the sender’s window

A 2xx response tells the sender that the event is safe with you, so it should be sent only after the event is durable. GitHub’s documentation states the target directly:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

“Your server should respond with a 2XX response within 10 seconds of receiving a webhook delivery.” (Best practices for using webhooks, GitHub Docs, checked in 2026.) GitHub terminates slower connections and counts the delivery as failed. Your provider’s window may differ, so read its value from its documentation.

The two common designs trade off as follows:

Axis Process inline before responding Record and enqueue, then process asynchronously
Acknowledgment latency Grows with the time the side effects take Covers one transaction and no match-event processing
Durability before acknowledgment Only if the work commits before the response is written The event is stored before the 2xx is sent
Behavior on timeout A slow response can trigger a retry after side effects have already run Retries reach the duplicate check; worker failures are retried from the job table
Idempotency requirement Required Still required, because a sender retries if the acknowledgment is lost
Operational complexity One process Adds a job table or queue, a worker process and monitoring for backlog

Choose inline processing only when the side effects are fast and reliable enough to finish well inside the window. For match events that touch several downstream systems, the asynchronous design is the safer default.

Secure the transport and the network path

Serve the endpoint over HTTPS and keep certificate verification enabled on the sender’s side. GitHub recommends the same. GitHub also documents IP allowlisting as an option. Its published IP addresses change, so an allowlist must be refreshed periodically. These controls supplement the signature check and never replace it, since an allowlist alone does not prove a request came from the sender’s application.

Troubleshoot the common failures

Symptom Likely cause Fix
Every delivery returns 401 The body was re-encoded or altered before verification, the secret differs from the sender’s, or the sha256= prefix is missing Verify the raw bytes from io.ReadAll. Check that the secret has no trailing newline, which is common when it is read from a file. Confirm the header prefix.
Valid deliveries return 413 The limit is set below the sender’s documented maximum Raise maxPayloadBytes to the documented cap, not to an arbitrary larger value.
The same event is processed twice The delivery table has no unique key on the identifier, or the row and job are written in separate transactions Add the primary key shown above and write both rows in one transaction.
The sender reports timeouts Side effects run before the response is written Move the work behind the job table and return after the commit.
Accepted events are missing after a restart The 2xx was sent before the record was durable, for example into an in-memory queue Persist the delivery before returning any 2xx.
Duplicates arrive after a successful response The acknowledgment was lost in transit or arrived after the sender’s timeout This is expected behavior. The duplicate check returns 200 without creating a job.

When a symptom does not match any row, log the delivery identifier, event type, byte length and the first few characters of the signature header for each request. Those four values are enough to match a failing delivery to the sender’s own delivery log.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.