# Local development (/en/integrate/webhooks/local-development)

Tunnel webhook deliveries to your laptop while you build. Build, fix, replay, repeat.

XPay can't deliver webhooks to `localhost`. To exercise your handler against real test-mode events while you build, expose your dev server through a tunnel that gives you a public HTTPS URL, point a test-mode webhook endpoint at that URL, and iterate.

The flow looks like this:

1. Run your handler locally on a port (say `3000`).
2. Run a tunnel that forwards a public HTTPS URL to that port.
3. In the dashboard, in test mode, create a webhook endpoint pointing at the tunnel URL.
4. Trigger events (test payment, refund, etc.) and watch them land on your laptop.
5. When something's broken, fix the handler and **Resend** the failed event from the Workbench. No need to re-trigger the original action.

## Set up a tunnel [#set-up-a-tunnel]

Any tool that forwards a public HTTPS URL to a local port works. Two common picks:

| Tool              | Stable URL                          | Notes                                                                                                          |
| ----------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Cloudflare Tunnel | Yes, when you create a named tunnel | Free. A named tunnel keeps the same hostname across restarts. Best for long-running dev. CLI is `cloudflared`. |
| ngrok             | Random per restart on the free plan | Fast to start. The free plan rotates the hostname every restart, so you re-edit the endpoint URL each time.    |

Pick whichever you already have. The webhook flow doesn't care which one signs your TLS cert.

<Tabs items="[&#x22;Cloudflare Tunnel&#x22;, &#x22;ngrok&#x22;]">
  <Tab value="Cloudflare Tunnel">
    <Steps>
      <Step>
        ### Run your handler [#run-your-handler]

        Start your dev server on whatever port your handler listens on. The examples below use `3000`.
      </Step>

      <Step>
        ### Start a quick tunnel [#start-a-quick-tunnel]

        The simplest path: a one-shot tunnel that gives you a random `*.trycloudflare.com` URL.

        ```bash
        cloudflared tunnel --url http://localhost:3000
        ```

        The command prints a public URL in the form `https://random-words-1234.trycloudflare.com`. Copy it.

        For a stable hostname across restarts, set up a named tunnel and config file ([Cloudflare's docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/)). Once configured, your tunnel keeps the same URL forever.
      </Step>

      <Step>
        ### Add the URL as a webhook endpoint [#add-the-url-as-a-webhook-endpoint]

        In the dashboard, switch to **Test mode**, open **Developers → Webhooks**, and click **Add endpoint**. Use:

        * **Endpoint URL**: `https://random-words-1234.trycloudflare.com/webhooks/xpay` (your tunnel URL plus your handler's path).
        * **Events**: pick the ones your handler cares about. `checkout.session.completed` is the usual starting point.

        Click **Add endpoint**, copy the `whsec_*` from the dialog, and paste it into your dev env (`.env.local`, `direnv`, or whatever you use):

        ```bash
        XPAY_WEBHOOK_SECRET=whsec_...
        ```

        Restart your dev server so the env var loads.
      </Step>
    </Steps>
  </Tab>

  <Tab value="ngrok">
    <Steps>
      <Step>
        ### Run your handler [#run-your-handler-1]

        ```bash
        # In the terminal where your dev server is
        pnpm dev # or whatever you use
        ```
      </Step>

      <Step>
        ### Open a tunnel [#open-a-tunnel]

        ```bash
        ngrok http 3000
        ```

        The terminal shows a `Forwarding` line with a `https://*.ngrok-free.app` URL. Copy it.
      </Step>

      <Step>
        ### Add the URL as a webhook endpoint [#add-the-url-as-a-webhook-endpoint-1]

        In the dashboard's **Test mode**, open **Developers → Webhooks → Add endpoint**, paste the tunnel URL, pick events, copy the `whsec_*` into your env, restart your handler.
      </Step>

      <Step>
        ### Update the URL each time you restart ngrok [#update-the-url-each-time-you-restart-ngrok]

        The free plan rotates the public hostname every time `ngrok http` restarts. When that happens:

        * Open the endpoint in **Developers → Webhooks**, click the `⋮` menu, pick **Edit webhook**, and replace the URL.
        * The `whsec_*` does not change on edit, so your handler's env stays the same.
      </Step>
    </Steps>
  </Tab>
</Tabs>

## Trigger events to test [#trigger-events-to-test]

Anything that fires a webhook in production fires the same event in test mode. The fastest paths to drive traffic at your handler:

* **Take a test payment.** Create a Payment Link in the dashboard, open it, pay with the success card `5123 4500 0000 0008` and expiry `01/39`. See [Test mode and test cards](/get-started/test-mode) for the full matrix and the failure expiries.
* **Issue a test refund.** From the test-mode dashboard, open a successful transaction and click **Refund**. The handler receives `refund.created` and `charge.refunded`.
* **Replay an event.** The Workbench's Events tab keeps every test-mode event for 90 days. Click **Resend** on any delivery to feed your handler that exact payload again. See [Replaying & retries](/integrate/webhooks/replaying-and-retries).

A typical loop while building: take one test payment, watch it hit your handler, fix what's broken, click **Resend**, repeat. You don't need to take a fresh payment for every iteration.

## Sign-off checklist before flipping to live [#sign-off-checklist-before-flipping-to-live]

Before you stop testing locally and move to live mode, verify each of these against your tunnel-delivered events:

* **Signatures are checked.** Your verifier returns 400 on a tampered body. Test it manually by mutating one byte of a captured request.
* **Replays are idempotent.** Click **Resend** on the same event five times. Your fulfillment runs once.
* **Bad signatures don't crash the handler.** Returning 400 is fine; throwing 500 because of a header parse error is a security smell.
* **The handler acks within 30 seconds.** If your work takes longer, push it to a queue and return 200 immediately. XPay considers anything slower a failure and retries.
* **The signing secret is not committed.** It lives in a local env file or a secret manager, not in your repo.

When all five pass against test events, you're ready to add a separate live-mode endpoint with its own URL and its own `whsec_*`. Keep both endpoints subscribed to the same events; develop and deploy independently.

## Where to next [#where-to-next]

<Cards>
  <Card icon="<Cable />" title="Setting up an endpoint" href="/integrate/webhooks/setting-up-an-endpoint">
    Walkthrough of the dashboard fields, the events selector, and the signing-secret dialog.
  </Card>

  <Card icon="<ShieldCheck />" title="Verifying signatures" href="/integrate/webhooks/verifying-signatures">
    The verifier code, framework-specific raw-body recipes, and the idempotency pattern.
  </Card>

  <Card icon="<RefreshCw />" title="Replaying and retries" href="/integrate/webhooks/replaying-and-retries">
    The retry schedule and the Resend button you'll lean on while debugging.
  </Card>

  <Card icon="<Braces />" title="Event reference" href="/integrate/webhooks/event-reference">
    Every event you can subscribe to, the envelope shape, and what `data.object` carries.
  </Card>

  <Card icon="<Network />" title="Object model" href="/integrate/object-model">
    How Checkout Session, Payment Intent, Charge, Refund, and Customer relate.
  </Card>
</Cards>