7 min read

Signature Verification

Verify webhook signatures to ensure requests originate from DocuRift

Every webhook request from DocuRift includes a cryptographic signature. Verifying this signature ensures the request genuinely came from DocuRift and hasn't been tampered with.

How Signatures Work

DocuRift signs each webhook request using HMAC-SHA256:

  1. We concatenate the timestamp and raw request body
  2. We compute an HMAC-SHA256 hash using your webhook secret
  3. We send the signature in the X-DocuRift-Signature header

Request Headers

Each webhook request includes these security headers:

ParameterTypeDescription
X-DocuRift-Signature
stringHMAC-SHA256 signature of the request
X-DocuRift-Timestamp
stringUnix timestamp when the request was sent
X-DocuRift-Event-Id
stringUnique event identifier for deduplication

Verification Steps

To verify a webhook signature:

  1. Extract the signature and timestamp from headers
  2. Construct the signed payload: {timestamp}.{rawBody}
  3. Compute HMAC-SHA256 using your webhook secret
  4. Compare your computed signature with the received signature
  5. Verify the timestamp is recent (within 5 minutes)
⚠️

Use Raw Request Body

You must use the raw request body (not parsed JSON) for signature verification. Parsing and re-serializing JSON may change the byte representation.

Code Examples

Python

verify_webhook.py
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = 'whsec_your_webhook_secret_here'

def verify_webhook_signature(payload: bytes, signature: str, timestamp: str) -> bool:
  """Verify the webhook signature from DocuRift."""

  # Check timestamp is recent (within 5 minutes)
  current_time = int(time.time())
  request_time = int(timestamp)
  if abs(current_time - request_time) > 300:  # 5 minutes
      return False

  # Construct the signed payload
  signed_payload = f"{timestamp}.".encode() + payload

  # Compute expected signature
  expected_signature = hmac.new(
      WEBHOOK_SECRET.encode(),
      signed_payload,
      hashlib.sha256
  ).hexdigest()

  # Constant-time comparison to prevent timing attacks
  return hmac.compare_digest(expected_signature, signature)


@app.route('/webhooks/docurift', methods=['POST'])
def handle_webhook():
  # Get raw body before parsing
  payload = request.get_data()

  # Get headers
  signature = request.headers.get('X-DocuRift-Signature')
  timestamp = request.headers.get('X-DocuRift-Timestamp')

  if not signature or not timestamp:
      abort(401, 'Missing signature headers')

  if not verify_webhook_signature(payload, signature, timestamp):
      abort(401, 'Invalid signature')

  # Signature verified - process the event
  event = request.json
  print(f"Received event: {event['type']}")

  return {'received': True}, 200


if __name__ == '__main__':
  app.run(port=3000)

Python (with Requests Library)

verify_standalone.py
import hmac
import hashlib
import time

def verify_docurift_webhook(
  raw_body: bytes,
  signature: str,
  timestamp: str,
  secret: str
) -> bool:
  """
  Verify a DocuRift webhook signature.

  Args:
      raw_body: The raw request body as bytes
      signature: X-DocuRift-Signature header value
      timestamp: X-DocuRift-Timestamp header value
      secret: Your webhook secret (whsec_...)

  Returns:
      True if signature is valid, False otherwise
  """
  # Reject old timestamps (replay attack prevention)
  current_time = int(time.time())
  request_time = int(timestamp)

  if abs(current_time - request_time) > 300:
      print("Timestamp too old, possible replay attack")
      return False

  # Build the signed payload
  signed_payload = f"{timestamp}.".encode() + raw_body

  # Calculate expected signature
  expected = hmac.new(
      secret.encode('utf-8'),
      signed_payload,
      hashlib.sha256
  ).hexdigest()

  # Constant-time comparison
  return hmac.compare_digest(expected, signature)


# Example usage
webhook_secret = "whsec_abc123def456ghi789jkl012mno345pqr678"
raw_body = b'{"id":"evt_123","type":"document.processing.completed"}'
signature = "a1b2c3d4e5f6..."  # From header
timestamp = "1706270400"  # From header

is_valid = verify_docurift_webhook(raw_body, signature, timestamp, webhook_secret)
print(f"Signature valid: {is_valid}")

Node.js (Express)

verify_webhook.js
import express from 'express';
import crypto from 'crypto';

const app = express();

const WEBHOOK_SECRET = process.env.DOCURIFT_WEBHOOK_SECRET;

function verifyWebhookSignature(payload, signature, timestamp) {
// Check timestamp is recent (within 5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);

if (Math.abs(currentTime - requestTime) > 300) {
  console.error('Timestamp too old, possible replay attack');
  return false;
}

// Construct the signed payload
const signedPayload = `${timestamp}.${payload}`;

// Compute expected signature
const expectedSignature = crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(signedPayload)
  .digest('hex');

// Constant-time comparison
return crypto.timingSafeEqual(
  Buffer.from(expectedSignature),
  Buffer.from(signature)
);
}

// Important: Use raw body parser for webhook route
app.post('/webhooks/docurift',
express.raw({ type: 'application/json' }),
(req, res) => {
  const payload = req.body.toString();
  const signature = req.headers['x-docurift-signature'];
  const timestamp = req.headers['x-docurift-timestamp'];

  if (!signature || !timestamp) {
    return res.status(401).json({ error: 'Missing signature headers' });
  }

  if (!verifyWebhookSignature(payload, signature, timestamp)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Signature verified - process the event
  const event = JSON.parse(payload);
  console.log('Received event:', event.type);

  // Acknowledge receipt immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  processEvent(event);
}
);

async function processEvent(event) {
switch (event.type) {
  case 'document.processing.completed':
    // Handle completed document
    break;
  case 'document.processing.failed':
    // Handle failed document
    break;
  default:
    console.log('Unhandled event type:', event.type);
}
}

app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});

Node.js (TypeScript)

verify_webhook.ts
import { createHmac, timingSafeEqual } from 'crypto';

interface VerifyOptions {
payload: string | Buffer;
signature: string;
timestamp: string;
secret: string;
tolerance?: number; // seconds, default 300 (5 min)
}

export function verifyDocuRiftWebhook(options: VerifyOptions): boolean {
const { payload, signature, timestamp, secret, tolerance = 300 } = options;

// Validate timestamp
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);

if (isNaN(requestTime)) {
  throw new Error('Invalid timestamp format');
}

if (Math.abs(currentTime - requestTime) > tolerance) {
  throw new Error('Timestamp outside tolerance window');
}

// Build signed payload
const payloadStr = typeof payload === 'string' ? payload : payload.toString();
const signedPayload = `${timestamp}.${payloadStr}`;

// Compute expected signature
const expectedSignature = createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex');

// Constant-time comparison
try {
  return timingSafeEqual(
    Buffer.from(expectedSignature, 'utf8'),
    Buffer.from(signature, 'utf8')
  );
} catch {
  return false;
}
}

// Usage example
const isValid = verifyDocuRiftWebhook({
payload: rawRequestBody,
signature: headers['x-docurift-signature'],
timestamp: headers['x-docurift-timestamp'],
secret: process.env.DOCURIFT_WEBHOOK_SECRET!
});

Go

verify_webhook.go
package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "fmt"
  "io"
  "math"
  "net/http"
  "strconv"
  "time"
)

const webhookSecret = "whsec_your_webhook_secret_here"

func verifyWebhookSignature(payload []byte, signature, timestamp string) bool {
  // Check timestamp is recent (within 5 minutes)
  requestTime, err := strconv.ParseInt(timestamp, 10, 64)
  if err != nil {
      return false
  }

  currentTime := time.Now().Unix()
  if math.Abs(float64(currentTime-requestTime)) > 300 {
      return false
  }

  // Construct signed payload
  signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))

  // Compute expected signature
  mac := hmac.New(sha256.New, []byte(webhookSecret))
  mac.Write([]byte(signedPayload))
  expectedSignature := hex.EncodeToString(mac.Sum(nil))

  // Constant-time comparison
  return hmac.Equal([]byte(expectedSignature), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
  payload, err := io.ReadAll(r.Body)
  if err != nil {
      http.Error(w, "Failed to read body", http.StatusBadRequest)
      return
  }

  signature := r.Header.Get("X-DocuRift-Signature")
  timestamp := r.Header.Get("X-DocuRift-Timestamp")

  if signature == "" || timestamp == "" {
      http.Error(w, "Missing signature headers", http.StatusUnauthorized)
      return
  }

  if !verifyWebhookSignature(payload, signature, timestamp) {
      http.Error(w, "Invalid signature", http.StatusUnauthorized)
      return
  }

  // Signature verified - process the event
  fmt.Fprintf(w, `{"received": true}`)
}

func main() {
  http.HandleFunc("/webhooks/docurift", webhookHandler)
  http.ListenAndServe(":3000", nil)
}

Security Best Practices

1. Always Verify Signatures

Never process webhook data without verifying the signature first. Unverified webhooks could be forged by attackers.

2. Use Constant-Time Comparison

Always use constant-time comparison functions (like hmac.compare_digest in Python or crypto.timingSafeEqual in Node.js) to prevent timing attacks.

3. Validate Timestamps

Reject webhooks with timestamps older than 5 minutes to prevent replay attacks:

timestamp-check.js
const TOLERANCE = 300; // 5 minutes in seconds

function isTimestampValid(timestamp) {
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);
return Math.abs(currentTime - requestTime) <= TOLERANCE;
}

4. Protect Your Webhook Secret

  • Store the secret in environment variables, not in code
  • Never log or expose the secret
  • Rotate the secret if it's ever compromised

5. Use HTTPS Only

Ensure your webhook endpoint uses HTTPS to protect the signature and payload in transit.

6. Handle Errors Gracefully

Return appropriate HTTP status codes:

| Status | Meaning | DocuRift Action | |--------|---------|-----------------| | 200 | Success | Event delivered | | 401 | Invalid signature | Logs security event | | 5xx | Server error | Retries delivery |

Testing Signature Verification

Use this test case to verify your implementation:

test-signature.json
{
"secret": "whsec_test_secret_for_verification",
"timestamp": "1706270400",
"payload": "{"id":"evt_test","type":"document.processing.completed"}",
"expectedSignature": "8a4f7c3e9b2d1a6f5e4c3b2a1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e"
}
💡

Test Endpoint

Use the DocuRift dashboard's webhook testing feature to send test events and verify your signature implementation. See Testing Webhooks.

Troubleshooting

Signature Mismatch

If signature verification fails:

  1. Check the raw body: Ensure you're using the raw request body, not parsed JSON
  2. Verify the secret: Confirm you're using the correct webhook secret
  3. Check encoding: The payload should be UTF-8 encoded
  4. Inspect headers: Ensure headers are being read correctly

Timestamp Errors

If timestamp validation fails:

  1. Check server time: Ensure your server clock is synchronized (use NTP)
  2. Increase tolerance: Temporarily increase the tolerance window for debugging
  3. Log timestamps: Compare the request timestamp with your server time

Next Steps