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
  • t is the time Ordina signed the request, in Unix seconds.
  • v1 is the hex HMAC-SHA256 of t, 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

  1. Read the raw body exactly as received, before any JSON parsing.
  2. Compute HMAC-SHA256(secret, t + "." + rawBody) as hex.
  3. Compare it with v1 in constant time.
  4. Reject the request if t is 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.

Did this answer it?If not, write to help@useordina.com with your handle, or contact us. A person replies.
Contact us