Skip to content

Verifying signatures

Anyone who learns your endpoint URL can post to it. Always verify the signature before acting on a delivery.

The X-IDPortal-Signature header looks like:

t=1801537200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix time in seconds when CardOffice signed the delivery.
  • v1 is a hex HMAC-SHA256 of the string "{t}.{raw body}", using your webhook’s signing secret as the key.

Including the timestamp in the signed text is what stops an attacker replaying a captured delivery later.

  1. Read the raw request body before any JSON parsing. Re-serialising it changes the bytes and breaks the signature.
  2. Split the header into t and v1.
  3. Compute HMAC_SHA256(secret, t + "." + rawBody) as hex.
  4. Compare with v1 in constant time, and reject if t is more than five minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
Symptom Likely cause
Every signature fails Verifying parsed-then-reserialised JSON instead of the raw body, or using the wrong secret
Fails after a secret change The old webhook still holds the old secret. Update the receiver
Intermittent failures Your server clock has drifted beyond the five-minute tolerance. Sync it with NTP

Check recent deliveries in Settings → Integrations to see the exact status and response body CardOffice recorded.

© 2026 Secure ID Ltd. All rights reserved.Documentation licence and terms