What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
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.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →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 Best Overall
- Register a narrow route. Use
net/httpwith a method-qualified pattern such asPOST /webhooks/matches. This pattern syntax requires Go 1.22 or later; the mux then rejects other methods with 405 without your code running. - Bound the body. Wrap
r.Bodywithhttp.MaxBytesReaderusing the provider’s documented maximum. - Read the raw bytes. Call
io.ReadAllbefore writing any response. Go’snet/httpdocumentation warns that reading a request body after the handler has written to theResponseWritermay not work reliably across clients, protocol versions and intermediaries. - Verify the signature over those exact bytes. Reject missing, malformed or mismatched signatures before anything else happens.
- Check the event type and action, then parse the verified bytes with
encoding/json. - Record the delivery in durable storage, keyed by the delivery identifier, and enqueue the work in the same transaction.
- 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:
Do these 3 things before closing this tab:
1Scan for outdated or missing drivers - takes under a minute2Clear out junk files and repair common Windows errors3Fix the driver behind crashes, sound loss and screen glitches- Compare with
hmac.Equal. Go’scrypto/hmacpackage provideshmac.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.
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.
Rank #4
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.
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:
“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.
Best Value
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.
Quick Recap
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.

