Verifying Signatures

By Orqestra · Published September 25, 2026

Your webhook URL is public, so anyone can POST to it. The signature proves a request came from Orqestra and wasn't changed on the way. Check it on every request before you trust the body.

How requests are signed

Each endpoint has its own signing secret. For every attempt, Orqestra takes the Unix timestamp it sends in X-Orqestra-Timestamp, a literal ., and the exact bytes of the request body, and computes an HMAC-SHA256 with the secret as the key:

signed_payload = X-Orqestra-Timestamp + "." + raw_request_body
signature      = hex( HMAC_SHA256( key = signing_secret, message = signed_payload ) )

X-Orqestra-Signature: v1=<signature>

The signature is lowercase hex, and v1 names the scheme. Parse the header as a comma-separated list, because during a secret rotation it carries two values.

Verifying, step by step

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Read X-Orqestra-Timestamp. Reject the request if it is more than 5 minutes from your server's clock. This stops a captured request from being replayed later.
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody), hex-encoded.
  4. Split X-Orqestra-Signature on commas. Accept the request if any v1= value matches, using a constant-time comparison.
  5. Only now parse the JSON, and deduplicate on its id.

If verification fails, answer 401. Orqestra doesn't retry a 4xx. If the cause was your own configuration, such as the wrong secret, fix it and redeliver the failed events from the delivery history.

Code

Each function below takes the raw body, the two headers and the secret, and returns true or false. They are tested against Orqestra's own signer, including rotation, tampered bodies, stale timestamps and non-ASCII message text.

Node.js

import crypto from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

/**
 * rawBody: the request body as a Buffer, exactly as received.
 * headers: lower-cased header names (Node, Express and Fastify all do this).
 * secret:  the signing secret exactly as the dashboard showed it. Do not decode it.
 */
export function verifyOrqestraSignature(rawBody, headers, secret, nowMs = Date.now()) {
  const timestamp = headers['x-orqestra-timestamp']
  const signatureHeader = headers['x-orqestra-signature']
  if (!timestamp || !signatureHeader || !/^\d+$/.test(timestamp)) return false
  if (Math.abs(Math.floor(nowMs / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false

  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()

  // During a secret rotation the header carries two values: "v1=<new>,v1=<old>".
  return signatureHeader.split(',').some((part) => {
    const [version, hex] = part.trim().split('=')
    if (version !== 'v1' || !/^[0-9a-f]{64}$/.test(hex ?? '')) return false
    return crypto.timingSafeEqual(Buffer.from(hex, 'hex'), expected)
  })
}

With Express, use express.raw() on the webhook route so the body stays a Buffer. express.json() would parse it, and the bytes you need would be gone:

import express from 'express'
import { verifyOrqestraSignature } from './verify.mjs'

const app = express()
const SECRET = process.env.ORQESTRA_WEBHOOK_SECRET

app.post('/webhooks/orqestra', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyOrqestraSignature(req.body, req.headers, SECRET)) return res.sendStatus(401)

  const event = JSON.parse(req.body.toString('utf8'))
  try {
    await saveToInbox(event) // your code, e.g. INSERT ... ON CONFLICT (id) DO NOTHING
    res.sendStatus(200)
  } catch {
    res.sendStatus(500) // Orqestra will retry
  }
})

Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_orqestra_signature(raw_body: bytes, timestamp: str | None,
                              signature_header: str | None, secret: str,
                              now: float | None = None) -> bool:
    """raw_body is the request body as bytes, exactly as received.
    secret is the signing secret exactly as the dashboard showed it. Do not decode it."""
    # isascii(): str.isdigit() also accepts digits like "²", which int() rejects.
    if not timestamp or not signature_header or not (timestamp.isascii() and timestamp.isdigit()):
        return False
    now = time.time() if now is None else now
    if abs(int(now) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(secret.encode("utf-8"),
                        timestamp.encode("ascii") + b"." + raw_body,
                        hashlib.sha256).hexdigest().encode("ascii")

    # During a secret rotation the header carries two values: "v1=<new>,v1=<old>".
    for part in signature_header.split(","):
        version, _, value = part.strip().partition("=")
        # Compare bytes: compare_digest raises on a str with non-ASCII characters.
        if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
            return True
    return False

With Flask, request.get_data() returns the raw bytes:

import os
from flask import Flask, abort, request
from verify import verify_orqestra_signature

app = Flask(__name__)
SECRET = os.environ["ORQESTRA_WEBHOOK_SECRET"]

@app.post("/webhooks/orqestra")
def orqestra_webhook():
    if not verify_orqestra_signature(request.get_data(),
                                     request.headers.get("X-Orqestra-Timestamp"),
                                     request.headers.get("X-Orqestra-Signature"),
                                     SECRET):
        abort(401)
    save_to_inbox(request.get_json())  # your code; ignore an event id you already have
    return "", 200

PHP

<?php

/**
 * $rawBody: the request body exactly as received (file_get_contents('php://input')).
 * $secret:  the signing secret exactly as the dashboard showed it. Do not decode it.
 */
function verifyOrqestraSignature(
    string $rawBody,
    ?string $timestamp,
    ?string $signatureHeader,
    string $secret,
    ?int $now = null
): bool {
    if (!$timestamp || !$signatureHeader || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > 300) {
        return false;
    }

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

    // During a secret rotation the header carries two values: "v1=<new>,v1=<old>".
    foreach (explode(',', $signatureHeader) as $part) {
        [$version, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }
    return false;
}
require __DIR__ . '/verify.php';

$raw = file_get_contents('php://input');
$ok = verifyOrqestraSignature(
    $raw,
    $_SERVER['HTTP_X_ORQESTRA_TIMESTAMP'] ?? null,
    $_SERVER['HTTP_X_ORQESTRA_SIGNATURE'] ?? null,
    getenv('ORQESTRA_WEBHOOK_SECRET')
);
if (!$ok) {
    http_response_code(401);
    exit;
}
saveToInbox(json_decode($raw, true)); // your code
http_response_code(200);

In Laravel, use $request->getContent() for the raw body and $request->header('X-Orqestra-Signature') for the headers. Put the route in routes/api.php or exclude it from CSRF verification. Otherwise Laravel answers 419, which Orqestra records as a permanent failure.

Go

package orqestra

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

// VerifySignature checks an Orqestra webhook delivery.
// rawBody is the request body exactly as received; secret is the signing
// secret exactly as the dashboard showed it. Do not decode it.
func VerifySignature(rawBody []byte, timestamp, signatureHeader, secret string, now time.Time) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || ts <= 0 || signatureHeader == "" {
		return false
	}
	if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	// During a secret rotation the header carries two values: "v1=<new>,v1=<old>".
	for _, part := range strings.Split(signatureHeader, ",") {
		version, value, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok || version != "v1" {
			continue
		}
		if got, err := hex.DecodeString(value); err == nil && hmac.Equal(got, expected) {
			return true
		}
	}
	return false
}
func orqestraWebhook(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}
	if !orqestra.VerifySignature(raw,
		r.Header.Get("X-Orqestra-Timestamp"),
		r.Header.Get("X-Orqestra-Signature"),
		os.Getenv("ORQESTRA_WEBHOOK_SECRET"),
		time.Now()) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	// json.Unmarshal(raw, &event), store it, then:
	w.WriteHeader(http.StatusOK)
}

Check your implementation

Feed these exact values to your code. If it produces the same header, it is correct. The body is a real webhook.test event, byte for byte. There is no trailing newline, and the secret is an example only.

Secret

XwQbesXDB9_dpkQ2QG4NdehV0KEJPVRXv4WenBnsvm8

X-Orqestra-Timestamp

1790216109

Raw body — one line, exactly as sent

{"apiVersion":"2026-09-01","channel":{"id":"cmf0q9b2d0003chn7docs0002","label":"Customer Care WA","type":"WHATSAPP"},"createdAt":"2026-09-24T02:15:07.000Z","data":{"test":true},"id":"evt_0b5e2f4c9a7d4c1e8f3a6b2d9c0e1f47","organizationId":"cmf0q8z1k0000org7docs0001","type":"webhook.test"}

Expected X-Orqestra-Signature

v1=eb9674f386191f3f3f774ef5eb8120f7e60fb664723663ea32d45ab68c6179c1

That timestamp is in the past, so turn off (or widen) the 5-minute check while you compare.

Common mistakes

  • Verifying a re-serialized body. Parsing the JSON and stringifying it again changes spacing, key order and character escaping, so the bytes no longer match. Frameworks that parse the body automatically are the usual cause. Verify the bytes exactly as received.
  • Decoding the secret. The secret looks like base64, but it isn't meant to be decoded. Use the string exactly as shown, as UTF-8 text, as the HMAC key.
  • Reading only the first v1= value. This works until you rotate, then breaks for up to 24 hours. Check every value.
  • Comparing with ==. Use crypto.timingSafeEqual, hmac.compare_digest, hash_equals or hmac.Equal, so the comparison time doesn't leak how much of the signature matched.
  • A drifting server clock. The 5-minute window assumes your clock is right. Keep NTP on.
  • Using the timestamp as the event time. It is re-generated on every attempt, so a retry hours later carries a fresh value. The event time is createdAt in the body.

Rotating the secret

Rotate when a secret may have leaked, when someone with access leaves, or on a schedule. Choose Rotate signing secret from the endpoint's menu. The new secret is shown once.

For the next 24 hours, every delivery is signed with both secrets, new one first:

X-Orqestra-Signature: v1=<signature with new secret>,v1=<signature with old secret>

A receiver that checks every value keeps working throughout. Switch it to the new secret any time in those 24 hours. After that, only the new signature is sent. Rotation doesn't pause the endpoint or drop any deliveries.

If the old secret leaked, switch your receiver to the new secret straight away rather than waiting. Signatures made with the old secret stay in the header until the 24 hours are up, but a receiver holding only the new secret ignores them.