Webhook signature verification
Overview
When you include a webhook object in a conversion request, PolyDoc delivers the generated file to your URL in an outbound HTTP request. That request includes an X-Polydoc-Signature header so you can confirm the payload really came from PolyDoc, was not modified in transit, and is not a replay of an older delivery.
Signature algorithm
- Algorithm: HMAC-SHA256
- Key: your account's webhook signature secret (same value as in the dashboard)
- Message: the ASCII bytes
<t>.(the timestamp followed by a literal dot) immediately followed by the exact binary body PolyDoc sends X-Polydoc-Signaturevalue:t=<unix-seconds>,v1=<hex>— the Unix timestamp the signature was computed against, plus the lowercase hexadecimal HMAC digest (64 characters)- Freshness window: we recommend rejecting requests where
|now - t| > 300seconds. This is what makes the signature replay-resistant — without a fresh timestamp, a captured payload + signature pair would be re-deliverable indefinitely.
Your secret is shown under Security → Webhook signature key in the PolyDoc dashboard. If you regenerate the key, update your verification logic immediately; old signatures will no longer validate.
Outbound request behavior
PolyDoc sets Content-Type to the file type being delivered. Any custom headers you configured on the webhook are merged in; PolyDoc always supplies X-Polydoc-Signature (and, during the legacy deprecation window, also X-Signature).
For synchronous webhook delivery, a failed delivery (non-success HTTP status from your endpoint, after retries) surfaces as a 502 response on the original conversion request. For asynchronous delivery, the conversion returns 202 while delivery runs in the background — use signature verification on the webhook request itself, not on the JSON acceptance response.
Each retry attempt carries a freshly computed X-Polydoc-Signature with a current timestamp. If your endpoint takes longer than the freshness window to respond and PolyDoc retries, the new attempt will still verify cleanly against now.
Example verification
const crypto = require('crypto');
const FRESHNESS_SECONDS = 300;
/**
* @param {Buffer} rawBody - Exact body bytes (e.g. req.read() before parsers run)
* @param {string} headerValue - X-Polydoc-Signature header ("t=<unix>,v1=<hex>")
* @param {string} secret - Webhook signature key from the dashboard
*/
function verifyPolyDocWebhookSignature(rawBody, headerValue, secret) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/i.exec(String(headerValue).trim());
if (!match) return false;
const [, tStr, v1Hex] = match;
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - Number(tStr)) > FRESHNESS_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${tStr}.`)
.update(rawBody)
.digest('hex');
try {
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1Hex.toLowerCase(), 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}
import hmac
import hashlib
import re
import time
FRESHNESS_SECONDS = 300
_HEADER_RE = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$", re.IGNORECASE)
def verify_polydoc_webhook_signature(
raw_body: bytes, header_value: str, secret: str
) -> bool:
match = _HEADER_RE.match(header_value.strip())
if not match:
return False
t_str, v1_hex = match.group(1), match.group(2).lower()
if abs(int(time.time()) - int(t_str)) > FRESHNESS_SECONDS:
return False
message = f"{t_str}.".encode("utf-8") + raw_body
expected = hmac.new(
secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
if len(expected) != len(v1_hex):
return False
return hmac.compare_digest(expected, v1_hex)
require 'openssl'
FRESHNESS_SECONDS = 300
# raw_body: binary string, e.g. request.body.read.b (before JSON parsers)
# header_value: value of X-Polydoc-Signature ("t=<unix>,v1=<hex>")
# secret: webhook signature key (UTF-8 string)
def verify_polydoc_webhook_signature(raw_body, header_value, secret)
match = header_value.strip.match(/\At=(\d+),v1=([0-9a-f]{64})\z/i)
return false unless match
t_str, v1_hex = match[1], match[2].downcase
return false if (Time.now.to_i - t_str.to_i).abs > FRESHNESS_SECONDS
message = +"#{t_str}.".b
message << raw_body
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, message)
return false unless expected.bytesize == v1_hex.bytesize
OpenSSL.fixed_length_secure_compare(expected, v1_hex)
rescue OpenSSL::OpenSSLError, ArgumentError
false
end
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
const freshnessSeconds = 300
var headerRe = regexp.MustCompile(`^t=(\d+),v1=([0-9a-fA-F]{64})$`)
// VerifyPolyDocWebhookSignature checks X-Polydoc-Signature for the raw webhook body.
func VerifyPolyDocWebhookSignature(rawBody []byte, headerValue, secret string) bool {
match := headerRe.FindStringSubmatch(strings.TrimSpace(headerValue))
if match == nil {
return false
}
tStr, v1Hex := match[1], strings.ToLower(match[2])
t, err := strconv.ParseInt(tStr, 10, 64)
if err != nil {
return false
}
delta := time.Now().Unix() - t
if delta < -freshnessSeconds || delta > freshnessSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.", tStr)
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1Hex))
}
<?php
const POLYDOC_FRESHNESS_SECONDS = 300;
/**
* @param string $rawBody Binary body exactly as received (file_get_contents('php://input'))
* @param string $headerValue X-Polydoc-Signature ("t=<unix>,v1=<hex>")
* @param string $secret Webhook signature key from the dashboard
*/
function verify_polydoc_webhook_signature(
string $rawBody,
string $headerValue,
string $secret
): bool {
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/i', trim($headerValue), $m)) {
return false;
}
$tStr = $m[1];
$v1Hex = strtolower($m[2]);
if (abs(time() - (int) $tStr) > POLYDOC_FRESHNESS_SECONDS) {
return false;
}
$message = $tStr . '.' . $rawBody;
$expected = hash_hmac('sha256', $message, $secret, false);
return hash_equals($expected, $v1Hex);
}
?>
// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4", subtle = "2.5",
// once_cell = "1.19", regex = "1.10"
use hmac::{Hmac, Mac};
use once_cell::sync::Lazy;
use regex::Regex;
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
const FRESHNESS_SECONDS: i64 = 300;
static HEADER_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^t=(\d+),v1=([0-9a-f]{64})$").unwrap()
});
pub fn verify_polydoc_webhook_signature(
raw_body: &[u8],
header_value: &str,
secret: &str,
) -> bool {
let Some(caps) = HEADER_RE.captures(header_value.trim()) else {
return false;
};
let t_str = caps.get(1).unwrap().as_str();
let v1_hex = caps.get(2).unwrap().as_str().to_lowercase();
let Ok(t) = t_str.parse::<i64>() else { return false };
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (now - t).abs() > FRESHNESS_SECONDS {
return false;
}
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(format!("{}.", t_str).as_bytes());
mac.update(raw_body);
let expected = hex::encode(mac.finalize().into_bytes());
if expected.len() != v1_hex.len() {
return false;
}
expected.as_bytes().ct_eq(v1_hex.as_bytes()).into()
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class PolyDocWebhookSignature {
private static final long FRESHNESS_SECONDS = 300;
private static final Pattern HEADER_RE = Pattern.compile(
"^t=(\\d+),v1=([0-9a-fA-F]{64})$");
public static boolean verify(
byte[] rawBody, String headerValue, String secret) {
if (headerValue == null) return false;
Matcher m = HEADER_RE.matcher(headerValue.trim());
if (!m.matches()) return false;
String tStr = m.group(1);
String v1Hex = m.group(2).toLowerCase();
try {
long t = Long.parseLong(tStr);
long now = Instant.now().getEpochSecond();
if (Math.abs(now - t) > FRESHNESS_SECONDS) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((tStr + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
byte[] received = hexToBytes(v1Hex);
if (received == null || expected.length != received.length) {
return false;
}
return MessageDigest.isEqual(expected, received);
} catch (NoSuchAlgorithmException | InvalidKeyException
| NumberFormatException e) {
return false;
}
}
private static byte[] hexToBytes(String hex) {
int n = hex.length();
if ((n & 1) != 0) return null;
byte[] out = new byte[n / 2];
for (int i = 0; i < n; i += 2) {
int high = Character.digit(hex.charAt(i), 16);
int low = Character.digit(hex.charAt(i + 1), 16);
if (high < 0 || low < 0) return null;
out[i / 2] = (byte) ((high << 4) | low);
}
return out;
}
}
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
public static class PolyDocWebhookSignature
{
private const long FreshnessSeconds = 300;
private static readonly Regex HeaderRe = new(
@"^t=(\d+),v1=([0-9a-fA-F]{64})$", RegexOptions.Compiled);
/// <summary>Verify X-Polydoc-Signature for the raw webhook body bytes.</summary>
public static bool Verify(byte[] rawBody, string headerValue, string secret)
{
if (headerValue is null) return false;
var match = HeaderRe.Match(headerValue.Trim());
if (!match.Success) return false;
var tStr = match.Groups[1].Value;
var v1Hex = match.Groups[2].Value.ToLowerInvariant();
try
{
var t = long.Parse(tStr, CultureInfo.InvariantCulture);
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - t) > FreshnessSeconds) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var prefix = Encoding.UTF8.GetBytes(tStr + ".");
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);
var expected = hmac.ComputeHash(message);
var received = Convert.FromHexString(v1Hex);
return CryptographicOperations.FixedTimeEquals(expected, received);
}
catch (FormatException) { return false; }
catch (OverflowException) { return false; }
}
}
# Verify X-Polydoc-Signature ("t=<unix>,v1=<hex>") against the raw webhook body.
# Requires OpenSSL 1.1+ / 3.x, sed, awk, date.
#
# POLYDOC_WEBHOOK_SECRET — your dashboard webhook signature key
# BODY_FILE — path to the exact bytes PolyDoc POSTed
# X_POLYDOC_SIGNATURE — value of the X-Polydoc-Signature header
t=$(printf '%s' "$X_POLYDOC_SIGNATURE" | sed -n 's/^t=\([0-9][0-9]*\),v1=.*$/\1/p')
v1=$(printf '%s' "$X_POLYDOC_SIGNATURE" | sed -n 's/^t=[0-9][0-9]*,v1=\([0-9a-fA-F][0-9a-fA-F]*\)$/\1/p' | tr '[:upper:]' '[:lower:]')
if [ -z "$t" ] || [ -z "$v1" ]; then
echo "malformed X-Polydoc-Signature" >&2
exit 1
fi
# Freshness check: ±300 s.
now=$(date +%s)
delta=$(( now - t ))
if [ "$delta" -lt -300 ] || [ "$delta" -gt 300 ]; then
echo "timestamp outside ±300s window (delta=${delta}s)" >&2
exit 1
fi
# HMAC over "<t>." + raw body. Prepend the prefix and stream through openssl.
computed=$( { printf '%s.' "$t"; cat "$BODY_FILE"; } \
| openssl dgst -sha256 -hmac "$POLYDOC_WEBHOOK_SECRET" \
| awk '{print $NF}' \
| tr '[:upper:]' '[:lower:]')
if [ "$computed" = "$v1" ]; then
echo "signature OK"
else
echo "signature mismatch" >&2
exit 1
fi
Legacy X-Signature header (deprecated)
The legacy header is HMAC-SHA256 of the raw body alone, encoded as 64 lowercase hexadecimal characters. The verification snippets below are kept here for customers mid-migration; switch them to the primary X-Polydoc-Signature verification (above) before the deprecation date.
const crypto = require('crypto');
/**
* @param {Buffer} rawBody - Exact body bytes (e.g. req.read() before parsers run)
* @param {string} signatureHex - X-Signature header value
* @param {string} secret - Webhook signature key from the dashboard
*/
function verifyPolyDocWebhookSignatureLegacy(rawBody, signatureHex, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
try {
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(String(signatureHex).trim(), 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}
import hmac
import hashlib
def verify_polydoc_webhook_signature_legacy(
raw_body: bytes, signature_hex: str, secret: str
) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
received = signature_hex.strip().lower()
if len(received) != len(expected):
return False
return hmac.compare_digest(expected, received)
require 'openssl'
# raw_body: binary string, e.g. request.body.read.b (before JSON parsers)
# signature_hex: value of X-Signature
# secret: webhook signature key (UTF-8 string)
def verify_polydoc_webhook_signature_legacy(raw_body, signature_hex, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
received = signature_hex.strip.downcase
return false unless expected.bytesize == received.bytesize
OpenSSL.fixed_length_secure_compare(expected, received)
rescue OpenSSL::OpenSSLError, ArgumentError
false
end
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifyPolyDocWebhookSignatureLegacy checks X-Signature for the raw webhook body.
func VerifyPolyDocWebhookSignatureLegacy(rawBody []byte, signatureHex, secret string) bool {
received, err := hex.DecodeString(strings.TrimSpace(signatureHex))
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := mac.Sum(nil)
return hmac.Equal(expected, received)
}
<?php
/**
* @param string $rawBody Binary body exactly as received (file_get_contents('php://input'))
* @param string $signatureHex X-Signature header
* @param string $secret Webhook signature key from the dashboard
*/
function verify_polydoc_webhook_signature_legacy(
string $rawBody,
string $signatureHex,
string $secret
): bool {
$expected = hash_hmac('sha256', $rawBody, $secret, false);
$received = strtolower(trim($signatureHex));
return hash_equals($expected, $received);
}
?>
// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4", subtle = "2.5"
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
// raw_body: exact bytes PolyDoc POSTed (verify the signature before parsing the body).
pub fn verify_polydoc_webhook_signature_legacy(
raw_body: &[u8],
signature_hex: &str,
secret: &str,
) -> bool {
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(raw_body);
let expected = hex::encode(mac.finalize().into_bytes());
let received = signature_hex.trim().to_lowercase();
if expected.len() != received.len() {
return false;
}
expected.as_bytes().ct_eq(received.as_bytes()).into()
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public final class PolyDocWebhookSignatureLegacy {
public static boolean verify(
byte[] rawBody, String signatureHex, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(rawBody);
byte[] received = hexToBytes(signatureHex.trim().toLowerCase());
if (received == null || expected.length != received.length) {
return false;
}
return MessageDigest.isEqual(expected, received);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
return false;
}
}
private static byte[] hexToBytes(String hex) {
int n = hex.length();
if ((n & 1) != 0) {
return null;
}
byte[] out = new byte[n / 2];
for (int i = 0; i < n; i += 2) {
int high = Character.digit(hex.charAt(i), 16);
int low = Character.digit(hex.charAt(i + 1), 16);
if (high < 0 || low < 0) {
return null;
}
out[i / 2] = (byte) ((high << 4) | low);
}
return out;
}
}
using System;
using System.Security.Cryptography;
using System.Text;
public static class PolyDocWebhookSignatureLegacy
{
/// <summary>Verify X-Signature for the raw webhook body bytes.</summary>
public static bool Verify(byte[] rawBody, string signatureHex, string secret)
{
try
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] expected = hmac.ComputeHash(rawBody);
byte[] received = Convert.FromHexString(signatureHex.Trim());
return CryptographicOperations.FixedTimeEquals(expected, received);
}
catch (FormatException)
{
return false;
}
}
}
# HMAC-SHA256 of the raw body must match X-Signature (64 hex chars).
# Requires OpenSSL 1.1+ / 3.x (same `openssl dgst` used on Linux and macOS).
#
# POLYDOC_WEBHOOK_SECRET — your dashboard webhook signature key
# BODY_FILE — path to the exact bytes PolyDoc POSTed (e.g. saved from stdin)
# SIG — value of the X-Signature header
computed=$(openssl dgst -sha256 -hmac "$POLYDOC_WEBHOOK_SECRET" "$BODY_FILE" | awk '{print $NF}' | tr '[:upper:]' '[:lower:]')
sig=$(printf '%s' "$SIG" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [ "$computed" = "$sig" ]; then
echo "signature OK"
else
echo "signature mismatch" >&2
exit 1
fi