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). |
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.
The steps
Section titled “The steps”- Split the header on
,and each part on the first=; taketandv1. - Reject if
tis more than 5 minutes from your clock — this stops replays of captured deliveries. - Compute
hex(HMAC-SHA256(secret, t + "." + rawBody)). - Compare with
v1in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
// Verify a CIQRA webhook. rawBody: the request body exactly as received (string), before any JSON parsing.export function verifyCiqra(rawBody, header, secret, toleranceSec = 300, nowSec = Date.now() / 1000) { const parts = {}; for (const part of String(header).split(",")) { const eq = part.indexOf("="); if (eq > 0) parts[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); }
const t = Number(parts.t); if (!Number.isInteger(t) || Math.abs(nowSec - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex"); const given = parts.v1 ?? ""; return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));}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.""" parts = {} for part in header.split(","): key, sep, value = part.partition("=") if sep: parts[key.strip()] = value.strip() try: t = int(parts["t"]) except (KeyError, 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() return hmac.compare_digest(expected, parts.get("v1", ""))using System.Security.Cryptography;using System.Text;
static bool VerifyCiqra(string rawBody, string header, string secret, TimeSpan tolerance){ string? t = null, v1 = null; 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") v1 = part[(eq + 1)..]; } if (!long.TryParse(t, out var unix) || v1 is null) return false;
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}")); return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(Convert.ToHexStringLower(mac)), Encoding.ASCII.GetBytes(v1));}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.