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.
The header
Section titled “The header”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.
More than one v1 during a rotation
Section titled “More than one v1 during a rotation”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.
The steps
Section titled “The steps”- Split the header on
,and each part on the first=; taketand everyv1(up to 4). - Reject if
tis more than 5 minutes from your clock — this stops replays of captured deliveries. - Compute
hex(HMAC-SHA256(secret, t + "." + rawBody)). - Compare it with each
v1in constant time; accept if any matches.
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;}import hashlibimport hmacimport time
def verify_ciqra(raw_body: bytes, header: str, secret: str, tolerance: int = 300, now: float | None = None) -> bool: """Verify a CIQRA webhook. raw_body: the request body bytes exactly as received.
The header may carry more than one v1 (during a signing-secret rotation): accept if ANY of them matches. """ t = None given = [] for part in header.split(","): key, sep, value = part.partition("=") if not sep: continue key, value = key.strip(), value.strip() if key == "t": t = value elif key == "v1" and len(given) < 4: given.append(value) try: t = int(t) except (TypeError, ValueError): return False if abs((time.time() if now is None else now) - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() ok = False for v1 in given: ok |= v1.isascii() and hmac.compare_digest(expected, v1) return okusing System.Security.Cryptography;using System.Text;
static bool VerifyCiqra(string rawBody, string header, string secret, TimeSpan tolerance){ string? t = null; var given = new List<string>(); foreach (var part in header.Split(',', StringSplitOptions.TrimEntries)) { var eq = part.IndexOf('='); if (eq <= 0) continue; if (part[..eq] == "t") t = part[(eq + 1)..]; else if (part[..eq] == "v1" && given.Count < 4) given.Add(part[(eq + 1)..]); } if (!long.TryParse(t, out var unix) || given.Count == 0) return false; if (unix < -62135596800 || unix > 253402300799) return false; // outside what DateTimeOffset can hold
var signedAt = DateTimeOffset.FromUnixTimeSeconds(unix); if ((DateTimeOffset.UtcNow - signedAt).Duration() > tolerance) return false;
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{unix}.{rawBody}")); var expected = Encoding.ASCII.GetBytes(Convert.ToHexStringLower(mac)); var ok = false; foreach (var v1 in given) // any v1 may match: two are sent during a secret rotation ok |= CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(v1)); return ok;}The Node.js and Python code above is the exact file this site’s test suite runs against the vector below.
Test vector
Section titled “Test vector”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.
Other delivery headers
Section titled “Other delivery headers”| 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.