Why Webhook Testing Deserves More Care Than You Think
A payment gateway webhook is not a notification. It is the authoritative source of truth for events that change money. When a UPI deposit clears, the gateway POSTs payment.succeeded to your endpoint and that POST is what tells your ledger to credit the player. If your handler 500s, times out, or silently drops the request, the money is real but your database does not know it.
The second-order failure is worse. Most gateways retry — anywhere from 3 to 24 attempts with exponential backoff — and each retry has the same event ID. A handler that partially succeeded (inserted the transaction, failed before committing) can get replayed and insert it again. The player's wallet shows 2×. The player cashes out. You eat the loss.
Testing webhooks is therefore not "send a request and check it hits the endpoint." It is testing your behaviour under:
- A valid signed request (the happy path)
- A request with a tampered or invalid signature
- Duplicate delivery of the same event ID
- Out-of-order delivery (
refundarriving beforepayment.succeeded) - Timeouts and retries when your handler is slow
- Events your code does not recognise (forward compatibility)
None of this can be tested reliably in production. The cost of doing it there is a reconciliation nightmare. So you need an environment where you can trigger any event, replay it, and watch exactly what your code does with it.
The Four Things You Must Test
Signature verification
Your endpoint is a publicly reachable URL. Anyone can POST to it. Signature verification is the only thing stopping an attacker from forging a payment.succeeded event and crediting their own account.
Idempotency
The same event ID must be safe to process twice. This is non-negotiable because retries are part of the gateway's normal behaviour, not an error condition.
Retry handling
Your handler should respond with 2xx as soon as it has safely persisted the event, and do heavy work asynchronously. A 30-second cron job inside a webhook handler is how you discover your gateway's retry policy the hard way.
Timeout and error behaviour
What does your handler do when your database is slow? When a downstream service is down? You want it to fail loudly with a non-2xx status so the gateway retries, not swallow the error and return 200.
Exposing Localhost: Tunneling Options
Payment gateways cannot POST to http://localhost:3000. You need a public HTTPS URL that forwards to your local process. There are three families of tools, each with trade-offs.
| Tool | How it works | Good for | Watch out for |
|---|---|---|---|
| ngrok | Installs a binary, creates an HTTPS tunnel to your local port, gives you a random URL | Quick ad-hoc testing, inspecting raw traffic in the ngrok dashboard | Free-tier URLs change on every restart — you'll keep re-pasting them into the gateway dashboard |
| Cloudflare Tunnel | Daemon runs as a service; stable URLs on a domain you own | Longer-running test setups, demo environments | More setup; overkill for 10-minute debugging |
| Hosted inspection + forward | A web service gives you a permanent URL, records every request, optionally forwards it to your local process | Capturing the exact payload shape from the gateway, replaying events later | You're routing real (sometimes signed) traffic through a third party — use a test-mode API key |
For most payment integrations, engineers end up combining the last two: a hosted tool to capture and replay, plus a tunnel when you need to single-step through handler code in a debugger.
Hosted Webhook Inspection Tools Compared
These are the tools engineers reach for when they need to see exactly what a gateway is sending. All four give you a public URL and a UI showing captured requests. What differs is what you can do with a captured request afterwards.
| Tool | Free tier | Replay | Forward to localhost | Sample provider payloads |
|---|---|---|---|---|
| Webhook.site | Yes, ephemeral URL | Manual (copy and re-POST) | Via CLI companion | No |
| RequestBin (Pipedream) | Yes, sign-in required | Yes, from UI | Via Pipedream workflow | No |
| WebhookWhisper | Yes, no sign-up for a temporary endpoint | Yes, one-click replay of any past event | Yes, with built-in retry | Yes — 35+ real provider payloads (Stripe, GitHub, Shopify and more) to simulate events without a live integration |
| Beeceptor | Yes, limited daily requests | Yes | Yes | Some |
For day-to-day inspection of "what did the gateway actually send me?", any of these work. The feature that matters most in payments work is replay: once you have captured a real payment.succeeded payload, you want to be able to fire it at your handler again and again as you iterate on the code. Webhook.site's manual copy-paste flow gets old fast.
The sample provider payloads feature is the other thing worth calling out. If you are building against a gateway whose sandbox is flaky (or you are writing a handler before you have a merchant account), being able to POST a realistic Stripe charge.succeeded or Shopify orders/paid event into your own endpoint with one click saves hours. WebhookWhisper's library of pre-baked provider payloads is the most complete we have used for this; if you just want a raw bucket to dump requests into, Webhook.site is still the fastest to start.
Practical tip: Never route production webhook traffic through a hosted inspection tool. Use these strictly with test-mode credentials from the gateway, or on staging. Signed payloads can leak information if the tool is compromised, and you do not want your live deposit events sitting in a third party's history.
A Worked Example: Testing a Deposit Webhook End-to-End
Let's walk through testing a payment.succeeded webhook for a UPI deposit. The same shape applies to PIX, SEPA, card deposits, and payouts.
Step 1 — Get a public URL
Start a hosted inspection endpoint and copy its URL. For the rest of this example, assume it is https://inspect.example.com/r/abc123. Configure the gateway's test-mode dashboard to POST webhooks to this URL.
Step 2 — Trigger a real event
In the gateway's test mode, create a ₹500 test deposit. Within a second or two, the hosted inspection tool should show a captured request. Expand it and you'll see something like:
Save this payload. It is now your test fixture — the exact shape the gateway will send in production.
Step 3 — Forward to your handler
Enable forwarding so the hosted tool also POSTs each captured request to your local server (http://localhost:3000/webhooks/gateway via a tunnel, or directly if the tool forwards from its side). Now every test event reaches both the inspection UI and your code.
Step 4 — Replay the event to iterate
Hit "replay" on the captured event. Your handler runs again with the exact same payload. Change your code, replay, repeat. This is the fast inner loop that makes webhook development tolerable.
Step 5 — Force the failure modes
Now deliberately break things:
- Edit the payload body (change
amount) and replay. Your signature check must reject it. - Replay the same unmodified event twice in a row. Your idempotency check must ignore the second one — no second credit to the player.
- Add a
sleep(60)at the top of your handler and replay. The gateway (or your test tool) should record a timeout and retry. Your handler on the retry must still do the right thing. - Return a 500 from your handler on the first delivery. Confirm the gateway retries, and that when your handler is fixed, the retry succeeds.
Signature Verification Without Shooting Yourself in the Foot
Three mistakes we see constantly:
- Verifying against the parsed body instead of the raw body. JSON serialisation is not canonical —
{"a":1,"b":2}and{ "b": 2, "a": 1 }hash differently. Read the raw request body before any middleware parses it, and verify against those exact bytes. - Using
==or.equals()to compare signatures. This leaks information through timing. Every language has a constant-time comparison function —hmac.compare_digestin Python,crypto.timingSafeEqualin Node. Use it. - Skipping timestamp checks. A valid signature is necessary but not sufficient. An attacker who recorded yesterday's successful webhook can replay it today, and the signature will still verify. Reject events whose timestamp is more than ~5 minutes old.
A correct Node handler sketch:
Idempotency: The One Thing Every Gateway Assumes You Handle
Every mature payment gateway's documentation has a sentence that reads roughly "you must handle duplicate events." Most engineers nod and move on. Do not.
The canonical pattern is a unique index on event_id in a dedicated webhook_events table. On receipt:
- Insert the event ID inside a transaction. If the insert throws a unique-constraint violation, you have already processed this event — return 200 and exit.
- Otherwise, do the business logic (credit the wallet, mark the payout, etc.) inside the same transaction.
- Commit. If anything throws, the event ID rolls back too, and the next retry will process correctly.
The mistake is checking-then-acting without a transaction: "Has this event ID been seen? No → process → record it." Two concurrent retries both see "no" and both process. The unique-index-inside-transaction pattern closes that race.
Retry Behaviour and Timeouts
Typical payment gateway retry policies look like this:
- Stripe: up to 3 days of retries with exponential backoff on non-2xx or timeout.
- Razorpay: retries for ~24 hours.
- PayPal IPN: retries for up to 4 days.
- Most high-risk gateways: somewhere between these, usually 24–72 hours.
Two implications. First, your 2xx must mean "I have durably persisted this event," not "I will eventually do something with it." Return 200 after the DB commit, not before. Second, any real work that takes more than a couple of seconds belongs in a background job that reads from a queue, not in the webhook handler itself. The handler's job is to accept, verify, persist, and acknowledge — fast.
Pre-Launch Checklist
Before you switch a webhook integration to live keys, confirm all of these have been tested explicitly — not assumed to work because the happy path does.
- Valid signed payload is accepted and processed once.
- Tampered payload is rejected with a non-2xx status.
- Missing or malformed signature header is rejected.
- Payload older than your timestamp tolerance is rejected.
- The same event delivered twice produces exactly one business-side effect.
- A slow handler that eventually succeeds does not corrupt state when the gateway retries in parallel.
- A handler that returns 500 on first attempt processes correctly on retry.
- An unknown event type is logged and 200'd, not 500'd.
- Out-of-order events (refund before payment) are handled — either by ordering on
created_ator by a state machine that tolerates it. - Secrets are loaded from the environment, not committed, and rotate-able without downtime.
- Your alerting fires on webhook 5xx rate, not just on absence of webhooks.
FAQ
Do I need a separate webhook endpoint per event type?
No. One endpoint that switches on the event field is simpler and lets you apply signature verification and idempotency uniformly. Some gateways let you configure different URLs per event if you want to — useful only if different event classes have very different SLAs.
Can I just poll the gateway's API instead of handling webhooks?
You can, and for low-volume integrations it is simpler. The problem is latency and cost: to match the responsiveness of a webhook-driven flow (player sees their balance update within seconds of depositing), you would need to poll every few seconds, which is wasteful and rate-limit-prone. Webhooks are the right tool when the event-to-reaction delay matters and the event volume is non-trivial.
What's the difference between a webhook and a callback URL?
A callback URL (aka return URL) is where the user's browser is redirected after a checkout — it is a front-channel signal and can be dropped if the user closes the tab. A webhook is a server-to-server POST that happens regardless of what the user's browser does. Never trust a callback URL to confirm payment; confirm on the webhook.
Should I store the full webhook payload?
Yes, at least long enough to investigate disputes — typically 90 days. Store the raw signed bytes, not just the parsed JSON. If a player disputes a deposit, the raw payload plus signature is your proof that the gateway told you the deposit cleared.
Related Resources
Building a high-risk payment integration?
FalconPay offers signed webhooks, a sandbox with full event simulation, and an SLA on delivery. Talk to our engineering team about your stack.
Talk to Our Team →