4.2 KiB
Webhook Signature
Every webhook request can be signed with an HMAC signature so your endpoint can verify it came from DocuSeal and was not modified in transit.
Enabling signatures
Open Settings → Webhooks, click Security on a webhook URL, and select the HMAC tab. The HMAC Signing Secret shown there is generated by DocuSeal for that webhook URL — it is read-only, so there is no value for you to choose. Copy it into your application's configuration.
The Secret tab beside it is a different feature: a custom request header (for example
X-Signature: my-value) that DocuSeal sends verbatim. It is not used to compute the signature
below.
The signature header
Signed requests carry:
X-Docuseal-Signature: <timestamp>.<sha256>
<timestamp>is the Unix time in seconds at which the request was signed.<sha256>is the lowercase hex HMAC-SHA256 digest of the string"<timestamp>.<body>"— the timestamp, a literal., then the raw request body — keyed with the HMAC signing secret.
Note that the digest covers the timestamp-prefixed body, not the body alone.
Verifying a request
- Split the header on the first
.intotimestampandsignature. - Reject the request if the timestamp is more than 5 minutes away from your current time, in either direction. This is what stops a captured request from being replayed later.
- Recompute
HMAC-SHA256(secret, "#{timestamp}.#{body}")over the raw body bytes, exactly as received. - Compare it to
signatureusing a constant-time comparison.
Verify before parsing. Re-serializing the JSON — even with identical keys — can change whitespace or key order and will produce a different digest.
Ruby
def verify_docuseal_signature(secret, body:, header:, tolerance: 300)
timestamp, signature = header.to_s.split('.', 2)
timestamp = Integer(timestamp, exception: false)
return false unless timestamp && signature
return false if (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}")
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
Python
import hashlib
import hmac
import time
def verify_docuseal_signature(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
timestamp, _, signature = header.partition(".")
if not timestamp or not signature:
return False
try:
signed_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - signed_at) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
Node.js
const crypto = require('crypto')
function verifyDocusealSignature(secret, body, header, tolerance = 300) {
const [timestamp, signature] = String(header).split(/\.(.*)/s)
if (!timestamp || !signature) return false
const signedAt = Number(timestamp)
if (!Number.isFinite(signedAt)) return false
if (Math.abs(Date.now() / 1000 - signedAt) > tolerance) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(body)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
In each example body is the raw request body — request.body.read in Rack, await request.body() in FastAPI, express.raw({ type: 'application/json' }) in Express.
Notes
- The signing secret is per webhook URL. A second webhook URL has its own secret.
- Every webhook URL is given a signing secret when it is created, so every request is signed — there is nothing to turn on.
- If you add a custom header on the Secret tab and name it
X-Docuseal-Signature, your value is sent and the real signature is not. Name it something else. - Return a
4xxwhen verification fails rather than200. A response of400or above marks the event as errored and is retried with exponential backoff (up to 10 attempts), so a failing signature stays visible instead of being silently accepted.