---
title: "Verifying signatures"
description: "Check that a webhook came from OrderCard and was not replayed."
image: "https://docs.ordercard.io/og.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.ordercard.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying signatures

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

```text
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

```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

```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.

Source: https://docs.ordercard.io/webhooks/signatures/index.mdx
