> ## Documentation Index
> Fetch the complete documentation index at: https://help.ciarem.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify signatures

> Check that a webhook request really comes from Ciarem and was not altered, with code you can copy.

Your webhook URL is public, so anyone who learns it can send requests to it. Ciarem signs every request with your endpoint's **signing secret**. Checking that signature is how your server tells a real Ciarem event from a forged one. **Verify before you trust the body.**

## The signing secret

Each endpoint has its own secret. It starts with `whsec_`.

* **In the dashboard:** shown when you add the endpoint, and again any time on the endpoint's page with **Show secret**. Only admins can see it.
* **With the API:** returned once, in the response of `POST /v1/webhook-endpoints`.

Treat it like a password: keep it in your secret manager or an environment variable, never in code or in a browser.

## The signature header

Every request carries a `Ciarem-Signature` header:

```
Ciarem-Signature: t=1760022245,v1=5f8a0c…c2e1
```

| Part | |
| - | - |
| `t` | When Ciarem sent this request, as a Unix timestamp in seconds. |
| `v1` | The signature: an HMAC-SHA256, written in lowercase hexadecimal. |

The signature is computed over the timestamp, a dot, and the **raw request body**, using your signing secret as the key:

```
v1 = HMAC_SHA256(key = signing secret, message = t + "." + raw body)
```

## How to verify

**1. Read the raw body**

Take the request body **exactly as the bytes arrived**, before any JSON parsing. Parsing and re-serializing changes spacing and character escaping, and the signature will no longer match.

**2. Split the header**

Split `Ciarem-Signature` on `,` and then on `=` to get `t` and `v1`.

**3. Compute the expected signature**

Build the message `t + "." + raw body` and compute its HMAC-SHA256 with your signing secret. Use the whole secret, including the `whsec_` prefix, as it is.

**4. Compare safely**

Compare your result with `v1` using a constant-time comparison function, not `==`.

**5. Check the timestamp**

Reject the request if `t` is more than **5 minutes** away from your server's clock. This stops someone from capturing a real request and replaying it later.

If any step fails, answer `400` and do nothing with the body.

## Code you can copy

```javascript Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CIAREM_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((p) => p.split("=", 2)),
  );
  const timestamp = Number(parts.t);
  if (!timestamp || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as bytes. Do not use express.json() on this route.
app.post("/hooks/ciarem", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("ciarem-signature"))) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(req.body);
  // Queue the event for processing, then answer right away.
  res.sendStatus(200);
});

app.listen(3000);
```

```python Python (FastAPI) theme={null}
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["CIAREM_WEBHOOK_SECRET"]  # whsec_…
TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str | None) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        SECRET.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/hooks/ciarem")
async def ciarem_webhook(request: Request):
    raw_body = await request.body()  # the bytes, not request.json()
    if not verify(raw_body, request.headers.get("ciarem-signature")):
        raise HTTPException(status_code=400, detail="invalid signature")
    event = json.loads(raw_body)
    # Queue the event for processing, then answer right away.
    return {"ok": True}
```

```php PHP theme={null}
<?php
$secret = getenv('CIAREM_WEBHOOK_SECRET'); // whsec_…
$tolerance = 300;

$rawBody = file_get_contents('php://input'); // the bytes, before json_decode
$header = $_SERVER['HTTP_CIAREM_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
    $parts[$key] = $value;
}
$timestamp = (int) ($parts['t'] ?? 0);
$signature = $parts['v1'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (
    $timestamp === 0
    || abs(time() - $timestamp) > $tolerance
    || !hash_equals($expected, $signature)
) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($rawBody, true);
// Queue the event for processing, then answer right away.
http_response_code(200);
```

```ruby Ruby (Sinatra) theme={null}
require "json"
require "openssl"
require "sinatra"

SECRET = ENV.fetch("CIAREM_WEBHOOK_SECRET") # whsec_…
TOLERANCE_SECONDS = 300

def verify(raw_body, header)
  parts = header.to_s.scan(/(\w+)=([^,]+)/).to_h
  timestamp = parts["t"].to_i
  signature = parts["v1"].to_s
  return false if timestamp.zero? || signature.empty?
  return false if (Time.now.to_i - timestamp).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(expected, signature)
end

post "/hooks/ciarem" do
  raw_body = request.body.read # the bytes, before JSON.parse
  halt 400, "invalid signature" unless verify(raw_body, request.env["HTTP_CIAREM_SIGNATURE"])
  event = JSON.parse(raw_body)
  # Queue the event for processing, then answer right away.
  status 200
end
```

## Check your code with a known example

Use these values to confirm your verification code computes the right signature, without waiting for a real event. Skip the timestamp check while you run this, since the example's timestamp is in the past.

| | |
| - | - |
| Signing secret | `whsec_test` |
| Timestamp `t` | `1760000000` |
| Raw body | `{"id":"evt_test_sample00000000000","type":"test","api_version":"v1","created_at":"2026-01-15T14:30:00Z","data":{}}` |
| Expected `v1` | `057e6f478035e5f43cc6c85e722bb8871eff8baa2852afdf23a52ef6a7d739c3` |

You can produce the same value from a terminal:

```bash theme={null}
printf '%s' '1760000000.{"id":"evt_test_sample00000000000","type":"test","api_version":"v1","created_at":"2026-01-15T14:30:00Z","data":{}}' \
  | openssl dgst -sha256 -hmac 'whsec_test'
```

## When verification fails

| What you see | Likely cause |
| - | - |
| The signature never matches | You are hashing a re-serialized body. Hash the raw bytes as they arrived. In Express, a global `express.json()` runs before your route and replaces the body; mount `express.raw()` on the webhook route instead. |
| It matches for some events and not others | The body contains accented characters or emoji and your framework changed their encoding. Ciarem sends the body as UTF-8 with those characters written as they are, not as `\u` escapes. Again, hash the raw bytes. |
| It never matches, and you hash the raw body | The wrong secret. Each endpoint has its own, and it must include the `whsec_` prefix. Copy it again from the endpoint's page. |
| It stopped matching after you recreated the endpoint | A new endpoint gets a new secret. Update your server. |
| Real requests are rejected as too old | Your server's clock is off. Sync it with NTP, or widen the tolerance a little. |

## Good practices

* **One secret per endpoint.** If you run staging and production servers, give each its own endpoint so a leaked staging secret cannot forge production events.
* **Rotate by replacing the endpoint.** A secret cannot be changed in place. To rotate, add a new endpoint with the same URL and events, update your server to accept the new secret, then delete the old endpoint. While both exist, you receive each event twice, once per endpoint, with the same event `id`, so [deduplicating on `id`](https://help.ciarem.ai/webhooks/deliveries#duplicates-and-order) keeps this harmless.
* **Every retry is signed again.** A retried delivery has the same body and event `id` but a new `t` and a new signature, so the 5-minute check works for retries too.
* **Verification proves origin, not uniqueness.** A verified event can still be a duplicate. Deduplicate on `id` as well.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.