Docs menu

Verifying signatures

Every delivery is signed with your endpoint's secret. Check the signature before you trust a single byte of the body.

The header

X-OH-Signature: t=1760083200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — when the delivery was signed, in Unix seconds.
  • v1 — the hex HMAC-SHA256 of <t>.<raw body>, keyed with your endpoint secret exactly as it was shown (whsec_…, prefix included). X-OH-Signature-Algorithm: HMAC-SHA256-RAW names this algorithm.

How to verify

  1. Take the raw request body as bytes. Re-serialising parsed JSON changes whitespace and key order and breaks the signature.
  2. Read X-OH-Signature by name in any capitalisation. HTTP header names are case-insensitive, and what reaches your code is often X-Oh-Signature (php-fpm, Go, many proxies) or x-oh-signature (Node).
  3. Split the header on , into t and one or more v1 values.
  4. Reject the delivery if t is more than 300 seconds from your clock — this stops replays of an old delivery.
  5. Compute HMAC-SHA256(secret, t + "." + body) as lowercase hex.
  6. Compare it with each v1 in constant time; accept if any one matches.

After a secret rotation the header is signed with the new secret straight away, so keep a single secret per endpoint and update it when you rotate.

Verifier code

Copy the function for your language. Each file is run, as published here, against signatures made by OfficeHero's own signing code — a non-ASCII body signed at the current time, checked with the default 300-second tolerance: it must accept that delivery, one carrying several v1 values and one with whitespace around the header, and refuse a tampered body, a wrong secret, a timestamp six minutes old or six minutes ahead, a malformed t (too long, signed or not ASCII digits) and an upper-case v1. The PHP, TypeScript and Python …DualSign helpers are also given the request's headers spelled X-Oh-Signature, x-oh-signature and X-OH-SIGNATURE, as lists and twice over, and must still refuse every forged copy.

import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

/**
 * Verify an OfficeHero webhook's `X-OH-Signature` header against the raw
 * request body (the exact bytes you received — not re-serialised JSON).
 *
 * Header shape: `t=<unix seconds>,v1=<hex HMAC-SHA256>`. The HMAC key is
 * the endpoint secret exactly as it was shown once (`whsec_…`, prefix
 * included) and the signed message is `<t>.<raw body>`. Deliveries older
 * than `toleranceSec` (default 300 s) are rejected to stop replays.
 *
 * The canonical header switches to a new secret the moment you rotate it,
 * so it carries one `v1=`; the parser still accepts several and passes if
 * any matches. Source: developers.officehero.io/docs/webhooks/signatures
 *
 * Read the header by name in any capitalisation — Express `req.get()`,
 * Node's lower-cased `req.headers['x-oh-signature']`, a Fetch
 * `request.headers.get()` — since proxies may send `X-Oh-Signature`. A
 * missing header (`undefined`, `null`) is refused; a list of values passes
 * if any one does.
 */
export function ohVerifySignature(
  secret: string,
  rawBody: string,
  header: string | readonly string[] | null | undefined,
  toleranceSec = 300,
): boolean {
  if (Array.isArray(header)) {
    return header.some((h) => ohVerifySignature(secret, rawBody, h, toleranceSec));
  }
  if (typeof header !== 'string') return false;
  const parts: Record<string, string[]> = {};
  for (const kv of header.split(',')) {
    const trimmed = kv.trim();
    if (!trimmed.includes('=')) continue;
    const eq = trimmed.indexOf('=');
    const k = trimmed.slice(0, eq);
    const v = trimmed.slice(eq + 1);
    (parts[k] ??= []).push(v);
  }
  const tRaw = parts.t?.[0] ?? '';
  if (!/^\d+$/.test(tRaw)) return false;
  const t = Number.parseInt(tRaw, 10);
  if (!Number.isFinite(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSec) return false;
  const v1 = parts.v1 ?? [];
  if (v1.length === 0) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.some((candidate) => {
    if (!/^[a-f0-9]{64}$/.test(candidate)) return false;
    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(candidate, 'hex');
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

/**
 * Only for verifiers written against the pre-2026 algorithm: prefer the
 * canonical header, fall back to `X-OH-Signature-Legacy` (HMAC key =
 * SHA-256 hex of the secret without its `whsec_` prefix). The legacy
 * header is sent until 2026-12-17 (`X-OH-Signature-Deprecation-Date`);
 * during the 24 h after a rotation it carries two `v1=` values, old and
 * new. New integrations call {@link ohVerifySignature} on the canonical
 * header only.
 *
 * `headers` is the request's whole header set: Node's `req.headers`, a
 * Fetch `Headers`, or any plain object (an AWS Lambda event's `headers`).
 * Names match in any capitalisation; a value may be a string or a list.
 */
export function ohVerifyDualSign(
  rawSecret: string,
  rawBody: string,
  headers: OhHeaders,
  toleranceSec = 300,
): boolean {
  const verify = (secret: string, values: string[]): boolean =>
    values.some((value) => ohVerifySignature(secret, rawBody, value, toleranceSec));
  if (verify(rawSecret, ohHeaderValues(headers, 'X-OH-Signature'))) return true;
  // Legacy algorithm — HMAC key was the SHA-256 of the secret-body
  // (everything after the `whsec_` prefix). Subscribers that
  // implemented the broken pre-G3-1 algorithm need this branch.
  const secretBody = rawSecret.startsWith('whsec_') ? rawSecret.slice('whsec_'.length) : rawSecret;
  const storedHashEquivalent = createHash('sha256').update(secretBody).digest('hex');
  return verify(storedHashEquivalent, ohHeaderValues(headers, 'X-OH-Signature-Legacy'));
}

/** Request headers as frameworks hand them over: a plain object or a Fetch `Headers`. */
export type OhHeaders =
  | Readonly<Record<string, string | readonly string[] | null | undefined>>
  | { get(name: string): string | null };

/**
 * Every value of header `name`, trimmed. HTTP header names are
 * case-insensitive (RFC 9110 §5.1): `X-OH-Signature`, `X-Oh-Signature` and
 * `x-oh-signature` are the same header. A header that arrives twice
 * yields both values.
 */
export function ohHeaderValues(headers: OhHeaders, name: string): string[] {
  const values: string[] = [];
  const add = (value: unknown): void => {
    if (typeof value === 'string' && value.trim() !== '') values.push(value.trim());
  };
  if (typeof headers.get === 'function') {
    add((headers as { get(name: string): string | null }).get(name));
    return values;
  }
  const wanted = name.toLowerCase();
  for (const [key, value] of Object.entries(headers)) {
    if (key.trim().toLowerCase() !== wanted) continue;
    if (Array.isArray(value)) value.forEach(add);
    else add(value);
  }
  return values;
}
"""Verify an OfficeHero webhook's ``X-OH-Signature`` header.

Header shape: ``t=<unix seconds>,v1=<hex HMAC-SHA256>``. The HMAC key is
the endpoint secret exactly as it was shown once (``whsec_…``, prefix
included); the signed message is ``<t>.<raw body bytes>``. Deliveries
older than ``tolerance_sec`` (default 300 s) are rejected to stop replays.

The canonical header switches to a new secret the moment you rotate it,
so it carries one ``v1=``; the parser accepts several and passes if any
matches. New integrations call ``oh_verify_signature`` on the canonical
header only; ``oh_verify_dual_sign`` exists for verifiers built against
the legacy header, which is sent until 2026-12-17.

Drop this snippet straight into your project. The OfficeHero repository
round-trips it against the production PHP ``Signer`` on every change.
Source: https://developers.officehero.io/docs/webhooks/signatures
"""
from __future__ import annotations

import hashlib
import hmac
import re
import time
from typing import Iterable, List, Mapping, Sequence, Tuple, Union


# ASCII digits only: ``\d`` would also take other scripts' digits (``٣``).
_DIGITS_RE = re.compile(r"^[0-9]+$")
_HEX_RE = re.compile(r"^[a-f0-9]{64}$")


def oh_verify_signature(
    secret: str,
    raw_body: bytes,
    header: Union[str, Sequence[str], None],
    tolerance_sec: int = 300,
) -> bool:
    """Return ``True`` iff at least one ``v1=`` candidate matches.

    Parameters
    ----------
    secret:
        Raw ``whsec_...`` secret as displayed at endpoint creation.
    raw_body:
        Untouched request body bytes. Decode-after-receive will mangle
        any non-ASCII payload — keep this as bytes.
    header:
        Value of the ``X-OH-Signature`` HTTP header. Read it by name in any
        capitalisation — ``request.headers.get("X-OH-Signature")`` in
        Flask, Django or Starlette, ``oh_header_values(...)`` on a plain
        dict — since proxies may send ``X-Oh-Signature``. ``None`` (no
        header) is rejected; a list of values passes if any one does.
    tolerance_sec:
        Max clock skew (default 300s = 5 min).
    """
    if isinstance(header, (list, tuple)):
        return any(oh_verify_signature(secret, raw_body, h, tolerance_sec) for h in header)
    if not isinstance(header, str):
        return False
    parts: dict[str, list[str]] = {}
    for kv in header.split(","):
        kv = kv.strip()
        if "=" not in kv:
            continue
        k, _, v = kv.partition("=")
        parts.setdefault(k, []).append(v)

    t_raw = (parts.get("t") or [""])[0]
    if not _DIGITS_RE.match(t_raw):
        return False
    t = int(t_raw)
    if abs(int(time.time()) - t) > tolerance_sec:
        return False

    v1_list = parts.get("v1") or []
    if not v1_list:
        return False

    message = f"{t}.".encode("ascii") + raw_body
    expected = hmac.new(secret.encode("ascii"), message, hashlib.sha256).hexdigest()
    for candidate in v1_list:
        if not _HEX_RE.match(candidate):
            continue
        if hmac.compare_digest(expected, candidate):
            return True
    return False


OhHeaders = Union[
    Mapping[str, Union[str, Sequence[str]]],
    Iterable[Tuple[str, str]],
]


def oh_verify_dual_sign(
    raw_secret: str,
    raw_body: bytes,
    headers: OhHeaders,
    tolerance_sec: int = 300,
) -> bool:
    """Prefer the canonical header; fall back to ``X-OH-Signature-Legacy``.

    The legacy HMAC key is the SHA-256 hex of the secret without its
    ``whsec_`` prefix; during the 24 h after a rotation that header carries
    two ``v1=`` values (old and new). New integrations should NOT call this
    helper — call ``oh_verify_signature`` on ``X-OH-Signature`` only.

    ``headers`` is the request's whole header set: ``request.headers``, a
    plain dict (``dict(request.headers)``, an AWS Lambda event's
    ``headers``) or a list of ``(name, value)`` pairs. Names match in any
    capitalisation; a value may be a string or a list of strings.
    """
    canonical = oh_header_values(headers, "X-OH-Signature")
    if oh_verify_signature(raw_secret, raw_body, canonical, tolerance_sec):
        return True
    # Legacy algorithm — HMAC key was the SHA-256 of the secret-body
    # (everything after the `whsec_` prefix). Subscribers that
    # implemented the broken pre-G3-1 algorithm need this branch.
    secret_body = raw_secret[len("whsec_"):] if raw_secret.startswith("whsec_") else raw_secret
    stored_hash_equivalent = hashlib.sha256(secret_body.encode("ascii")).hexdigest()
    legacy = oh_header_values(headers, "X-OH-Signature-Legacy")
    return oh_verify_signature(stored_hash_equivalent, raw_body, legacy, tolerance_sec)


def oh_header_values(headers: OhHeaders, name: str) -> List[str]:
    """Every value of header ``name``, stripped.

    HTTP header names are case-insensitive (RFC 9110 §5.1):
    ``X-OH-Signature``, ``X-Oh-Signature`` and ``x-oh-signature`` are the
    same header. A header that arrives twice yields both values.
    """
    wanted = name.lower()
    pairs = headers.items() if isinstance(headers, Mapping) or hasattr(headers, "items") else headers
    values: List[str] = []
    for key, value in pairs:
        if not isinstance(key, str) or key.strip().lower() != wanted:
            continue
        for item in value if isinstance(value, (list, tuple)) else [value]:
            if isinstance(item, str) and item.strip():
                values.append(item.strip())
    return values
<?php

declare(strict_types=1);

/**
 * Verify an OfficeHero webhook's `X-OH-Signature` header against the raw
 * request body (`file_get_contents('php://input')`, never re-encoded JSON).
 *
 * Header shape: `t=<unix seconds>,v1=<hex HMAC-SHA256>`. The HMAC key is
 * the endpoint secret exactly as it was shown once (`whsec_…`, prefix
 * included); the signed message is `<t>.<raw body>`. Deliveries older than
 * `$toleranceSec` (default 300 s) are rejected to stop replays.
 *
 * The canonical header switches to a new secret the moment you rotate it,
 * so it carries one `v1=`; the parser accepts several and passes if any
 * matches. Source: https://developers.officehero.io/docs/webhooks/signatures
 *
 * Header names are case-insensitive, and php-fpm's getallheaders() and many
 * proxies hand this one over as `X-Oh-Signature`. Read it with
 * `$_SERVER['HTTP_X_OH_SIGNATURE'] ?? ''`, a PSR-7
 * `$request->getHeaderLine('X-OH-Signature')`, or
 * `ohHeaderValues(getallheaders(), 'X-OH-Signature')[0] ?? ''` — never
 * `getallheaders()['X-OH-Signature']`.
 *
 * @param string $secret     Raw `whsec_...` secret as displayed at creation.
 * @param string $rawBody    Untouched request body bytes.
 * @param string $header     Value of the `X-OH-Signature` HTTP header.
 * @param int    $toleranceSec Max clock skew (default 300s = 5 min).
 */
function ohVerifySignature(string $secret, string $rawBody, string $header, int $toleranceSec = 300): bool
{
    /** @var array<string, list<string>> $parts */
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        $kv = trim($kv);
        if ($kv === '' || !str_contains($kv, '=')) {
            continue;
        }
        [$k, $v] = explode('=', $kv, 2);
        $parts[$k][] = $v;
    }
    if (!isset($parts['t'][0]) || empty($parts['v1'])) {
        return false;
    }
    if (!ctype_digit($parts['t'][0])) {
        return false;
    }
    $t = (int) $parts['t'][0];
    if (abs(time() - $t) > $toleranceSec) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($parts['v1'] as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }
    return false;
}

/**
 * Only for verifiers written against the legacy header: prefer the
 * canonical `X-OH-Signature`, fall back to `X-OH-Signature-Legacy`, whose
 * HMAC key is the SHA-256 hex of the secret without its `whsec_` prefix.
 * The legacy header is sent until 2026-12-17; during the 24 h after a
 * rotation it carries two `v1=` values (old and new).
 *
 * Pass every request header: getallheaders(), a PSR-7
 * `$request->getHeaders()` or your framework's map. Names match in any
 * capitalisation and a value may be a string or a list of strings.
 *
 * @param array<array-key, string|list<string>> $headers Header name => value(s).
 */
function ohVerifyDualSign(string $rawSecret, string $rawBody, array $headers, int $toleranceSec = 300): bool
{
    foreach (ohHeaderValues($headers, 'X-OH-Signature') as $canonical) {
        if (ohVerifySignature($rawSecret, $rawBody, $canonical, $toleranceSec)) {
            return true;
        }
    }
    // Legacy algorithm — HMAC key is SHA-256 of the secret-body
    // (everything after the `whsec_` prefix). Subscribers that
    // implemented the broken pre-G3-1 algorithm need this branch.
    $secretBody = str_starts_with($rawSecret, 'whsec_')
        ? substr($rawSecret, strlen('whsec_'))
        : $rawSecret;
    $storedHashEquivalent = hash('sha256', $secretBody);
    foreach (ohHeaderValues($headers, 'X-OH-Signature-Legacy') as $legacy) {
        if (ohVerifySignature($storedHashEquivalent, $rawBody, $legacy, $toleranceSec)) {
            return true;
        }
    }
    return false;
}

/**
 * Every value of header `$name` in `$headers`, trimmed. HTTP header names
 * are case-insensitive (RFC 9110 §5.1): `X-OH-Signature`, `X-Oh-Signature`
 * and `x-oh-signature` are the same header. A value may be a string or a
 * list of strings (PSR-7); a header that arrives twice yields both values.
 *
 * @param array<array-key, mixed> $headers Header name => value(s).
 * @return list<string>
 */
function ohHeaderValues(array $headers, string $name): array
{
    $values = [];
    foreach ($headers as $key => $value) {
        if (!is_string($key) || strcasecmp(trim($key), $name) !== 0) {
            continue;
        }
        foreach (is_array($value) ? $value : [$value] as $item) {
            if (is_string($item) && trim($item) !== '') {
                $values[] = trim($item);
            }
        }
    }
    return $values;
}
// Package officehero verifies OfficeHero webhook signatures.
//
// Header shape: `t=<unix seconds>,v1=<hex HMAC-SHA256>`. The HMAC key is the
// endpoint secret exactly as it was shown once (`whsec_…`, prefix included);
// the signed message is `<t>.<raw body>`. Deliveries older than tolerance
// (300 s is a good default) are rejected to stop replays. The header may carry
// several v1 entries; the delivery is genuine if any one matches.
//
// Source: https://developers.officehero.io/docs/webhooks/signatures
package officehero

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

// VerifySignature reports whether header (the X-OH-Signature value) signs
// rawBody (the exact bytes received) with secret. Read the header with
// r.Header.Get("X-OH-Signature"), which matches the name in any
// capitalisation (proxies may send X-Oh-Signature).
func VerifySignature(secret string, rawBody []byte, header string, tolerance time.Duration) bool {
	var timestamp int64 = -1
	var candidates []string
	for _, part := range strings.Split(header, ",") {
		key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok {
			continue
		}
		switch key {
		case "t":
			// Unix seconds: 1 to 18 ASCII digits, no sign (ParseInt alone
			// would take "+1760270000"), so the parse cannot overflow.
			if !isDigits(value, 18) {
				return false
			}
			t, err := strconv.ParseInt(value, 10, 64)
			if err != nil {
				return false
			}
			timestamp = t
		case "v1":
			candidates = append(candidates, value)
		}
	}
	if timestamp < 0 || len(candidates) == 0 {
		return false
	}
	if math.Abs(float64(time.Now().Unix()-timestamp)) > tolerance.Seconds() {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, candidate := range candidates {
		// The signature is 64 lowercase hex characters; hex.DecodeString
		// would also take upper case.
		if !isLowerHex(candidate, 2*sha256.Size) {
			continue
		}
		got, err := hex.DecodeString(candidate)
		if err == nil && hmac.Equal(expected, got) {
			return true
		}
	}
	return false
}

// isDigits reports whether s is 1 to maxLen ASCII digits.
func isDigits(s string, maxLen int) bool {
	if len(s) == 0 || len(s) > maxLen {
		return false
	}
	for i := 0; i < len(s); i++ {
		if s[i] < '0' || s[i] > '9' {
			return false
		}
	}
	return true
}

// isLowerHex reports whether s is exactly n lowercase hex characters.
func isLowerHex(s string, n int) bool {
	if len(s) != n {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
			return false
		}
	}
	return true
}
# Verify an OfficeHero webhook's X-OH-Signature header.
#
# Header shape: "t=<unix seconds>,v1=<hex HMAC-SHA256>". The HMAC key is the
# endpoint secret exactly as it was shown once ("whsec_…", prefix included);
# the signed message is "<t>.<raw body>". Deliveries older than tolerance
# seconds (default 300) are rejected to stop replays. The header may carry
# several v1 entries; the delivery is genuine if any one matches.
#
# Pass the raw request body (request.raw_post in Rails, request.body.read in
# Rack/Sinatra) — never re-serialised JSON. Read the header with
# request.headers["X-OH-Signature"] (Rails) or
# request.get_header("HTTP_X_OH_SIGNATURE") (Rack): both match the name in
# any capitalisation, and proxies may send X-Oh-Signature.
#
# Source: https://developers.officehero.io/docs/webhooks/signatures
require "openssl"

module OfficeHero
  def self.verify_signature(secret, raw_body, header, tolerance = 300)
    timestamp = nil
    candidates = []
    header.to_s.split(",").each do |part|
      key, value = part.strip.split("=", 2)
      next if value.nil?

      case key
      when "t"
        return false unless value.match?(/\A\d+\z/)

        timestamp = value.to_i
      when "v1"
        candidates << value
      end
    end
    return false if timestamp.nil? || candidates.empty?
    return false if (Time.now.to_i - timestamp).abs > tolerance

    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
    candidates.any? { |candidate| secure_compare(expected, candidate) }
  end

  # Constant-time string comparison (works on every Ruby version).
  def self.secure_compare(a, b)
    return false unless a.bytesize == b.bytesize

    left = a.unpack("C*")
    result = 0
    b.each_byte { |byte| result |= byte ^ left.shift }
    result.zero?
  end
end
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
 * Verify an OfficeHero webhook's X-OH-Signature header.
 *
 * <p>Header shape: {@code t=<unix seconds>,v1=<hex HMAC-SHA256>}. The HMAC key is the endpoint
 * secret exactly as it was shown once ({@code whsec_…}, prefix included); the signed message is
 * {@code <t>.<raw body>}. Deliveries older than {@code toleranceSeconds} (300 is a good default)
 * are rejected to stop replays. The header may carry several v1 entries; the delivery is genuine
 * if any one matches. Pass the raw request body bytes, never re-serialised JSON. Read the header
 * with {@code request.getHeader("X-OH-Signature")}, which matches the name in any capitalisation
 * (proxies may send {@code X-Oh-Signature}).
 *
 * <p>Source: https://developers.officehero.io/docs/webhooks/signatures
 */
public final class OfficeHeroWebhooks {
  private OfficeHeroWebhooks() {}

  public static boolean verifySignature(String secret, byte[] rawBody, String header, long toleranceSeconds) {
    if (header == null) {
      return false;
    }
    long timestamp = -1;
    List<String> candidates = new ArrayList<>();
    for (String part : header.split(",")) {
      String[] kv = part.trim().split("=", 2);
      if (kv.length != 2) {
        continue;
      }
      if (kv[0].equals("t")) {
        // At most 18 digits, so parseLong cannot overflow.
        if (!kv[1].matches("[0-9]{1,18}")) {
          return false;
        }
        timestamp = Long.parseLong(kv[1]);
      } else if (kv[0].equals("v1")) {
        candidates.add(kv[1]);
      }
    }
    if (timestamp < 0 || candidates.isEmpty()) {
      return false;
    }
    if (Math.abs(System.currentTimeMillis() / 1000 - timestamp) > toleranceSeconds) {
      return false;
    }
    byte[] expected;
    try {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
      mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
      expected = mac.doFinal(rawBody);
    } catch (java.security.GeneralSecurityException e) {
      return false;
    }
    for (String candidate : candidates) {
      if (!candidate.matches("[0-9a-f]{64}")) {
        continue;
      }
      if (MessageDigest.isEqual(expected, HexFormat.of().parseHex(candidate))) {
        return true;
      }
    }
    return false;
  }
}
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

/// <summary>
/// Verify an OfficeHero webhook's X-OH-Signature header.
///
/// Header shape: <c>t=&lt;unix seconds&gt;,v1=&lt;hex HMAC-SHA256&gt;</c>. The HMAC key is the
/// endpoint secret exactly as it was shown once (<c>whsec_…</c>, prefix included); the signed
/// message is <c>&lt;t&gt;.&lt;raw body&gt;</c>. Deliveries older than <c>toleranceSeconds</c>
/// (300 is a good default) are rejected to stop replays. The header may carry several v1
/// entries; the delivery is genuine if any one matches. Pass the raw request body bytes, never
/// re-serialised JSON. Read the header with <c>Request.Headers["X-OH-Signature"]</c>, which
/// matches the name in any capitalisation (proxies may send <c>X-Oh-Signature</c>).
///
/// Source: https://developers.officehero.io/docs/webhooks/signatures
/// </summary>
public static class OfficeHeroWebhooks
{
    public static bool VerifySignature(string secret, byte[] rawBody, string header, long toleranceSeconds = 300)
    {
        if (string.IsNullOrEmpty(header)) return false;
        long timestamp = -1;
        var candidates = new List<string>();
        foreach (var part in header.Split(','))
        {
            var kv = part.Trim().Split('=', 2);
            if (kv.Length != 2) continue;
            if (kv[0] == "t")
            {
                // At most 18 digits, so the parse below cannot overflow.
                if (!Regex.IsMatch(kv[1], "^[0-9]{1,18}$")) return false;
                timestamp = long.Parse(kv[1]);
            }
            else if (kv[0] == "v1")
            {
                candidates.Add(kv[1]);
            }
        }
        if (timestamp < 0 || candidates.Count == 0) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > toleranceSeconds) return false;

        var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
        var message = new byte[prefix.Length + rawBody.Length];
        Buffer.BlockCopy(prefix, 0, message, 0, prefix.Length);
        Buffer.BlockCopy(rawBody, 0, message, prefix.Length, rawBody.Length);
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var expected = hmac.ComputeHash(message);

        foreach (var candidate in candidates)
        {
            if (!Regex.IsMatch(candidate, "^[0-9a-f]{64}$")) continue;
            if (CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(candidate))) return true;
        }
        return false;
    }
}

A complete receiver

Node.js with Express — verify, queue, answer 2xx:

import express from 'express';
import { ohVerifySignature } from './verifier';

const app = express();

// Verify against the RAW body: parse JSON only after the signature checks out.
app.post('/officehero-webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const header = req.get('X-OH-Signature') ?? '';
  if (!ohVerifySignature(process.env.OFFICEHERO_WEBHOOK_SECRET!, rawBody, header)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(rawBody);
  queue.add(event);          // do the work later…
  res.sendStatus(204);       // …and answer within 15 seconds
});

The legacy header

Until 2026-12-17 (X-OH-Signature-Deprecation-Date) deliveries also carry X-OH-Signature-Legacy: the same format, but keyed with the SHA-256 hex of the secret without its whsec_ prefix. It exists only for receivers written against the pre-2026 algorithm; the …DualSign helpers in the PHP, TypeScript and Python files check it as a fallback. They take the request's whole header map (getallheaders(), Node's req.headers, a Fetch Headers, request.headers) and find both headers whatever the capitalisation. New receivers verify X-OH-Signature only.

An endpoint whose canonical_signing_enabled is false (it was created while the secret vault was unavailable) sends the legacy value in X-OH-Signature. Rotate its secret to switch it to the documented algorithm.

Troubleshooting

SymptomCause
Every signature failsYour framework parsed the JSON before you read it. Use the raw body (express.raw, request.get_data(), php://input).
The header is missing, or the …DualSign helper refuses every deliveryYour code looks the header up as X-OH-Signature in a map that spells it X-Oh-Signature (php-fpm's getallheaders(), proxies). Header names are case-insensitive: use your framework's header accessor, and copy the helpers again if yours predate 2026-10-12 — the current ones match any capitalisation.
Fails since a rotationYou are still using the old secret. Store the secret returned by rotate_secret.
Fails only sometimesYour clock is off. Sync it with NTP; the tolerance is 300 seconds.
Fails on non-ASCII textThe body was decoded and re-encoded. Hash the bytes as received.