Go · Code recipe
Verify a webhook signature in Go
Prove a delivery came from URLpipe before you act on it, in Go 1.21+ with net/http from the standard library. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To verify a URLpipe webhook in Go, compute HMAC-SHA256 over the X-URLpipe-Timestamp value, a dot and the raw request body, keyed with your whsec_ secret. Prefix it with v1= and compare it with hmac.Equal against each comma-separated value of X-URLpipe-Signature; reject timestamps more than five minutes off.
Free plan, no credit card. 1,000 credits a month.
Your report_to URL accepts a POST from anyone who learns it. Turn on webhook signing for the project and every delivery carries X-URLpipe-Timestamp and X-URLpipe-Signature, so the receiver can prove the body came from URLpipe, unchanged, in the last five minutes.
The Go receiver below does the whole check: it reads the raw body (io.ReadAll(r.Body), before anything decodes it), recomputes the HMAC, compares it in constant time with hmac.Equal, and rejects stale timestamps. To send it a signed test delivery, use the shell script on the cURL page.
Setup
Before you start
Nothing to fetch: crypto/hmac and net/http are standard library. Turn signing on under Settings → Webhook Signing and copy the secret (it starts with whsec_).
export URLPIPE_WEBHOOK_SECRET="whsec_your_signing_secret"
Receiver
A receiver that verifies every delivery
verify is the part to copy into your service. Read r.Body with io.ReadAll and verify those bytes before you decode anything — decoding and re-encoding changes them. hmac.Equal is the constant-time compare.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
var secret = []byte(os.Getenv("URLPIPE_WEBHOOK_SECRET"))
// verify reports whether the delivery was signed with secret in the last five minutes.
func verify(body []byte, timestamp, signatureHeader string) bool {
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(seconds, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
// One signature normally, two during a secret rotation: accept any match.
for _, signature := range strings.Split(signatureHeader, ",") {
if hmac.Equal([]byte(strings.TrimSpace(signature)), expected) {
return true
}
}
return false
}
func webhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// The raw bytes, exactly as sent.
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if !verify(body, r.Header.Get("X-URLpipe-Timestamp"), r.Header.Get("X-URLpipe-Signature")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var delivery struct {
Token string `json:"token"`
}
if err := json.Unmarshal(body, &delivery); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
log.Printf("Verified delivery for %s", delivery.Token)
w.WriteHeader(http.StatusOK)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8000"
}
http.HandleFunc("/webhooks/urlpipe", webhook)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
Run it: go run main.go
Details
What to know about signed deliveries
- Signing is off until you turn it on under Settings → Webhook Signing; the secret starts with
whsec_. Enabling it is safe at any time — the body does not change — so enable it first and deploy the check after. - Rotating the secret opens a 24-hour window in which every delivery carries two signatures, the new one first. That is why the header is a list and any match is accepted.
- Each attempt is signed with a fresh timestamp, so a retry passes the five-minute check like the first delivery did.
- A delivery your endpoint rejects is retried with backoff, up to six attempts over roughly twenty minutes, and can be resent by hand from the dashboard afterwards.
- Make the handler idempotent on
token: the same result can arrive more than once.
Other languages
Verify a webhook signature in another language
More Go: every Go recipe · how signing works, in the docs
FAQ
Frequently asked questions
Why verify against the raw body?
Why can the signature header hold more than one value?
What should a receiver answer when the check fails?
Why a five-minute tolerance?
Get a key and run it.
Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.