Signatures
Verify webhook authenticity with HMAC-SHA256
Why verify?
Your webhook receiver is a public endpoint — anyone on the internet can POST JSON to it. Verifying the X-GivePay-Signature header proves the request came from GivePay and hasn't been tampered with.
Reject any request whose signature does not match. Do this before parsing the body or doing any work.
The signature header
Every webhook includes an X-GivePay-Signature header with two comma-separated parts:
X-GivePay-Signature: t=1715425696,v1=a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789| Part | Description |
|---|---|
t=<unix_timestamp> | Seconds since epoch when the signature was generated |
v1=<hex_signature> | HMAC-SHA256 of the signed payload, hex-encoded |
How it's computed
signed_payload = "{timestamp}.{raw_request_body}"
signature = HMAC_SHA256(signed_payload, signing_secret)signing_secretis thewhsec_…value shown when you created the endpoint.raw_request_bodyis the exact bytes of the body — don't re-serialize the JSON; many serializers reorder keys or add/strip whitespace, which will break the HMAC.
Verification steps
For every incoming webhook:
- Read the
X-GivePay-Signatureheader. Split on,to extracttandv1. - (Recommended) Reject if
|now - t|exceeds a tolerance window (5 minutes is a good default) — this defends against replay attacks. - Recompute
HMAC_SHA256("{t}.{raw_body}", signing_secret)using the endpoint's signing secret. - Compare your computed value to
v1using a constant-time comparison. - If they match, accept and process the event. Otherwise, return
401.
Use a constant-time comparison. Functions like == or === can leak information through timing side channels. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hmac.Equal (Go), or hash_equals (PHP).
Code samples
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.GIVEPAY_WEBHOOK_SECRET; // "whsec_..."
// IMPORTANT: capture the raw body before JSON parsing
app.post(
'/webhooks/givepay',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-GivePay-Signature') || '';
const match = header.match(/^t=(\d+),v1=([0-9a-f]+)$/);
if (!match) return res.status(400).send('bad signature header');
const [, timestamp, signature] = match;
// 5-minute replay tolerance
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > 300) return res.status(400).send('signature too old');
const signedPayload = `${timestamp}.${req.body.toString('utf8')}`;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// TODO: enqueue background work, then respond
res.status(200).send('ok');
}
);Python (Flask)
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["GIVEPAY_WEBHOOK_SECRET"]
TOLERANCE = 300 # seconds
@app.route("/webhooks/givepay", methods=["POST"])
def receive():
header = request.headers.get("X-GivePay-Signature", "")
try:
ts_part, sig_part = header.split(",")
timestamp = int(ts_part.removeprefix("t="))
signature = sig_part.removeprefix("v1=")
except (ValueError, AttributeError):
abort(400, "bad signature header")
if abs(time.time() - timestamp) > TOLERANCE:
abort(400, "signature too old")
raw_body = request.get_data() # bytes, before any parsing
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, "invalid signature")
event = request.get_json(force=True)
# TODO: enqueue background work
return "", 200Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var webhookSecret = []byte(os.Getenv("GIVEPAY_WEBHOOK_SECRET"))
func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
header := r.Header.Get("X-GivePay-Signature")
var ts int64
var sig string
if _, err := fmt.Sscanf(header, "t=%d,v1=%s", &ts, &sig); err != nil {
http.Error(w, "bad signature header", http.StatusBadRequest)
return
}
_ = strconv.Itoa // appease imports
if d := time.Since(time.Unix(ts, 0)); d < -5*time.Minute || d > 5*time.Minute {
http.Error(w, "signature too old", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, webhookSecret)
mac.Write([]byte(fmt.Sprintf("%d.%s", ts, string(body))))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
_ = strings.TrimSpace
// TODO: parse body, enqueue background work
w.WriteHeader(http.StatusOK)
}PHP
<?php
$secret = getenv('GIVEPAY_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_GIVEPAY_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([0-9a-f]+)$/', $header, $m)) {
http_response_code(400);
exit('bad signature header');
}
[, $timestamp, $signature] = $m;
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
exit('signature too old');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($body, true);
// TODO: process $event, then respond
http_response_code(200);Rotating your signing secret
If you suspect your secret has leaked, rotate it from the GivePay dashboard. A fresh whsec_… is issued and the old secret is invalidated immediately — update your receiver at the same time.