You wired up a PayPal integration, subscribed to PAYMENT.CAPTURE.COMPLETED, and now you need to confirm your handler actually fires when a payment lands. The problem: PayPal only speaks to public HTTPS URLs. Your handler lives on localhost:3000. So you deploy to staging, tail logs, tweak one line, redeploy, wait, repeat. Every iteration costs minutes you'll never get back.
Worse, PayPal signs each event with a set of Paypal-Transmission-* headers, and any tunneling hack that rewrites or drops those headers quietly breaks your signature verification — so you can't even test the part that matters most.
Relayers fixes both problems. It gives you a public ingest URL, forwards the original request body and headers untouched to your local machine, and lets you filter and reshape events before they hit your code.
What you'll build
PayPal never knows your code runs on a laptop. It just sees a stable public URL.
Prerequisites
- A PayPal developer account with an app under My Apps & Credentials (sandbox is fine to start).
- Your webhook handler running locally, e.g.
localhost:3000. - The Relayers desktop app or CLI installed. It's free to start — grab it at relayers.app.
1Create an endpoint in Relayers
Open the desktop app and go to Endpoints → Create. Give it a name like "PayPal Sandbox." Relayers generates a public ingest URL that looks like:
https://api.relayers.app/v1/webhooks/wep_8fk29dl3
Hit Copy to grab it. Before touching PayPal, click Send test to fire a sample payload at the endpoint and confirm it's live — you'll see it appear instantly in the Events view.
2Add the webhook in the PayPal Developer Dashboard
In the PayPal Developer Dashboard, go to My Apps & Credentials, open your app, and scroll to the Webhooks section. Click Add Webhook.
- Webhook URL: paste your Relayers ingest URL from step 1.
- Event types: subscribe to what you actually handle. Common ones:
PAYMENT.CAPTURE.COMPLETED— a payment was captured.CHECKOUT.ORDER.APPROVED— buyer approved an order.BILLING.SUBSCRIPTION.ACTIVATED— a subscription went live.
Save it. Every event PayPal sends arrives with its signature headers intact:
Paypal-Transmission-Id: ...
Paypal-Transmission-Sig: ...
Paypal-Transmission-Time: ...
Paypal-Cert-Url: ...
Paypal-Auth-Algo: ...
These are what the verify-webhook-signature API uses to prove the event came from PayPal. Relayers forwards them byte-for-byte, so verification behaves exactly as it will in production.
To generate traffic on demand, use the Webhooks Simulator in the dashboard. Pick an event type, point it at your endpoint, and send — no real transaction needed.
3Forward events to localhost
Authenticate the CLI once:
relayers login
Then open a tunnel that forwards incoming events to your local handler:
relayers listen --forward localhost:3000
Now trigger a simulated event (or make a real sandbox payment). It flows PayPal → Relayers → your machine in real time. Open the payload inspector in the desktop app to see the full request — body, headers, and status — for anything that comes through.
4Route and filter with JQ
You rarely want every event hitting your handler. In the Rules tab, add a JQ filter so only the events you care about get forwarded. To let through captured payments only:
{ "event_type": "PAYMENT.CAPTURE.COMPLETED" }
Each rule has a destination — a tunnel to localhost:PORT or a public URL — and rules match top to bottom, first match wins. Drag to reorder them. So you might send PAYMENT.CAPTURE.COMPLETED to localhost:3000, route BILLING.SUBSCRIPTION.* events to a different port, and drop everything else.
5Transform the payload
PayPal's payloads are deeply nested. If your local handler only needs a few fields, add a Transformation (jq, JSONata, JavaScript, or Go template) and shape the event before it's delivered. A jq transform to flatten a capture:
{
event: .event_type,
capture: .resource.id,
amount: .resource.amount.value,
currency: .resource.amount.currency_code
}
Use the built-in test playground to paste a real PayPal event and see the output before you save. Your handler now receives clean, predictable JSON instead of a nested tree.
A note on signature verification
This is the part most local-testing setups get wrong. PayPal's verify-webhook-signature API needs the exact Paypal-Transmission-Id, Paypal-Transmission-Sig, Paypal-Transmission-Time, Paypal-Cert-Url, and Paypal-Auth-Algo headers, plus the unmodified raw body and your webhook ID.
Because Relayers forwards the original body and headers untouched, you can run the full verification flow against localhost and get the same pass/fail result you'd get in production. If you use a filter rule without a transform, the body is delivered verbatim — ideal when your handler hashes the raw payload. (If you do transform the payload, verify the signature upstream in your rule logic, since verification depends on the original bytes.)
Going to production
When you're ready to ship, nothing about your integration changes on PayPal's side. Point a rule at your production URL instead of the tunnel, or keep the same endpoint and swap the destination. You can export your endpoint, rules, and transformations as a JSON bundle and import them into a production workspace, so the config you tested locally is the config that runs live. Switch your PayPal app from sandbox to live credentials and you're done.
Troubleshooting
- Nothing arrives: confirm the tunnel is running (
relayers listen --forward localhost:3000) and that the webhook URL in PayPal exactly matches your Relayers endpoint. Use Send test in the app to isolate whether the problem is PayPal-side or handler-side. - Sandbox vs live: sandbox events only come from your sandbox app, and live events only from your live app. Make sure the webhook is registered on the environment you're actually testing.
- No real transactions to trigger events: use the Webhooks Simulator in the dashboard to send any event type on demand.
- Signature check fails: call the verify-webhook-signature API with the raw body and all five
Paypal-Transmission-*headers. If it fails after a transform, remember verification must run against the original bytes — verify before reshaping. Check the payload inspector to confirm the headers actually reached your handler. - Events retry: if your handler errored, replay it from the Events view with retry instead of triggering a whole new payment.
Wrap-up
Testing PayPal webhooks on localhost doesn't require a deploy loop or a hack that breaks your signatures. Create an endpoint, register it in the PayPal dashboard, forward with the CLI, and filter and transform events to fit your handler — with the Paypal-Transmission-* headers preserved so verification works exactly as it will in production.
Ready to stop redeploying to test a webhook? Get started at relayers.app and download the app.