# 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: . ``` - `` is the Unix time in seconds at which the request was signed. - `` is the lowercase hex HMAC-SHA256 digest of the string `"."` — 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 1. Split the header on the first `.` into `timestamp` and `signature`. 2. 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. 3. Recompute `HMAC-SHA256(secret, "#{timestamp}.#{body}")` over the **raw body bytes**, exactly as received. 4. Compare it to `signature` using 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 ```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 ```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 ```javascript 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 `4xx` when verification fails rather than `200`. A response of `400` or 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.