PayFence

Technical Reference

HMAC Verification Spec

How to verify that requests forwarded to your origin in proxy mode were signed by PayFence.

Overview

When PayFence operates in proxy mode, every request forwarded to your origin carries an HMAC-SHA256 signature. Your origin must verify this signature before processing the request. If the signature is missing, expired, or invalid, your origin should reject the request with a 401 Unauthorized response.

The shared secret used for signing is available in your dashboard under site settings. It starts with whsec_ and should be stored securely as an environment variable on your server. Never expose it in client-side code or version control.

Request Headers

PayFence adds the following headers to every proxied request:

X-PayFence-Signature
The HMAC-SHA256 signature, prefixed with v1=. Example: v1=a3f1b9c7...
X-PayFence-Timestamp
Unix epoch timestamp (seconds) when the signature was generated.
X-PayFence-Request-Id
A unique identifier for the request, used in the canonical string to prevent replay attacks.
X-PayFence-Site
The site slug this request is associated with. Useful if your origin serves multiple PayFence sites.

Canonical String Format

The signature is computed over a canonical string built from five components separated by newline characters (\n):

canonical format
method\npath\ntimestamp\nrequestId\nbodyHash
method
The HTTP method in uppercase: GET, POST, PUT, etc.
path
The request path without query string. For example: /v1/flights
timestamp
The value from the X-PayFence-Timestamp header (Unix epoch seconds).
requestId
The value from the X-PayFence-Request-Id header.
bodyHash
SHA-256 hex digest of the raw request body. If the request has no body (e.g., a GET request), hash the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Algorithm

The signature algorithm is HMAC-SHA256. The HMAC is keyed with your shared secret (whsec_...) and computed over the canonical string. The resulting hex digest is prefixed with v1= and sent in the X-PayFence-Signature header.

signature format
X-PayFence-Signature: v1={hex(HMAC-SHA256(secret, canonical))}

Replay Protection

The timestamp in the canonical string prevents replay attacks. Your origin should reject any request where the timestamp is more than 5 minutes (300 seconds) from the current server time. This window accounts for reasonable clock skew between PayFence servers and your origin.

Additionally, the X-PayFence-Request-Id is included in the canonical string to ensure that each signature is unique per request, even if two requests have the same method, path, and body within the same timestamp.

Verification Steps

Follow these steps in your origin server to verify each request:

  1. 1

    Extract the X-PayFence-Signature, X-PayFence-Timestamp, and X-PayFence-Request-Id headers. Reject the request if any are missing.

  2. 2

    Check that the timestamp is within 5 minutes (300 seconds) of the current server time. Reject if expired.

  3. 3

    Compute the SHA-256 hash of the raw request body (or empty string for bodiless requests).

  4. 4

    Build the canonical string: method\npath\ntimestamp\nrequestId\nbodyHash

  5. 5

    Compute the HMAC-SHA256 of the canonical string using your shared secret.

  6. 6

    Compare the computed signature with the signature from the header using a timing-safe comparison function. Reject if they do not match.

Important: Always use a timing-safe comparison function (e.g., crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hmac.Equal in Go) to prevent timing attacks.

Node.js (Express)

Complete middleware using the built-in crypto module. Make sure your Express app preserves the raw body (e.g., via express.json({ verify: (req, _res, buf) => req.rawBody = buf })).

verify-payfence.js
const crypto = require("crypto");

/**
 * Express middleware to verify PayFence HMAC signatures.
 * Expects raw body to be available as req.rawBody (Buffer).
 */
function verifyPayFence(secret) {
  return (req, res, next) => {
    const signature = req.headers["x-payfence-signature"];
    const timestamp = req.headers["x-payfence-timestamp"];
    const requestId = req.headers["x-payfence-request-id"];

    if (!signature || !timestamp || !requestId) {
      return res.status(401).json({ error: "Missing PayFence headers" });
    }

    // 1. Check replay window (5 minutes)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - Number(timestamp)) > 300) {
      return res.status(401).json({ error: "Request timestamp expired" });
    }

    // 2. Build the canonical string
    const bodyHash = crypto
      .createHash("sha256")
      .update(req.rawBody || "")
      .digest("hex");

    const canonical = [
      req.method.toUpperCase(),
      req.path,
      timestamp,
      requestId,
      bodyHash,
    ].join("\n");

    // 3. Compute expected HMAC
    const expected = crypto
      .createHmac("sha256", secret)
      .update(canonical)
      .digest("hex");

    // 4. Timing-safe comparison
    const sig = signature.replace("v1=", "");
    if (
      sig.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    ) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    next();
  };
}

// Usage with Express
// app.use(verifyPayFence(process.env.PAYFENCE_WEBHOOK_SECRET));

Python (Flask)

Decorator using the standard library hmac and hashlib modules.

verify_payfence.py
import hashlib
import hmac
import time
from functools import wraps
from flask import request, jsonify

def verify_payfence(secret: str):
    """Flask decorator to verify PayFence HMAC signatures."""
    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            signature = request.headers.get("X-PayFence-Signature", "")
            timestamp = request.headers.get("X-PayFence-Timestamp", "")
            request_id = request.headers.get("X-PayFence-Request-Id", "")

            if not signature or not timestamp or not request_id:
                return jsonify({"error": "Missing PayFence headers"}), 401

            # 1. Check replay window (5 minutes)
            now = int(time.time())
            if abs(now - int(timestamp)) > 300:
                return jsonify({"error": "Request timestamp expired"}), 401

            # 2. Build the canonical string
            body = request.get_data(as_text=False)
            body_hash = hashlib.sha256(body).hexdigest()

            canonical = "\n".join([
                request.method.upper(),
                request.path,
                timestamp,
                request_id,
                body_hash,
            ])

            # 3. Compute expected HMAC
            expected = hmac.new(
                secret.encode(),
                canonical.encode(),
                hashlib.sha256,
            ).hexdigest()

            # 4. Timing-safe comparison
            sig = signature.removeprefix("v1=")
            if not hmac.compare_digest(sig, expected):
                return jsonify({"error": "Invalid signature"}), 401

            return f(*args, **kwargs)
        return wrapper
    return decorator

# Usage with Flask
# @app.route("/api/resource")
# @verify_payfence(os.environ["PAYFENCE_WEBHOOK_SECRET"])
# def my_endpoint():
#     return jsonify({"ok": True})

Go

Standard net/http middleware using crypto/hmac and crypto/sha256.

middleware.go
package middleware

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// VerifyPayFence returns HTTP middleware that validates PayFence
// HMAC-SHA256 request signatures.
func VerifyPayFence(secret string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			signature := r.Header.Get("X-PayFence-Signature")
			timestamp := r.Header.Get("X-PayFence-Timestamp")
			requestID := r.Header.Get("X-PayFence-Request-Id")

			if signature == "" || timestamp == "" || requestID == "" {
				http.Error(w, "Missing PayFence headers", http.StatusUnauthorized)
				return
			}

			// 1. Check replay window (5 minutes)
			ts, err := strconv.ParseInt(timestamp, 10, 64)
			if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
				http.Error(w, "Request timestamp expired", http.StatusUnauthorized)
				return
			}

			// 2. Build the canonical string
			body, err := io.ReadAll(r.Body)
			if err != nil {
				http.Error(w, "Failed to read body", http.StatusInternalServerError)
				return
			}
			bodyHashBytes := sha256.Sum256(body)
			bodyHash := hex.EncodeToString(bodyHashBytes[:])

			canonical := fmt.Sprintf("%s\n%s\n%s\n%s\n%s",
				r.Method, r.URL.Path, timestamp, requestID, bodyHash)

			// 3. Compute expected HMAC
			mac := hmac.New(sha256.New, []byte(secret))
			mac.Write([]byte(canonical))
			expected := hex.EncodeToString(mac.Sum(nil))

			// 4. Timing-safe comparison
			sig := strings.TrimPrefix(signature, "v1=")
			if !hmac.Equal([]byte(sig), []byte(expected)) {
				http.Error(w, "Invalid signature", http.StatusUnauthorized)
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

// Usage:
// mux.Handle("/api/", VerifyPayFence(os.Getenv("PAYFENCE_WEBHOOK_SECRET"))(handler))

Example Signed Request

Here is what a complete signed request looks like when PayFence forwards it to your origin:

example request
GET /v1/flights HTTP/1.1
Host: your-origin.example.com
Authorization: Bearer tok_abc123
X-PayFence-Signature: v1=a3f1b9c7e2d4f6a8b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
X-PayFence-Timestamp: 1706745600
X-PayFence-Request-Id: req_8f2a1b3c4d5e
X-PayFence-Site: travel-api

The canonical string for this request would be:

canonical string
GET
/v1/flights
1706745600
req_8f2a1b3c4d5e
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Note: The body hash above is the SHA-256 of an empty string, since GET requests have no body.

Troubleshooting

Signature mismatch on POST requests

Ensure you are hashing the raw, unparsed request body. If your framework parses JSON before your verification middleware runs, the body bytes may differ from what PayFence signed. Configure your framework to preserve the raw body.

Timestamp always expired

Check that your server clock is synchronized with NTP. The 5-minute window assumes accurate clocks on both sides. Large clock drift will cause all requests to fail.

Wrong secret

The shared secret is specific to each site. If you operate multiple PayFence sites on the same origin, make sure you are using the correct secret for each site. You can check the X-PayFence-Site header to route to the right secret.

Ready to monetize your API?

Start monetizing your API in minutes. Free to get started.

Or book a demo