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-RAWnames this algorithm.
How to verify
- Take the raw request body as bytes. Re-serialising parsed JSON changes whitespace and key order and breaks the signature.
- Read
X-OH-Signatureby name in any capitalisation. HTTP header names are case-insensitive, and what reaches your code is oftenX-Oh-Signature(php-fpm, Go, many proxies) orx-oh-signature(Node). - Split the header on
,intotand one or morev1values. - Reject the delivery if
tis more than 300 seconds from your clock — this stops replays of an old delivery. - Compute
HMAC-SHA256(secret, t + "." + body)as lowercase hex. - Compare it with each
v1in 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
endimport 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=<unix seconds>,v1=<hex HMAC-SHA256></c>. The HMAC key is the
/// endpoint secret exactly as it was shown once (<c>whsec_…</c>, prefix included); the signed
/// message is <c><t>.<raw body></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
| Symptom | Cause |
|---|---|
| Every signature fails | Your 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 delivery | Your 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 rotation | You are still using the old secret. Store the secret returned by rotate_secret. |
| Fails only sometimes | Your clock is off. Sync it with NTP; the tolerance is 300 seconds. |
| Fails on non-ASCII text | The body was decoded and re-encoded. Hash the bytes as received. |