Skip to main content
Every webhook FitProTracker delivers carries a cryptographic signature in the X-FPT-Signature header. Verifying this signature on every request proves the payload genuinely came from us and hasn’t been tampered with in transit.
Verify the signature on every request, before any processing. Don’t trust the body, don’t read the eventType, don’t even log the payload until verification passes. An unsigned or invalid request should return 401 Unauthorized immediately.

The signature header

X-FPT-Signature: t=1717023600,v1=a3f8c2e7d1b5...
Two fields, comma-separated:
KeyMeaning
tUnix timestamp (seconds since epoch) when we computed the signature. Use this to reject replay attempts older than your tolerance window (we suggest 5 minutes).
v1Hex-encoded HMAC-SHA256 of "{t}.{rawBody}" using your subscription’s signing secret.

The signing scheme

signature = HMAC_SHA256(
    secret = <your subscription's signing secret>,
    message = "{t}.{rawBody}"
)
Where:
  • {t} is the same timestamp value from the header
  • {rawBody} is the exact request body, byte-for-byte — don’t re-serialize the JSON (whitespace changes break the signature)
  • secret is the 32+ character secret revealed once when you click Generate Key on your subscription

Verification recipe (language-agnostic)

1

Parse the X-FPT-Signature header

Split on ,. Pull out t and v1. Reject if either is missing.
2

Reject ancient timestamps

Check now - t > 300 (5 minutes). Reject as 401 — defends against replay attacks if a signature ever leaks.
3

Read the raw body, exactly as received

Don’t parse, don’t pretty-print, don’t strip whitespace. Capture the bytes.
4

Compute the expected signature

expected = HMAC_SHA256(secret, "{t}.{rawBody}"). Hex-encode it.
5

Constant-time compare

Use a constant-time comparison function (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, CryptographicOperations.FixedTimeEquals in .NET). Never use == on the strings — it leaks the secret via timing side channels.
6

Reject mismatches with 401

Don’t expose details about why it failed — that helps attackers.

Code samples

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

export function verifySignature(headerValue, rawBody, secret) {
    if (!headerValue) return false;

    // Parse header: "t=1717023600,v1=a3f..."
    const parts = Object.fromEntries(
        headerValue.split(",").map(p => p.split("=").map(s => s.trim()))
    );
    if (!parts.t || !parts.v1) return false;

    // Reject ancient timestamps
    const ts = parseInt(parts.t, 10);
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - ts) > TOLERANCE_SECONDS) return false;

    // Compute expected
    const expected = crypto
        .createHmac("sha256", secret)
        .update(`${ts}.${rawBody}`)
        .digest("hex");

    // Constant-time compare
    try {
        return crypto.timingSafeEqual(
            Buffer.from(expected, "hex"),
            Buffer.from(parts.v1, "hex")
        );
    } catch {
        return false; // wrong length, etc.
    }
}

// --- Express usage ---
// IMPORTANT: capture the raw body BEFORE express.json() parses it.
import express from "express";
const app = express();

app.post(
    "/webhooks/fpt",
    express.raw({ type: "application/json" }),
    (req, res) => {
        const rawBody = req.body.toString("utf8");
        if (!verifySignature(req.headers["x-fpt-signature"], rawBody, process.env.FPT_WEBHOOK_SECRET)) {
            return res.status(401).end();
        }
        const event = JSON.parse(rawBody);
        // ... dedupe by event.eventId, process, return 200
        res.status(200).end();
    }
);

Key rotation

Click Rotate Key on your subscription’s row in Settings → Webhook Endpoints to issue a new secret. The dialog reveals the new secret once — save it.
Grace period after rotation: for 24 hours after rotation, FPT signs deliveries with the NEW secret only — the old one is invalidated immediately. Plan rotations carefully:
  1. Get the new secret in the FPT admin
  2. Deploy the new secret to your endpoint config
  3. Verify a test event lands successfully
  4. Done — the rotation is complete
If you accidentally break a key (lose it, leak it), rotate immediately. Events delivered between the leak and the rotation should be considered untrusted.

Common verification mistakes

Your framework probably parses the body into a req.body object before your handler runs. Capture the raw bytes before that happens. If you sign JSON.stringify(req.body), the whitespace or field ordering will differ from what we signed and you’ll always fail verification.
String comparison short-circuits on the first differing character — that timing leak lets an attacker brute-force the signature one character at a time. Always use the constant-time comparison helper your platform provides.
Without a timestamp tolerance check, a leaked signature is valid forever. The t= field in the header exists specifically so you can reject replays older than ~5 minutes.
Treat it like any other API credential. Store in your secrets manager, never log it, rotate periodically.