> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Local Testing

> How to develop and debug Raise webhook integrations on a local machine — tunneling, test-mode event generation, signature verification testing, and the webhook log endpoints for inspection.

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

| Requirement                                                 | Examples                                                                                               |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **A tunneling tool** that exposes `localhost` over HTTPS    | ngrok, Cloudflare Tunnel, Tailscale Funnel, localtunnel                                                |
| **A Raise developer organization** with test mode available | Coordinate with your partner manager — see [Base URLs and Environments](/raise/base-urls#environments) |
| **A local webhook receiver**                                | The HTTP server your integration runs                                                                  |
| **The test-payment-method generator**                       | `POST /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

```bash theme={null}
# 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 JavaScript theme={null}
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

| Tool                       | Notes                                                                                               |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| **Cloudflare Tunnel**      | Free; persistent named tunnels available; integrates with Cloudflare's CDN                          |
| **Tailscale Funnel**       | Free for personal use; requires Tailscale account                                                   |
| **localtunnel**            | Free; ephemeral URLs                                                                                |
| **Custom on a public VPS** | If 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 JavaScript theme={null}
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 JavaScript theme={null}
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](/raise/webhooks/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

```bash cURL theme={null}
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 JavaScript theme={null}
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](#step-5-troubleshoot-delivery) below.

### Generate other event types

Different event types fire from different actions:

| Event family         | How to trigger                                                                                                                                   |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Gift events          | Submit a donation via `POST /api/Raise/give`; refund via `POST /api/Gift/{id}/refund`; delete via `DELETE /api/Gift/{id}`                        |
| RecurringGift events | Submit a donation with `isRecurring: true`; update via `PUT /api/RecurringGift/{id}`; cancel via `PUT /api/RecurringGift/{id}/cancel`            |
| Donor events         | Donor-creating donations trigger create events; `PATCH /api/Donor/{id}` triggers updates; `POST /api/Donor/{id}/archive` triggers a state change |
| Campaign events      | `POST /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:

```bash cURL theme={null}
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:

| Field              | What to confirm                                                                    |
| ------------------ | ---------------------------------------------------------------------------------- |
| `httpStatusCode`   | Your receiver returned `200` (or what you intended)                                |
| `success`          | Raise recorded the delivery as successful                                          |
| `serverResponse`   | The body your receiver returned, captured by Raise                                 |
| `eventTypeDisplay` | The label for this event type — useful for confirming the integer-to-label mapping |
| `contextId`        | The 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"

| Check                                                  | What 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"

| Check                                                 | What 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 JavaScript theme={null}
// 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 JavaScript theme={null}
// 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 JavaScript theme={null}
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:

<Steps>
  <Step title="Stop the tunnel">
    Free the port and disconnect the tunnel to avoid leaving an open public URL pointing at your machine.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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}`.
  </Step>
</Steps>

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Signature Verification" icon="shield-check" href="/raise/webhooks/signature-verification">
    The verification pattern you're testing locally.
  </Card>

  <Card title="Idempotency and Safe Reprocessing" icon="check-double" href="/raise/webhooks/idempotency-and-safe-reprocessing">
    Test the dedup logic by re-firing the same event.
  </Card>

  <Card title="Retry Behavior" icon="arrows-rotate" href="/raise/webhooks/retry-behavior">
    Simulate retry scenarios locally by returning `5xx` from the receiver.
  </Card>

  <Card title="Process a Donation" icon="hand-holding-dollar" href="/raise/workflows/process-a-donation">
    The donation flow that fires the events you're testing.
  </Card>
</CardGroup>
