You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
docuseal/docs/webhooks/webhook-signature.md

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

  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

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