Skip to main content
Webhook integrations are notoriously hard to develop locally. Raise needs to deliver events to a public HTTPS URL — but a local development server runs on localhost, not the public internet. This page covers the patterns for getting webhook events to a local development environment, generating test events, validating signature verification, and inspecting deliveries. The audience is partner integration developers building or debugging webhook handlers. The patterns work for any framework — the examples use Node.js, but the principles apply equally to Python, Ruby, Go, etc.

What you’ll need

RequirementExamples
A tunneling tool that exposes localhost over HTTPSngrok, Cloudflare Tunnel, Tailscale Funnel, localtunnel
A Raise developer organization with test mode availableCoordinate with your partner manager — see Base URLs and Environments
A local webhook receiverThe HTTP server your integration runs
The test-payment-method generatorPOST /api/Raise/generate-test-payment-method
Don’t try to test webhooks against a production customer’s account during development. Use the dedicated developer organization to keep test traffic isolated.

Step 1: expose your local server over HTTPS

The webhook subscription’s notificationUrl must be HTTPS — Raise won’t deliver to plain HTTP. The simplest path: a tunneling tool that proxies a public HTTPS URL to your local server.

Using ngrok

# Start your local server first
npm run dev          # Or python manage.py runserver, etc.
# (Server is now running on http://localhost:3000)

# In a separate terminal, start the tunnel
ngrok http 3000
ngrok prints a public HTTPS URL — something like https://abc123.ngrok.io. This URL forwards to your localhost:3000. Use it as the webhook’s notificationUrl.
JavaScript
const TUNNEL_URL = 'https://abc123.ngrok.io'; // Replace with your ngrok URL

await fetch('https://prod-api.raisedonors.com/api/Webhook', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RAISE_API_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Local development',
    notificationUrl: `${TUNNEL_URL}/raise-webhooks`,
    eventTypesList: [10, 11], // Gift events — adjust to what you're testing
    format: 1,
    status: 1,
    securityToken: 'dev-secret-please-rotate-before-prod',
  }),
});

Other tunneling options

ToolNotes
Cloudflare TunnelFree; persistent named tunnels available; integrates with Cloudflare’s CDN
Tailscale FunnelFree for personal use; requires Tailscale account
localtunnelFree; ephemeral URLs
Custom on a public VPSIf your team has a dedicated dev VPS, you can set up nginx + Let’s Encrypt to proxy to your machine
Pick whichever fits your team’s workflow. The principle is the same — get a public HTTPS URL that forwards to your local server.

Important: tunnel URLs change

Most tunneling tools assign a fresh URL each time you restart the tunnel (ngrok’s free tier rotates URLs). After every tunnel restart, update the webhook subscription’s notificationUrl via PUT /api/Webhook/{id}. A common dev-loop pattern: a small script that starts the tunnel, captures the new URL, and updates the webhook subscription automatically:
JavaScript
import { spawn } from 'child_process';
import fetch from 'node-fetch';

async function startDevTunnel() {
  // Start ngrok
  const ngrok = spawn('ngrok', ['http', '3000', '--log=stdout']);

  // Wait for ngrok to report its URL (via its local API)
  await sleep(2000);
  const tunnels = await fetch('http://localhost:4040/api/tunnels').then((r) => r.json());
  const publicUrl = tunnels.tunnels[0].public_url;

  // Update the webhook subscription with the new URL
  await fetch(`https://prod-api.raisedonors.com/api/Webhook/${WEBHOOK_ID}`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${process.env.RAISE_API_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'Local development',
      notificationUrl: `${publicUrl}/raise-webhooks`,
      eventTypesList: [10, 11],
      format: 1,
      status: 1,
      securityToken: process.env.WEBHOOK_SECRET,
    }),
  });

  console.log(`Tunnel active: ${publicUrl}`);
  return publicUrl;
}
For paid ngrok plans, persistent subdomains avoid this dance — the same URL works across restarts.

Step 2: deploy the receiver

Your local webhook receiver needs to:
  1. Listen on the local port the tunnel forwards to.
  2. Verify the signature on incoming requests.
  3. Acknowledge quickly with 200 OK.
  4. Process (or queue for processing) the event.
A minimal Express-based receiver:
JavaScript
import express from 'express';
import crypto from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

// IMPORTANT: capture raw body for signature verification
app.use('/raise-webhooks', express.raw({ type: 'application/json' }));

function verifySignature(req) {
  const signature = req.headers['x-raise-signature']; // Confirm header name against live data
  if (!signature) return false;

  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (signature.length !== expected.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expected, 'utf8')
  );
}

app.post('/raise-webhooks', (req, res) => {
  if (!verifySignature(req)) {
    console.error('Signature verification failed');
    console.error('Headers:', req.headers);
    console.error('Body:', req.body.toString('utf8'));
    return res.status(401).send('Invalid signature');
  }

  // Parse and log for inspection
  const event = JSON.parse(req.body.toString('utf8'));
  console.log('Received event:', JSON.stringify(event, null, 2));

  // Acknowledge quickly
  res.status(200).send('OK');

  // Process (or queue for processing) here
  processEvent(event).catch((err) => console.error('Processing failed:', err));
});

app.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});
The signature header name (x-raise-signature above) and the algorithm (sha256/hex) are placeholders — confirm against actual deliveries before relying on them. See Signature Verification.

Step 3: trigger test events

The fastest way to generate webhook events: submit test-mode donations through POST /api/Raise/give. Each successful submission fires the events your subscription is listening for.

Generate a test payment method

cURL
curl -X POST https://prod-api.raisedonors.com/api/Raise/generate-test-payment-method \
  -H "Authorization: Bearer YOUR_API_TOKEN"
The response contains a paymentMethodId usable for test-mode submissions. Re-use the same token across many test donations.

Submit a test donation

JavaScript
async function fireTestDonation() {
  // 1. Generate a test payment method
  const tokenResponse = await fetch(
    'https://prod-api.raisedonors.com/api/Raise/generate-test-payment-method',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.RAISE_API_TOKEN}` },
    }
  );
  const { paymentMethodId } = await tokenResponse.json();

  // 2. Submit a test donation
  const giftResponse = await fetch(
    'https://prod-api.raisedonors.com/api/Raise/give',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.RAISE_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        amount: 10.00,
        paymentMethodId,
        paymentMethodType: 'CreditCard',
        isTestMode: true,
        donor: {
          firstName: 'Test',
          lastName: 'Donor',
          email: `dev+${Date.now()}@example.com`, // Unique per test
        },
      }),
    }
  );

  const gift = await giftResponse.json();
  console.log(`Test donation processed: gift ${gift.id}`);
  return gift;
}

await fireTestDonation();
Within seconds, the webhook receiver should log the incoming event. If it doesn’t, see Step 5: troubleshoot delivery below.

Generate other event types

Different event types fire from different actions:
Event familyHow to trigger
Gift eventsSubmit a donation via POST /api/Raise/give; refund via POST /api/Gift/{id}/refund; delete via DELETE /api/Gift/{id}
RecurringGift eventsSubmit a donation with isRecurring: true; update via PUT /api/RecurringGift/{id}; cancel via PUT /api/RecurringGift/{id}/cancel
Donor eventsDonor-creating donations trigger create events; PATCH /api/Donor/{id} triggers updates; POST /api/Donor/{id}/archive triggers a state change
Campaign eventsPOST /api/Campaign, PUT /api/Campaign/{id}, DELETE /api/Campaign/{id}
A small test script that generates one event of each type is useful for development — every time you start working on the integration, run the script to confirm webhooks still flow end-to-end.

Step 4: verify what arrives

For each test event, confirm two things: the event arrived at your local receiver, and Raise considers it successfully delivered.

Local receiver inspection

Your local receiver should log every incoming event. Inspect the logs to confirm:
  • The event arrived
  • The signature verified
  • The payload shape matches what you expected
  • Your processing logic ran without errors

Raise-side log inspection

Raise’s webhook log endpoints show the platform’s view of the delivery:
cURL
curl "https://prod-api.raisedonors.com/api/Webhook/${WEBHOOK_ID}/log/list?Take=10&SortBy=createddatetime&Descending=true" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
Each log entry shows:
FieldWhat to confirm
httpStatusCodeYour receiver returned 200 (or what you intended)
successRaise recorded the delivery as successful
serverResponseThe body your receiver returned, captured by Raise
eventTypeDisplayThe label for this event type — useful for confirming the integer-to-label mapping
contextIdThe ID of the entity that triggered the event
If your receiver logs the event but the Raise log shows success: "No", the issue is in your response — likely a status code other than 200 or a delayed response that exceeded Raise’s timeout. If the Raise log shows success: "Yes" but your receiver doesn’t log the event, the receiver isn’t reachable at the URL configured (likely a tunneling issue).

Step 5: troubleshoot delivery

The most common local-dev issues and how to diagnose them:

“I’m not receiving any events”

CheckWhat to verify
Is the tunnel running?curl https://your-tunnel.ngrok.io/raise-webhooks should hit your local server. If not, the tunnel is down.
Is the webhook subscription pointing at the right URL?GET /api/Webhook/{id} and confirm notificationUrl matches the current tunnel URL.
Is the subscription active?status: 1 (active) — if 2 (inactive), no events fire.
Is the event type subscribed?eventTypesList must include the integers for the events you’re firing.
Are events actually firing?Confirm via GET /api/Webhook/log/list — if no logs exist, no events fired.
Is the test mode correct?If you’re submitting isTestMode: true donations, the resulting events should still fire — but confirm your subscription isn’t filtering them out (the spec doesn’t show a test-mode filter, so subscriptions receive both).

”I’m receiving events but signature verification fails”

CheckWhat to verify
Are you reading the raw body before any JSON parsing?The signature is over the raw bytes. If a middleware parses to JSON first, re-serialized bytes won’t match.
Is the header name correct?Inspect the request headers your local receiver sees. The header name isn’t formally documented in the spec — confirm against actual deliveries.
Is the algorithm right?Try sha256 first, then sha1, then variants. Try hex vs base64 digest encoding.
Is the secret correct?Confirm your local WEBHOOK_SECRET env var matches the securityToken on the subscription record.
Are you including any prefix or suffix?Some webhook systems include a timestamp before the body (${ts}.${body}). Inspect the signature header format for any patterns.

”Raise shows success but my receiver returned an error”

If httpStatusCode in the Raise log shows 200 but your local logs show errors, the response was sent before the error happened. Common cause: async processing inside the request handler — res.status(200) returns before the actual processing fails. This is actually the correct pattern (acknowledge quickly, process asynchronously) — the “error” is downstream of the webhook reception. Investigate the processing pipeline separately.

”Multiple log entries for the same event”

If GET /api/Webhook/{id}/log/list shows multiple entries with the same contextId, Raise retried the delivery. Check the timestamps:
  • If the first attempt failed (success: "No") and the second succeeded (success: "Yes"), retry worked correctly.
  • If multiple attempts all failed, the receiver is consistently failing — investigate the receiver-side logs for the cause.

Multi-customer testing

For partner integrations that serve multiple customers, test the multi-customer dispatch logic locally:
JavaScript
// Different customers can route to different paths
app.use('/raise-webhooks/:customerId', express.raw({ type: 'application/json' }));

app.post('/raise-webhooks/:customerId', async (req, res) => {
  const { customerId } = req.params;
  const customerConfig = await getCustomerConfig(customerId);

  if (!verifySignature(req, customerConfig.webhookSecret)) {
    return res.status(401).send('Invalid signature');
  }

  res.status(200).send('OK');

  const event = JSON.parse(req.body.toString('utf8'));
  await processCustomerEvent(customerId, event);
});
Each customer’s webhook subscription’s notificationUrl includes the customer ID in the path. The receiver looks up the per-customer secret to verify the signature. For testing this locally, create multiple test webhook subscriptions — one per simulated customer — each pointing at a different path under the same tunnel URL.

Capturing payloads for offline testing

Once you’ve confirmed end-to-end webhook delivery works, capture real payloads for offline testing. Save them to fixture files and replay them through your processing logic without needing the tunnel.
JavaScript
// Capture payloads to disk during development
app.post('/raise-webhooks', async (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  // Save the raw body to a fixture file
  const event = JSON.parse(req.body.toString('utf8'));
  const filename = `fixtures/event-${event.eventType}-${event.contextId}.json`;
  await fs.writeFile(filename, req.body);

  res.status(200).send('OK');
  await processEvent(event);
});
In your test suite, replay the captured payloads through the processing logic:
JavaScript
import fs from 'fs/promises';
import path from 'path';

const fixtureDir = './fixtures';

test('processing gift events from real captured payloads', async () => {
  const files = await fs.readdir(fixtureDir);
  const giftEventFiles = files.filter((f) => f.startsWith('event-10-'));

  for (const file of giftEventFiles) {
    const payload = JSON.parse(await fs.readFile(path.join(fixtureDir, file), 'utf8'));
    await processEvent(payload);
    // Assertions about the side effects produced
  }
});
This produces a fast, deterministic test suite that exercises the processing logic against real payload shapes — without depending on the tunnel, the Raise API, or the test-mode infrastructure.

Cleaning up after development

When you’re done with a development session:
1

Stop the tunnel

Free the port and disconnect the tunnel to avoid leaving an open public URL pointing at your machine.
2

Set the subscription to inactive

PUT /api/Webhook/{id} with status: 2 to pause event delivery until your next session. This prevents events from queuing up at a tunnel URL that no longer works.
3

Or delete the subscription entirely

If the work is complete, DELETE /api/Webhook/{id} to remove the subscription. Keeps the customer’s webhook subscription list clean.
4

Rotate the secret if it was committed to source control

Dev secrets often leak into source control by accident. If your secret was ever in a commit, generate a new one and update via PUT /api/Webhook/{id}.

Where to go next

Signature Verification

The verification pattern you’re testing locally.

Idempotency and Safe Reprocessing

Test the dedup logic by re-firing the same event.

Retry Behavior

Simulate retry scenarios locally by returning 5xx from the receiver.

Process a Donation

The donation flow that fires the events you’re testing.
Last modified on May 20, 2026