Fixing CalmonyPay Webhook Signature Verification
Fixing CalmonyPay Webhook Signature Verification
Relates to: v1.0.70 — Spec drift fix
A critical bug has been identified and documented in the CalmonyPay.webhooks.verifySignature method. If you are verifying inbound Calmony Pay webhooks in your integration, you need to understand this fix before it is deployed.
Background
When Calmony Pay delivers a webhook event to your endpoint, it includes a Calmony-Signature header so you can verify that the request genuinely came from Calmony Pay and has not been tampered with.
The header format is:
Calmony-Signature: t={timestampMs},v1={hmac_hex}
-
t— the Unix timestamp in milliseconds at the time the webhook was signed. -
v1— the HMAC-SHA256 hex digest of the signed payload, which is constructed as:{timestampMs}.{rawRequestBody}
This construction ties the signature to both the body content and the delivery time, providing protection against both payload tampering and replay attacks.
The Bug
The SDK's verifySignature method did not implement this correctly. Instead of parsing the structured header, it:
- Took the full
Calmony-Signatureheader value (e.g.t=1234567890,v1=abc123...) as thesignatureargument. - Computed an HMAC over only the raw body — without prepending the timestamp:
// ❌ Incorrect — timestamp prefix missing, header not parsed createHmac('sha256', secret).update(payload).digest('hex') - Compared that digest against the entire
t=...,v1=...string — which can never match.
The result: verifySignature always returned false for any legitimate Calmony Pay webhook.
The Correct Implementation
To correctly verify a Calmony-Signature header, the following steps must be performed:
Step 1 — Parse the signature header
const parts = signature.split(',');
const tPart = parts.find((p) => p.startsWith('t='));
const v1Part = parts.find((p) => p.startsWith('v1='));
if (!tPart || !v1Part) {
return false; // malformed header
}
const timestamp = tPart.slice(2); // e.g. "1234567890"
const expectedDigest = v1Part.slice(3); // e.g. "abc123..."
Step 2 — Reconstruct the signed payload
const signedPayload = `${timestamp}.${body}`;
Step 3 — Compute the HMAC
import { createHmac, timingSafeEqual } from 'crypto';
const digest = createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
Step 4 — Compare using a timing-safe method
const digestBuf = Buffer.from(digest);
const expectedBuf = Buffer.from(expectedDigest);
if (digestBuf.length !== expectedBuf.length) {
return false;
}
return timingSafeEqual(digestBuf, expectedBuf);
Full Verified Usage Example
The corrected SDK call looks like this:
import { CalmonyPay } from '@/lib/calmony-pay/client';
// In your webhook route handler:
const rawBody = await request.text();
const signature = request.headers.get('Calmony-Signature') ?? '';
const secret = process.env.CALMONY_WEBHOOK_SECRET!;
const isValid = CalmonyPay.webhooks.verifySignature(rawBody, signature, secret);
if (!isValid) {
return new Response('Unauthorized', { status: 401 });
}
// Safe to process the event
const event = JSON.parse(rawBody);
⚠️ Always use the raw, unparsed request body as the
payloadargument. Parsing the body as JSON and re-serialising it may alter whitespace and cause signature mismatches.
Replay Attack Protection
Because the timestamp is embedded in the signed payload, you should also check that the webhook was delivered recently. A common threshold is 5 minutes:
const tPart = signature.split(',').find((p) => p.startsWith('t='));
const timestamp = tPart ? parseInt(tPart.slice(2), 10) : 0;
const ageMs = Date.now() - timestamp;
if (ageMs > 5 * 60 * 1000) {
return new Response('Webhook timestamp too old', { status: 400 });
}
Affected Files
| File | Role |
|---|---|
src/lib/calmony-pay/client.ts | SDK verifySignature method — contained the bug |
src/lib/pay/webhooks.ts | signWebhookPayload — defines the canonical signing format |
src/inngest/functions/pay-webhook-deliver.ts | Sets the Calmony-Signature header on delivery |
Summary
| Before fix | After fix | |
|---|---|---|
| Signature parsed? | ❌ No | ✅ Yes (t=, v1= extracted) |
| Signed payload construction | ❌ Body only | ✅ {timestamp}.{body} |
| Result for valid webhooks | ❌ Always false | ✅ true |