Verify signatures from Ordina
How Ordina signs webhook deliveries and connected-action calls, with working code in Node.js, Python and PHP, and the mistakes that break verification.
Every webhook delivery and every connected-action call from Ordina is signed with a secret only you and Ordina know. Verify it before you trust the request.
The header
X-Ordina-Signature: t=1759312800,v1=5f2c7b...e9a1
tis the time Ordina signed the request, in Unix seconds.v1is the hex HMAC-SHA256 oft, a dot, and the raw request body, keyed with the secret.
The secret is the whsec_... value you were given once, when the webhook endpoint or the action was created. Each endpoint and each action has its own.
Checking it
- Read the raw body exactly as received, before any JSON parsing.
- Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex. - Compare it with
v1in constant time. - Reject the request if
tis more than 5 minutes from your clock, so an old request cannot be replayed.
Node.js
import crypto from "node:crypto";
export function verifyOrdina(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`, "utf8").digest("hex");
return expected.length === parts.v1.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
With Express, keep the raw body: app.post("/ordina", express.raw({ type: "application/json" }), handler) and pass req.body.toString("utf8"). In a Next.js route handler, use await req.text().
Python
import hashlib, hmac, time
def verify_ordina(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts.get("t", 0))) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
With Flask, pass request.get_data(). With Django, request.body.
PHP
function verify_ordina(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $p);
if (abs(time() - (int)($p['t'] ?? 0)) > 300) return false;
$expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $p['v1'] ?? '');
}
Read the body with file_get_contents('php://input').
When verification fails
| Cause | Fix |
|---|---|
| The body was parsed and re-serialised before hashing | Hash the raw bytes as received |
| The wrong secret | Each endpoint and each action has its own secret |
| The clock on your server is off | Sync it with NTP; the window is 5 minutes |
| A proxy changed the body (whitespace, encoding) | Verify before any middleware touches it |
Lost a secret? For a webhook, remove the endpoint and add it again. For an action, delete it and register it again; the owner then switches the new one on.
