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
- Capture the raw request body as a UTF-8 string (before any JSON parsing)
- Extract headers:
Webhook-Timestamp,Webhook-Key-Id,Webhook-Signature - Reject if timestamp is older than 5 minutes (replay protection)
- Fetch JWKS (cache it!) — URL provided separately per environment
- Find JWK where
kidmatchesWebhook-Key-Id - Build the signing message:
${timestamp}.${rawBody}(literal dot, no extra spaces) - Base64-decode
Webhook-Signatureto get signature bytes - Verify with Ed25519 using the public key
- Invalid → return
401 Unauthorized/ Valid → safely parse JSON and process the event
Headers
| Header | Description | Required |
|---|---|---|
Webhook-Timestamp | ISO 8601 timestamp (with ms and Z) | Yes |
Webhook-Key-Id | Key ID (kid) used for this signature | Yes |
Webhook-Signature | Base64-encoded Ed25519 signature | Yes |
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)
| Environment | JWKS URL |
|---|---|
| Production | https://api.365finance.co.uk/.well-known/jwks.json |
| Staging | https://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
- Read the raw body as early as possible — before any JSON parsing middleware
- Extract
Webhook-Timestamp,Webhook-Key-Id,Webhook-Signature(case-insensitive) - Timestamp check — too old or in the future →
401 - Load JWKS → find matching
kid→ not found? Re-fetch once → still not found →401 - Construct the signing message exactly:
${Webhook-Timestamp}.${rawBody}(single dot, no added whitespace or newlines) - Decode
Webhook-Signaturefrom standard base64 to bytes - Verify using Ed25519 with the matched public key
- Valid → parse JSON and process / Invalid →
401 Unauthorized
Common Failure Modes
| Issue | Symptom | Fix |
|---|---|---|
| Parsed or reformatted JSON body | Signature always fails | Use raw bytes exactly as received |
| Timestamp reformatted before building message | Verification fails | Use the literal header value |
| Stale or missing JWKS | Unknown kid | Re-fetch JWKS when kid is not found |
| Base64 decoding error | Invalid signature bytes | Use standard base64 (not base64url) |
| Framework lowercases headers | Header not found | Use case-insensitive header access |
| Body consumed by middleware | Empty or modified rawBody | Read 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 averifycallback:app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); } }));
