Skip to content

Verifying signatures

Check that a webhook came from OrderCard and was not replayed.

Updated View as Markdown

Each endpoint has a signing secret starting with whsec_. Find it under Developers → Webhooks → Reveal signing secret. Every delivery carries an OrderCard-Signature header:

OrderCard-Signature: t=1792250401,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 is the HMAC-SHA256, keyed with the secret, of the timestamp, a dot, and the raw request body. To verify a delivery:

  1. Read the raw body before any JSON parsing; re-encoding changes the bytes.
  2. Split the header into t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + body) as lowercase hex.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the delivery if t is more than five minutes old, so a captured request cannot be replayed later.

PHP

function ordercardWebhookIsValid(string $body, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];

    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        $parts[$key] = $value;
    }

    if (! ctype_digit($parts['t'] ?? '') || abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $parts['t'].'.'.$body, $secret);

    return hash_equals($expected, $parts['v1'] ?? '');
}

// Laravel: $request->getContent() is the raw body.
$valid = ordercardWebhookIsValid($request->getContent(), (string) $request->header('OrderCard-Signature'), config('services.ordercard.webhook_secret'));

Node.js

import crypto from "node:crypto";

export function ordercardWebhookIsValid(rawBody, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=", 2)));
  const timestamp = Number(parts.t);

  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
    return false;
  }

  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const received = Buffer.from(parts.v1 ?? "", "hex");

  return received.length === 32 && crypto.timingSafeEqual(Buffer.from(expected, "hex"), received);
}

// Express: express.raw({ type: "application/json" }) keeps the body as a Buffer.

Rotating the secret

Choose Rotate secret on the endpoint. The new secret applies to the next delivery and the old one stops working at once, so update your server first if you cannot afford rejected deliveries; failed ones are retried and can be resent from the dashboard.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close