Skip to content

Verifying signatures

Every delivery carries a Ciqra-Signature header. Reject any request whose signature does not verify — your endpoint is public, and anyone can POST to it.

Ciqra-Signature: t=1758535200,v1=530e6f8f…4596
Part Meaning
t Unix time (seconds) when CIQRA signed the delivery.
v1 Lowercase hex of HMAC-SHA256(key = signing secret, message = t + "." + raw body). May appear more than once — see below.

The key is your installation’s signing secret (ciqra_ss_…) as UTF-8 bytes — the whole string, prefix included. The message is the timestamp, a literal ., and the raw request body exactly as received. Verify before parsing: re-serialising parsed JSON changes the bytes, and the signature will not match.

When your signing secret is rotated, the previous secret stays valid for an overlap window (24 hours). During that window every delivery carries one v1 per valid secret, current first:

Ciqra-Signature: t=1758535200,v1=<signed with the new secret>,v1=<signed with the previous secret>

So you can switch to the new secret whenever you deploy: whichever one you hold verifies. Accept the delivery if ANY v1 matches the value you compute; never only the first or the last. A header never carries more than 4 signatures, so you may ignore any beyond the fourth. Outside a rotation there is exactly one v1, as before.

If CIQRA retires a secret because it was compromised, there is no overlap: only the new secret signs, and deliveries fail verification until you are given the new secret.

  1. Split the header on , and each part on the first =; take t and every v1 (up to 4).
  2. Reject if t is more than 5 minutes from your clock — this stops replays of captured deliveries.
  3. Compute hex(HMAC-SHA256(secret, t + "." + rawBody)).
  4. Compare it with each v1 in constant time; accept if any matches.
verify-signature.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
// Verify a CIQRA webhook. rawBody: the request body exactly as received (string), before any JSON parsing.
// The header may carry more than one v1 (during a signing-secret rotation): accept if ANY of them matches.
export function verifyCiqra(rawBody, header, secret, toleranceSec = 300, nowSec = Date.now() / 1000) {
let t = null;
const given = [];
for (const part of String(header).split(",")) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") t = Number(value);
else if (key === "v1" && given.length < 4) given.push(value);
}
if (!Number.isInteger(t) || Math.abs(nowSec - t) > toleranceSec) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex"));
let ok = false;
for (const v1 of given) {
const candidate = Buffer.from(v1);
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) ok = true;
}
return ok;
}

The Node.js and Python code above is the exact file this site’s test suite runs against the vector below.

Check your implementation with this before connecting a real store.

Input Value
secret ciqra_ss_test
raw body {"orderId":"0f8e","status":"Paid"}
t 1758535200
signed message 1758535200.{"orderId":"0f8e","status":"Paid"}
header t=1758535200,v1=530e6f8fc05a8e47dd1befcbdf45fa89b16b4491362f5e7c0427b44965bb4596

This value is pinned by a test in the platform itself, so it cannot drift from what CIQRA actually sends. Remember to pass a fixed “now” when testing, or the 5-minute window will reject the 2025 timestamp.

Header Value
Ciqra-Event The event type, e.g. order.paid.
Ciqra-Event-Id Stable id of the event — use it to deduplicate.
Ciqra-Delivery Id of this delivery attempt series.
Ciqra-Webhook-Id The subscription id.
Ciqra-Event-Age-Seconds Seconds since the event happened; large after retries.
User-Agent CIQRA-Webhooks/1.0

These headers are not signed. Use them for routing, and trust only the signed body for data.