Give Pay Documentation
Webhooks

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
PartDescription
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_secret is the whsec_… value shown when you created the endpoint.
  • raw_request_body is 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:

  1. Read the X-GivePay-Signature header. Split on , to extract t and v1.
  2. (Recommended) Reject if |now - t| exceeds a tolerance window (5 minutes is a good default) — this defends against replay attacks.
  3. Recompute HMAC_SHA256("{t}.{raw_body}", signing_secret) using the endpoint's signing secret.
  4. Compare your computed value to v1 using a constant-time comparison.
  5. 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 "", 200

Go

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.