Verifying signatures
Anyone who learns your endpoint URL can post to it. Always verify the signature before acting on a delivery.
How the signature works
Section titled “How the signature works”The X-IDPortal-Signature header looks like:
t=1801537200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the Unix time in seconds when CardOffice signed the delivery.v1is 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.
Verify in four steps
Section titled “Verify in four steps”- Read the raw request body before any JSON parsing. Re-serialising it changes the bytes and breaks the signature.
- Split the header into
tandv1. - Compute
HMAC_SHA256(secret, t + "." + rawBody)as hex. - Compare with
v1in constant time, and reject iftis 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);}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool: parts = dict(kv.split("=", 1) for kv in header.split(",")) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1 or abs(time.time() - int(t)) > tolerance: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { parse_str(str_replace(',', '&', $header), $parts); if (empty($parts['t']) || empty($parts['v1'])) return false; if (abs(time() - (int)$parts['t']) > $tolerance) return false; $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']);}Troubleshooting a failing check
Section titled “Troubleshooting a failing check”| 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