Skip to main content

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-Signature header so you can confirm the payload really came from PolyDoc and was not modified in transit.

Signature algorithm

  • Algorithm: HMAC-SHA256
  • Key: your account's webhook signature secret (same value as in the dashboard)
  • Message: the exact binary body PolyDoc sends (typically application/pdf or an image type)
  • X-Signature value: lowercase hexadecimal encoding of the digest (64 characters)

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-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.

Example verification

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 verifyPolyDocWebhookSignature(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(
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(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"
)

// VerifyPolyDocWebhookSignature checks X-Signature for the raw webhook body.
func VerifyPolyDocWebhookSignature(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(
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(
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 PolyDocWebhookSignature {

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 PolyDocWebhookSignature
{
/// <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