7 min read

Testing Webhooks Locally

Test and debug your webhook integration during development

Testing webhooks during development requires exposing your local server to the internet. This guide covers various approaches and debugging techniques.

The Challenge

Webhooks require a publicly accessible HTTPS URL, but your development server typically runs on localhost. You need a way to tunnel external requests to your local machine.

Using ngrok

ngrok is the most popular tool for exposing local servers to the internet.

Setup

  1. Install ngrok:
terminal
# macOS (Homebrew)
brew install ngrok

# Windows (Chocolatey)
choco install ngrok

# Linux (Snap)
snap install ngrok

# Or download from https://ngrok.com/download
  1. Create a free account at ngrok.com and get your auth token

  2. Configure ngrok:

terminal
ngrok config add-authtoken your_auth_token_here

Start a Tunnel

Start your local webhook server, then create a tunnel:

terminal
# Start your local server (example: Express on port 3000)
node server.js

# In another terminal, start ngrok
ngrok http 3000

ngrok will display output like:

ngrok-output
Session Status                online
Account                       your-email@example.com
Forwarding                    https://abc123.ngrok.io -> http://localhost:3000

Connections                   ttl     opn     rt1     rt5     p50     p90
                            0       0       0.00    0.00    0.00    0.00

Use the https://abc123.ngrok.io URL as your webhook endpoint in DocuRift.

ngrok Web Interface

ngrok provides a web interface at http://localhost:4040 for inspecting requests:

  • View all incoming requests
  • Inspect headers and body
  • Replay requests for debugging
  • See response details
💡

URL Changes on Restart

Free ngrok URLs change each time you restart ngrok. For persistent URLs, consider ngrok's paid plans or alternatives like Cloudflare Tunnel.

Alternative Tunneling Tools

Cloudflare Tunnel (Free, Persistent URLs)

terminal
# Install cloudflared
brew install cloudflare/cloudflare/cloudflared

# Create a tunnel (requires Cloudflare account)
cloudflared tunnel --url http://localhost:3000

localtunnel (Open Source)

terminal
# Install
npm install -g localtunnel

# Start tunnel
lt --port 3000

Tailscale Funnel (For Tailscale Users)

terminal
# Expose local service
tailscale funnel 3000

Dashboard Testing Feature

DocuRift provides a built-in webhook testing feature in the dashboard.

Send Test Events

  1. Navigate to Settings > Webhooks in your dashboard
  2. Click on your webhook endpoint
  3. Click Send Test Event
  4. Select an event type to send
  5. View the request and response details

Test Event Payload

Test events include a test: true flag to distinguish them from real events:

test-event.json
{
"id": "evt_test_abc123def456",
"type": "document.processing.completed",
"test": true,
"apiVersion": "2024-01-26",
"createdAt": "2024-01-26T10:30:00.000Z",
"data": {
  "documentId": "doc_test_123",
  "status": "completed",
  "result": {
    "vendor": {
      "name": "Test Vendor Inc."
    },
    "invoiceNumber": "TEST-001",
    "total": 1234.56
  }
}
}
💡

Handling Test Events

Check for event.test === true in your webhook handler if you want to skip processing test events or handle them differently.

Webhook Logs

The dashboard shows recent webhook delivery attempts:

| Column | Description | |--------|-------------| | Event | Event type that was sent | | Status | HTTP status code returned | | Duration | Response time in milliseconds | | Timestamp | When the event was sent | | Attempts | Number of delivery attempts |

Click on any log entry to see full request/response details.

Debugging Tips

1. Log Everything

Add comprehensive logging to your webhook handler:

debug-handler.js
app.post('/webhooks/docurift', express.raw({ type: 'application/json' }), (req, res) => {
// Log all headers
console.log('--- Webhook Received ---');
console.log('Headers:', JSON.stringify(req.headers, null, 2));

// Log raw body
const rawBody = req.body.toString();
console.log('Raw Body:', rawBody);

// Log signature details
const signature = req.headers['x-docurift-signature'];
const timestamp = req.headers['x-docurift-timestamp'];
console.log('Signature:', signature);
console.log('Timestamp:', timestamp);

// Verify and log result
const isValid = verifySignature(rawBody, signature, timestamp);
console.log('Signature Valid:', isValid);

if (!isValid) {
  console.error('Signature verification failed!');
  return res.status(401).json({ error: 'Invalid signature' });
}

// Parse and log event
const event = JSON.parse(rawBody);
console.log('Event Type:', event.type);
console.log('Event ID:', event.id);
console.log('Event Data:', JSON.stringify(event.data, null, 2));

res.status(200).json({ received: true });
});

2. Use the ngrok Inspector

Open http://localhost:4040 when running ngrok to:

  • See the exact request DocuRift sent
  • Inspect headers and body
  • Replay requests while debugging

3. Check Signature Calculation

If signature verification fails, debug step by step:

debug-signature.js
function debugSignatureVerification(rawBody, signature, timestamp, secret) {
console.log('=== Signature Debug ===');
console.log('Secret (first 10 chars):', secret.substring(0, 10) + '...');
console.log('Timestamp:', timestamp);
console.log('Raw body length:', rawBody.length);
console.log('Raw body (first 100 chars):', rawBody.substring(0, 100));

const signedPayload = `${timestamp}.${rawBody}`;
console.log('Signed payload (first 100 chars):', signedPayload.substring(0, 100));

const expectedSignature = crypto
  .createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex');

console.log('Received signature:', signature);
console.log('Expected signature:', expectedSignature);
console.log('Match:', signature === expectedSignature);

return signature === expectedSignature;
}

4. Common Issues

Body Parser Conflict

If you use express.json() globally, it will parse the body before your webhook handler sees it:

fix-body-parser.js
// WRONG - json() parses body before webhook handler
app.use(express.json());
app.post('/webhooks/docurift', (req, res) => {
// req.body is already parsed, signature verification will fail!
});

// CORRECT - Use raw() specifically for webhook route
app.post('/webhooks/docurift',
express.raw({ type: 'application/json' }),
(req, res) => {
  // req.body is raw Buffer, signature verification works
}
);

// Apply json() to other routes
app.use(express.json());

Clock Skew

If timestamp validation fails, check your server's clock:

terminal
# Check current time
date

# Sync with NTP (Linux)
sudo ntpdate pool.ntp.org

# macOS automatically syncs, but you can force it
sudo sntp -sS time.apple.com

Wrong Webhook Secret

Verify you're using the correct secret:

  1. Go to Settings > Webhooks in the dashboard
  2. Click on your webhook
  3. Click Reveal Secret to see the current secret
  4. Compare with the secret in your environment variables

5. Test with cURL

Simulate a webhook request locally:

curl-test.bash
# Generate signature (replace with your secret)
SECRET="whsec_your_secret_here"
TIMESTAMP=$(date +%s)
BODY='{"id":"evt_test","type":"document.processing.completed","data":{}}'
SIGNATURE=$(echo -n "$TIMESTAMP.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

# Send test request
curl -X POST http://localhost:3000/webhooks/docurift \
-H "Content-Type: application/json" \
-H "X-DocuRift-Signature: $SIGNATURE" \
-H "X-DocuRift-Timestamp: $TIMESTAMP" \
-d "$BODY"

Local Development Workflow

A recommended workflow for webhook development:

  1. Start your local server

    terminal
    npm run dev
  2. Start ngrok tunnel

    terminal
    ngrok http 3000
  3. Configure webhook in DocuRift

    • Add ngrok URL as webhook endpoint
    • Subscribe to desired events
  4. Send test events from dashboard

    • Verify signature handling
    • Check event processing
  5. Process a real document

    • Upload a document via API or dashboard
    • Observe webhook delivery
    • Debug any issues using ngrok inspector
  6. Iterate and test

    • Make code changes
    • Replay requests from ngrok inspector
    • No need to upload new documents

Environment Setup

Create a .env.local file for development:

.env.local
# DocuRift credentials
DOCURIFT_API_KEY=frc_your_api_key_here
DOCURIFT_WEBHOOK_SECRET=whsec_your_webhook_secret_here

# Webhook server config
WEBHOOK_PORT=3000

# Debug mode
DEBUG=docurift:*

Integration Tests

Write automated tests for your webhook handler:

webhook.test.js
import crypto from 'crypto';
import request from 'supertest';
import { app } from './app';

const WEBHOOK_SECRET = 'whsec_test_secret';

function generateSignature(payload, timestamp) {
const signedPayload = `${timestamp}.${payload}`;
return crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(signedPayload)
  .digest('hex');
}

describe('Webhook Handler', () => {
test('accepts valid webhook', async () => {
  const event = {
    id: 'evt_test_123',
    type: 'document.processing.completed',
    data: { documentId: 'doc_123' }
  };
  const payload = JSON.stringify(event);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = generateSignature(payload, timestamp);

  const response = await request(app)
    .post('/webhooks/docurift')
    .set('Content-Type', 'application/json')
    .set('X-DocuRift-Signature', signature)
    .set('X-DocuRift-Timestamp', timestamp)
    .send(payload);

  expect(response.status).toBe(200);
  expect(response.body.received).toBe(true);
});

test('rejects invalid signature', async () => {
  const response = await request(app)
    .post('/webhooks/docurift')
    .set('Content-Type', 'application/json')
    .set('X-DocuRift-Signature', 'invalid_signature')
    .set('X-DocuRift-Timestamp', Date.now().toString())
    .send('{"test": true}');

  expect(response.status).toBe(401);
});

test('rejects old timestamp', async () => {
  const event = { id: 'evt_test', type: 'test' };
  const payload = JSON.stringify(event);
  const oldTimestamp = (Math.floor(Date.now() / 1000) - 600).toString(); // 10 min ago
  const signature = generateSignature(payload, oldTimestamp);

  const response = await request(app)
    .post('/webhooks/docurift')
    .set('Content-Type', 'application/json')
    .set('X-DocuRift-Signature', signature)
    .set('X-DocuRift-Timestamp', oldTimestamp)
    .send(payload);

  expect(response.status).toBe(401);
});
});

Next Steps