Post-mortem: Webhook Signature Verification Always Returned False
Post-mortem: Webhook Signature Verification Always Returned False
Release: v1.0.71 Severity: Critical — spec drift caused all spec-compliant webhook verification to silently fail.
Summary
A deviation between the Calmony Pay webhook delivery system and the SDK's verifySignature implementation meant that any developer following the documented SDK interface would receive false from every call to CalmonyPay.webhooks.verifySignature(), regardless of whether the webhook payload was genuine.
Background
How webhook signatures are created
When Calmony Pay delivers a webhook, pay-webhook-deliver.ts calls signWebhookPayload (defined in src/lib/pay/webhooks.ts) and attaches the result as the Calmony-Signature HTTP header. The signature is structured as:
Calmony-Signature: t={timestampMs},v1={hmac_hex}
The HMAC is computed over a signed payload string formed by joining the millisecond timestamp and the raw request body with a period:
signed_payload = "{timestampMs}.{rawBody}"
hmac_hex = HMAC-SHA256(secret, signed_payload)
This two-part format — timestamp plus body — is intentional. It makes the signature time-bound, protecting against replay attacks.
How the SDK was verifying signatures
The SDK's CalmonyPay.webhooks.verifySignature(payload, signature, secret) was implemented as:
// src/lib/calmony-pay/client.ts — BEFORE fix
const digest = createHmac('sha256', secret)
.update(payload)
.digest('hex');
return digest === signature;
This implementation had two independent errors:
- It did not parse the signature header. The
signatureargument contains the fullt={timestampMs},v1={hmac_hex}string. Comparing a raw hex digest to this string always fails. - It did not reconstruct the signed payload. Even if the hex portion were extracted, the HMAC was computed over
payload(the raw body) alone — not over{timestamp}.{body}as the delivery system expects.
Impact
| Scenario | Before fix | After fix |
|---|---|---|
Developer passes Calmony-Signature header value directly | Always false ❌ | Correct result ✅ |
Developer manually extracts v1= hex and passes that | false (wrong signed payload) ❌ | N/A — use full header ✅ |
All spec-compliant integrations were broken. Because the method returned false rather than throwing, the failure mode was silent — developers would see rejected webhooks with no obvious error unless they inspected the return value explicitly.
Fix
The corrected verifySignature implementation now mirrors the signing logic in signWebhookPayload:
// src/lib/calmony-pay/client.ts — AFTER fix
function verifySignature(payload: string, signature: string, secret: string): boolean {
// 1. Parse t= and v1= from the Calmony-Signature header
const parts = Object.fromEntries(
signature.split(',').map(part => part.split('='))
);
const timestamp = parts['t'];
const receivedHmac = parts['v1'];
if (!timestamp || !receivedHmac) return false;
// 2. Reconstruct the signed payload
const signedPayload = `${timestamp}.${payload}`;
// 3. Compute expected HMAC
const expectedHmac = createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// 4. Compare digests
return expectedHmac === receivedHmac;
}
Note: The code block above illustrates the corrected logic. Refer to
src/lib/calmony-pay/client.tsfor the exact implementation.
Using verifySignature correctly
Pass the full, unmodified value of the Calmony-Signature header as the signature argument:
import { CalmonyPay } from '@/lib/calmony-pay/client';
// In your webhook handler:
const signature = request.headers.get('Calmony-Signature') ?? '';
const rawBody = await request.text();
const isValid = CalmonyPay.webhooks.verifySignature(
rawBody,
signature,
process.env.CALMONY_WEBHOOK_SECRET
);
if (!isValid) {
return new Response('Unauthorized', { status: 401 });
}
Migration notes
- If you were passing the full
Calmony-Signatureheader value — no changes required. The SDK now behaves as documented. - If you were manually extracting the
v1=hex portion as a workaround — revert to passing the full header string. Passing a raw hex digest will now fail, as the method expects the fullt=...,v1=...format. - If you were not verifying webhook signatures at all — update your webhook handler to call
verifySignatureusing the pattern above.
Affected files
src/lib/calmony-pay/client.ts— SDKverifySignatureimplementation correctedsrc/inngest/functions/pay-webhook-deliver.ts— webhook delivery (signing side, unchanged)src/lib/pay/webhooks.ts—signWebhookPayloadutility (signing side, unchanged)