Skip to content

Verifying Signatures

Your webhook endpoint is a public URL, so anyone could send it a fake request. To confirm a request really came from DocSpring — and that nobody altered the payload in transit — DocSpring signs every request with your webhook’s signing secret and includes the signature in the X-DocSpring-Signature header.

X-DocSpring-Signature: t=1782532874,v1=66269feb6c39613749b415977617cff9d29b39a36296fd7b6d048c68cd4ae6ea

The header has two comma-separated parts:

  • t — the Unix timestamp (in seconds) when the request was signed.
  • v1 — the signature: an HMAC-SHA256 hex digest. v1 denotes the signature scheme version.

DocSpring builds a signed payload by joining the timestamp and the raw request body with a literal .:

signed_payload = "{t}.{raw_request_body}"

It then computes:

v1 = HMAC_SHA256(key: webhook_secret, message: signed_payload)

Because the timestamp is part of the signed message, it can’t be altered without invalidating the signature — which is what makes timestamp-based replay protection possible.

To verify an incoming request:

  1. Read the X-DocSpring-Signature header and parse out t and v1.
  2. Recompute HMAC_SHA256(secret, "{t}.{raw_body}") using the raw request body exactly as received.
  3. Compare your computed value to v1 using a constant-time comparison.
  4. Optionally reject requests whose timestamp t is too old to limit replay attacks.

Each example reads the webhook secret from an environment variable. You’ll find the secret on the webhook’s detail page in the dashboard (and on every webhook API response).

require "openssl"
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook(secret, signature_header, raw_body)
parts = signature_header.to_s.split(",").map { |p| p.split("=", 2) }.to_h
timestamp = parts["t"]
signature = parts["v1"]
return false if timestamp.nil? || signature.nil?
# Reject old timestamps (replay protection)
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
signed_payload = "#{timestamp}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
# Constant-time comparison (both are 64-char hex strings)
return false unless expected.bytesize == signature.bytesize
OpenSSL.fixed_length_secure_compare(expected, signature)
end
# Example (Rails controller):
# raw_body = request.raw_post
# header = request.headers["X-DocSpring-Signature"]
# verified = verify_webhook(ENV["WEBHOOK_SECRET"], header, raw_body)

Even a genuine, correctly-signed request can be captured and re-sent by an attacker. Checking that the timestamp t is recent (the examples above use a five-minute tolerance) limits how long a captured request stays usable. Choose a tolerance that accounts for clock skew between your server and DocSpring.

For stronger protection against duplicate deliveries — which can also happen naturally during retries — make your handler idempotent: record which events you’ve already processed and ignore repeats.