Webhook Signature Verification

We sign every webhook using Ed25519 (asymmetric public-key cryptography). You verify signatures using our public keys — no shared secrets are required.

Golden Rule – Read This First

Always verify using the raw, untouched request body. Never use parsed JSON, JSON.stringify(), pretty-printed output, or modified whitespace.

This is the #1 cause of verification failures.


Quick Start

  1. Capture the raw request body as a UTF-8 string (before any JSON parsing)
  2. Extract headers: Webhook-Timestamp, Webhook-Key-Id, Webhook-Signature
  3. Reject if timestamp is older than 5 minutes (replay protection)
  4. Fetch JWKS (cache it!) — URL provided separately per environment
  5. Find JWK where kid matches Webhook-Key-Id
  6. Build the signing message: ${timestamp}.${rawBody} (literal dot, no extra spaces)
  7. Base64-decode Webhook-Signature to get signature bytes
  8. Verify with Ed25519 using the public key
  9. Invalid → return 401 Unauthorized / Valid → safely parse JSON and process the event

Headers

HeaderDescriptionRequired
Webhook-TimestampISO 8601 timestamp (with ms and Z)Yes
Webhook-Key-IdKey ID (kid) used for this signatureYes
Webhook-SignatureBase64-encoded Ed25519 signatureYes

Missing any header → return 401 Unauthorized immediately.

Header names are case-insensitive.


Replay Protection

Reject the request if:

current server time − parsed timestamp > 5 minutes

Use your server's clock. Small drift (a few seconds) is acceptable.


Public Keys (JWKS)

EnvironmentJWKS URL
Productionhttps://api.365finance.co.uk/.well-known/jwks.json
Staginghttps://staging-api.365finance.co.uk/.well-known/jwks.json

Best practices:

  • Cache the JWKS response — key changes are rare
  • Only re-fetch when you encounter an unknown Webhook-Key-Id
  • Key rotation is automatic: new keys appear in JWKS with no code changes required on your side

Verification Steps

  1. Read the raw body as early as possible — before any JSON parsing middleware
  2. Extract Webhook-Timestamp, Webhook-Key-Id, Webhook-Signature (case-insensitive)
  3. Timestamp check — too old or in the future → 401
  4. Load JWKS → find matching kid → not found? Re-fetch once → still not found → 401
  5. Construct the signing message exactly: ${Webhook-Timestamp}.${rawBody} (single dot, no added whitespace or newlines)
  6. Decode Webhook-Signature from standard base64 to bytes
  7. Verify using Ed25519 with the matched public key
  8. Valid → parse JSON and process / Invalid401 Unauthorized

Common Failure Modes

IssueSymptomFix
Parsed or reformatted JSON bodySignature always failsUse raw bytes exactly as received
Timestamp reformatted before building messageVerification failsUse the literal header value
Stale or missing JWKSUnknown kidRe-fetch JWKS when kid is not found
Base64 decoding errorInvalid signature bytesUse standard base64 (not base64url)
Framework lowercases headersHeader not foundUse case-insensitive header access
Body consumed by middlewareEmpty or modified rawBodyRead raw body before JSON/body parsers run

Node.js Example

import crypto from 'node:crypto';

// Production:  https://api.365finance.co.uk/.well-known/jwks.json
// Staging:     https://staging-api.365finance.co.uk/.well-known/jwks.json
const JWKS_URL = 'https://api.365finance.co.uk/.well-known/jwks.json';

// Cache this in production (e.g. in-memory with TTL, Redis, etc.)
async function getPublicKey(kid) {
  const res = await fetch(JWKS_URL);
  if (!res.ok) throw new Error('JWKS fetch failed');
  const { keys } = await res.json();
  const jwk = keys.find(k => k.kid === kid);
  if (!jwk) return null;
  return crypto.createPublicKey({ key: jwk, format: 'jwk' });
}

async function verifyWebhookSignature(req) {
  const timestamp = req.headers['webhook-timestamp'];
  const keyId     = req.headers['webhook-key-id'];
  const sigB64    = req.headers['webhook-signature'];
  const rawBody   = req.rawBody; // Must be set by your body parser middleware

  if (!timestamp || !keyId || !sigB64 || !rawBody) {
    return false;
  }

  // Replay protection — 5 minute window
  const ageMs = Date.now() - new Date(timestamp).getTime();
  if (ageMs < 0 || ageMs > 5 * 60 * 1000) {
    return false;
  }

  const publicKey = await getPublicKey(keyId);
  if (!publicKey) {
    return false;
  }

  const message   = `${timestamp}.${rawBody}`;
  const signature = Buffer.from(sigB64, 'base64');

  try {
    return crypto.verify(
      null,
      Buffer.from(message, 'utf8'),
      publicKey,
      signature
    );
  } catch {
    return false;
  }
}

// Express handler example
app.post('/webhook', async (req, res) => {
  const isValid = await verifyWebhookSignature(req);

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid or unauthorized webhook' });
  }

  const event = req.body;
  console.log('Verified event:', event.event);

  res.status(200).json({ received: true });
});

Express setup note: To access req.rawBody, configure your body parser with a verify callback:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));