All Docs
FeaturesCalmony PayUpdated March 15, 2026

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:

  1. Took the full Calmony-Signature header value (e.g. t=1234567890,v1=abc123...) as the signature argument.
  2. 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')
    
  3. 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 payload argument. 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

FileRole
src/lib/calmony-pay/client.tsSDK verifySignature method — contained the bug
src/lib/pay/webhooks.tssignWebhookPayload — defines the canonical signing format
src/inngest/functions/pay-webhook-deliver.tsSets the Calmony-Signature header on delivery

Summary

Before fixAfter fix
Signature parsed?❌ No✅ Yes (t=, v1= extracted)
Signed payload construction❌ Body only{timestamp}.{body}
Result for valid webhooks❌ Always falsetrue