All Docs
FeaturesCalmony PayUpdated March 15, 2026

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:

  1. It did not parse the signature header. The signature argument contains the full t={timestampMs},v1={hmac_hex} string. Comparing a raw hex digest to this string always fails.
  2. 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

ScenarioBefore fixAfter fix
Developer passes Calmony-Signature header value directlyAlways falseCorrect result ✅
Developer manually extracts v1= hex and passes thatfalse (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.ts for 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-Signature header 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 full t=...,v1=... format.
  • If you were not verifying webhook signatures at all — update your webhook handler to call verifySignature using the pattern above.

Affected files

  • src/lib/calmony-pay/client.ts — SDK verifySignature implementation corrected
  • src/inngest/functions/pay-webhook-deliver.ts — webhook delivery (signing side, unchanged)
  • src/lib/pay/webhooks.tssignWebhookPayload utility (signing side, unchanged)