Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
Written By: BrainSoft In Backend
Every webhook endpoint I have written eventually receives something it did not expect. A truncated JSON body. A signature computed over different bytes than the ones that arrived. A 40 MB payload from a provider that decided to batch a year of events into one POST. The endpoint that handles the happy path is easy; the one that survives the garbage is the one you actually have to design.
Validate the raw bytes before parsing, verify the signature against those bytes, cap the body size, and hand the work to a queue instead of doing it inline. Then return 2xx fast and let a worker deal with anything malformed. Here is how I build that endpoint, step by step.
The first step is the one people skip. If your web framework has already consumed and re-serialized the body, the signature will not match, and you will spend an afternoon blaming the provider. Grab the bytes first.
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
return
}
sig := r.Header.Get("X-Signature")
if !validHMAC(raw, sig, secret) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
The MaxBytesReader is doing real work here. Without it, a single bad actor or a misconfigured sender can push your process into swap before your validation code ever runs. One megabyte is generous for most event payloads; pick a number that matches what the provider actually sends and reject everything above it with a 413.
Verification should compare the HMAC in constant time and should never fall back to a default secret when the header is missing. If the signature header is absent, that is a rejection, not a warning. I have seen endpoints that logged a missing signature and continued anyway. That is an open door.
After verification, parse the body but do not trust its shape. Providers add fields, rename fields, and occasionally send a string where you expected a number. Decode into a struct with optional fields, or decode into a map and pull out what you need with explicit checks. Then write the raw bytes to durable storage along with a generated id, and return 200. The request is over; the work is not.
INSERT INTO webhook_inbox (id, source, received_at, raw_body)
VALUES ($1, $2, now(), $3)
ON CONFLICT (id) DO NOTHING;
That ON CONFLICT clause is your idempotency guard for the common case where the provider retries a delivery you already accepted. The unique id comes from the provider when they send one, or from a hash of the body plus timestamp when they do not. Either way, the second insert is a no-op and the worker will not process the same event twice.
The worker reads from the inbox table, attempts the real work, and on failure retries with backoff a bounded number of times before moving the row to a dead-letter table. Malformed payloads end up there too, tagged with the parse error. That gives you a queue of things to look at instead of a stream of 500s that the provider eventually disables.
Logging deserves a note. Log the event id, the source, the signature result, and the parse outcome. Do not log the full body at info level, and never log the secret or the computed signature. When something goes wrong at 3am, the event id is what you search for. The body is in the inbox table if you need it.
If you would rather hand this off than build it, this is the kind of thing we do for clients through our services, usually as part of an integration project.
Almost always because the body was parsed and re-serialized before you hashed it. Whitespace, key order, and number formatting all change the bytes. Read the raw body first, verify against those exact bytes, and only then parse.
If the signature is valid but the contents are garbage, return 200 and store it for inspection. Returning 4xx tells the provider to retry, and retrying a payload that will never parse just wastes both sides. Reject only on signature or size failures.
Use a unique constraint on the event id or a hash of the body, and insert with ON CONFLICT DO NOTHING. The worker then only ever sees one row per logical event, no matter how many times the delivery arrives.