Skip to main content
Webhooks present an awkward problem for local development: your laptop is not publicly addressable, but webhook deliveries require a public HTTPS URL. This page covers the patterns that solve the problem — tunneling tools that expose your local server to the internet, fixture-based replay testing, and the safer setup of a Seeded Sandbox subscription so you never test against customer data.

Why local testing matters

Three classes of bug are nearly impossible to find without local testing:
  • Signature verification mismatches. Code that looks correct against a unit test fixture may fail against the actual bytes Virtuous sends. The only way to catch this is with a real delivery.
  • Edge-case payload shapes. Production payloads contain combinations of optional fields, custom field values, and lifecycle states that test fixtures rarely cover. Real deliveries expose these.
  • Timing assumptions. Code that works at unit-test speed may not work at the latency of a real delivery — especially if it makes downstream API calls inside the handler.
Set up a local testing loop before going to production. The cost is an hour or two of setup; the benefit is catching webhook bugs in your laptop’s logs instead of in a customer’s data.

The local testing setup

Three components:
  1. A tunneling tool that exposes your localhost to the public internet as an HTTPS URL.
  2. A Seeded Sandbox subscription pointed at the tunnel’s URL.
  3. Your local webhook handler running on a local port.
The tunnel does two jobs: it gives Virtuous a public URL to deliver to, and (with most modern tunneling tools) it exposes an inspector UI where you can view, replay, and modify deliveries.

Step 1: pick a tunneling tool

Three options that work well for Virtuous webhook development:
ToolStrengthsConsiderations
ngrokMature, well-documented, generous free tier, built-in request inspector. The default choice for most webhook development.Free-tier URLs are randomized on each restart — re-subscribe the webhook every time.
Cloudflare TunnelFree, stable URLs that persist across restarts, can be tied to a domain you control.More setup; requires a Cloudflare account and DNS configuration.
localtunnelOpen-source, simple.Less reliable than ngrok or Cloudflare; not recommended for serious work.
The examples below use ngrok because it is the most common choice. The pattern translates directly to other tools.

Setting up ngrok

# Install ngrok (macOS via Homebrew)
brew install ngrok

# Authenticate with your ngrok account (free signup at ngrok.com)
ngrok config add-authtoken YOUR_NGROK_AUTHTOKEN

# Start a tunnel to your local webhook server on port 3000
ngrok http 3000
ngrok prints a public HTTPS URL (e.g., https://abc123.ngrok-free.app). This is the URL you’ll register as your webhook payload URL. The ngrok inspector is at http://localhost:4040 — a local web UI showing every request that came through the tunnel. You can view payloads, replay requests, and inspect headers. This is the most useful tool in your local-testing kit.

Step 2: subscribe to webhooks against the tunnel

Create a webhook subscription pointed at your tunnel URL. Always use a Seeded Sandbox, not a production customer organization:
cURL
curl -X POST https://api.virtuoussoftware.com/api/Webhook \
  -H "Authorization: Bearer SANDBOX_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "payloadUrl": "https://abc123.ngrok-free.app/virtuous/webhook",
    "secret": "local-dev-secret",
    "contactCreate": true,
    "contactUpdate": true,
    "giftCreate": true,
    "active": true
  }'
Save the returned subscription id — you’ll use it to delete the subscription when you’re done testing.
Subscribe only against a Seeded Sandbox. Subscribing your local machine against a production customer organization risks sending sensitive donor data into your local logs and breaks the principle of separating development and production environments. If you do not yet have a Seeded Sandbox provisioned, request one from your Virtuous partner contact — see Seeded Sandbox setup.

Step 3: run your handler locally

Start your webhook handler on the local port matching the tunnel:
# Set environment variables — including the webhook secret you registered above
export VIRTUOUS_API_TOKEN="your-sandbox-api-token"
export VIRTUOUS_WEBHOOK_SECRET="local-dev-secret"

# Start the server
node server.js
A minimal local server that prints every incoming event:
JavaScript
import express from 'express';
import { verifyVirtuousSignature } from './signature-verification.js';

const app = express();

app.post(
  '/virtuous/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const valid = verifyVirtuousSignature(req.body, req.headers, process.env.VIRTUOUS_WEBHOOK_SECRET);
    if (!valid) {
      console.warn('Signature verification failed');
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    console.log('Received event:', JSON.stringify(event, null, 2));
    res.status(200).send('OK');
  }
);

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

Step 4: trigger events

With everything connected, trigger events in the Seeded Sandbox and watch them arrive locally. Two ways:

Via the Virtuous UI

Open the Seeded Sandbox in the Virtuous web app and perform actions that fire the events you subscribed to: create a Contact, record a Gift, archive a Project. Each action triggers the corresponding webhook delivery to your tunnel.

Via the API

Sometimes faster — call the API directly to create or update records:
cURL
# Create a test Contact via Transaction (fires contactCreate after batch)
curl -X POST https://api.virtuoussoftware.com/api/Contact/Transaction \
  -H "Authorization: Bearer SANDBOX_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "referenceSource": "LocalTest",
    "referenceId": "local-test-001",
    "contactType": "Household",
    "firstName": "Test",
    "lastName": "Donor",
    "email": "test@example.org"
  }'
Contact and Gift Transactions process in the nightly batch — the webhook for the resulting Contact/Gift creation does not fire immediately. For faster feedback during local development, use the direct endpoints (POST /api/Contact, POST /api/Gift) to trigger contactCreate and giftCreate events synchronously, or edit existing records to trigger contactUpdate and giftUpdate events.

Capturing fixtures for replay testing

The ngrok inspector lets you view every delivery, including the raw body and all headers. Once a real delivery has come through, save it as a fixture:
JavaScript
// test/fixtures/contact-created.json
{
  "headers": {
    "x-virtuous-signature": "...",
    "content-type": "application/json",
    "user-agent": "Virtuous-Webhooks/..."
  },
  "body": "<raw body bytes as captured>"
}
Then in your unit tests, replay the fixture through your handler:
JavaScript
import { describe, test, expect } from 'vitest';
import fs from 'fs';

test('handles contact.created from real Virtuous fixture', async () => {
  const fixture = JSON.parse(fs.readFileSync('test/fixtures/contact-created.json'));
  const rawBody = Buffer.from(fixture.body, 'utf8');

  const valid = verifyVirtuousSignature(rawBody, fixture.headers, 'local-dev-secret');
  expect(valid).toBe(true);

  // Now invoke your handler logic with the parsed event
  const event = JSON.parse(rawBody.toString('utf8'));
  await processWebhookEvent(event);

  // Assert your side effects ran as expected
});
This is the most reliable way to validate signature verification logic — your test runs against the exact bytes Virtuous sent, not against a self-generated approximation.
Save a fixture for every event type your integration handles. The set becomes a regression suite that catches signature, parsing, and side-effect bugs across the full surface your integration exercises. Refresh fixtures every few months as the platform may add new fields to payloads.

Replaying deliveries

The ngrok inspector has a “Replay” button next to every captured request. Click it to re-send the same delivery to your local handler. Use this to test:
  • Idempotency. Replay the same delivery twice and confirm your handler produces the same observable state (and only one set of side effects).
  • Code changes. Stop the local server, change your handler code, restart, then replay the captured delivery against the new code. No need to re-trigger the event in Virtuous.
  • Failure paths. Configure your local server to return 500 on the replay, then watch what your handler does on the inevitable retry from Virtuous (this is the cleanest way to observe retry behavior on a live subscription).

Cleaning up

When you’re done testing, delete or deactivate the webhook subscription so the tunnel URL stops receiving deliveries:
cURL
# Delete the subscription entirely
curl -X DELETE https://api.virtuoussoftware.com/api/Webhook/{webhookId} \
  -H "Authorization: Bearer SANDBOX_API_TOKEN"

# OR — keep it for next time but stop deliveries
curl -X PUT "https://api.virtuoussoftware.com/api/Webhook/{webhookId}/Active?active=false" \
  -H "Authorization: Bearer SANDBOX_API_TOKEN"
For free-tier ngrok URLs that change on restart, deactivating-and-reactivating with the new URL is more efficient than deleting and recreating. The subscription’s event toggles are preserved across the URL change.

CI/CD considerations

Production deployments need integration tests that don’t depend on a developer’s tunnel. Two patterns:

Fixture-based CI tests

Run your fixture replay tests in CI. They validate signature verification and handler logic against captured-real-world payloads without requiring network access to Virtuous.

Staging environment with persistent tunnel

For end-to-end tests, run a staging environment that maintains a persistent tunnel URL (Cloudflare Tunnel works well for this) and a permanently-subscribed Seeded Sandbox webhook. The CI suite triggers an action in the sandbox and asserts the webhook arrived at the staging endpoint. This is slower and more expensive than fixture replay but catches issues across the full delivery path. Most partner integrations only need the fixture-based approach. The full staging environment is worth setting up for high-risk integrations (financial reconciliation, donor communication) where end-to-end confidence matters.

Where to go next

Webhooks Overview

Subscription management and the receiver pattern — the production-side of what you’re testing locally.

Signature Verification

The verifier code you’ll exercise most heavily during local testing.

Idempotency and Safe Reprocessing

Use ngrok’s replay button to exercise your idempotency logic with real captured deliveries.

Base URLs and Environments

Set up a Seeded Sandbox if you don’t already have one.
Last modified on May 21, 2026