Payment webhooks are the automated callbacks that Stripe, Razorpay and other gateways send to your server after a charge, refund or dispute. Three mistakes recur in AI-generated webhook handlers: skipping signature verification (so any caller can trigger your payment logic), missing idempotency keys (so the same event is processed twice), and mishandling delivery retries (so the handler fails on the second delivery or times out and the gateway keeps retrying). Together these failures can result in duplicate fulfilment, missed payments and silent fraud.
How payment webhooks work
When a payment succeeds, your gateway does not just return a response to the user's browser. It also sends an HTTP POST to a server endpoint you have registered - your webhook URL. That POST contains a JSON payload describing the event: a charge ID, the amount, the customer, the status.
Stripe's documentation says your server must return a 2xx status quickly, before any complex logic that could cause a timeout. If it does not, Stripe retries: in live mode it attempts delivery for up to three days with exponential backoff. The same event can therefore arrive at your endpoint more than once.
This means your webhook handler has to do three things reliably: verify the request came from the gateway (not from an attacker), process the event exactly once (not twice), and return a 2xx quickly even if the downstream work takes longer. AI-generated code routinely fails at all three.
Mistake 1 - skipping signature verification
Every major gateway signs its webhook payloads with a secret you store in your environment variables. Stripe uses HMAC-SHA256 and includes the signature in a `Stripe-Signature` header. Razorpay uses an HMAC-SHA256 hash in an `X-Razorpay-Signature` header. Verifying this signature before processing the payload is mandatory - without it, any party who knows your webhook URL can POST a fake event.
A fake `charge.succeeded` event that passes without signature verification could trigger order fulfilment, unlock premium features, or mark an invoice as paid - all without any real money changing hands.
Stripe's SDK provides a `stripe.webhooks.constructEvent(rawBody, sig, secret)` method that performs this verification and throws if the signature does not match. Stripe states it requires the raw body of the request for signature verification, and that any manipulation of it causes verification to fail. Many AI-generated Next.js handlers parse the body with `await req.json()` before verification, which changes the byte string and causes all legitimate webhooks to fail verification. The correct pattern reads the raw body first, verifies, then parses.
Razorpay's SDKs offer a similar helper, `validateWebhookSignature`, and its docs say not to parse or cast the webhook body before validating. Note that Razorpay's own Node.js example passes `JSON.stringify(webhookBody)`, which re-serialises the body, so pass the raw bytes instead. The principle is identical: never touch the parsed payload before the signature check.
If you cannot confirm signature verification is in place, run the free code review on your repo before going live with payments.
Mistake 2 - no idempotency keys
Because gateways retry failed deliveries, your handler will receive the same event more than once. If your handler creates an order or sends a confirmation email on every call, a retry that arrives after a brief server hiccup will create a duplicate order or send the customer a second welcome email.
The fix is to record a unique identifier for each processed event - Stripe documents logging the event IDs you have processed and skipping already-logged events, and Razorpay gives each event a unique `x-razorpay-event-id` header for the same purpose (Razorpay docs) - and check it before doing any work. If the ID is already in your database, return 200 immediately without processing. This is idempotency: the same input produces the same result regardless of how many times it is applied.
A correct idempotency pattern looks like this at a high level: receive the event, check whether the event ID is already in a `processed_events` table, return 200 if it is, otherwise process and then write the event ID to the table in the same database transaction as the business action.
AI-generated handlers often skip this entirely. The generated code processes the event, fulfils the order, and returns 200 - with no record that this event was handled. The first retry from the gateway will produce a second order.
Mistake 3 - handling replays and timeouts incorrectly
A related but distinct problem is timeout handling. If your webhook handler performs a slow operation - sending an email, updating a third-party CRM, kicking off a background job - and that operation takes more than a few seconds, the gateway may not receive your 200 response within its timeout window and will retry.
The correct pattern is to acknowledge immediately and do the slow work asynchronously. Return 200 to the gateway as soon as you have verified the signature and saved the raw event to your database. Then process the event in a background job or queue. This decouples the acknowledgement (which must be fast) from the fulfilment (which may be slow).
AI-generated code tends to do everything inline: verify, process, fulfil, email, return 200. This pattern works for fast handlers and breaks under any latency spike, external API call or database slowness.
Stripe also includes a timestamp in the signature header, and its official libraries apply a default tolerance of 5 minutes so stale, replayed requests are rejected. Stripe warns not to set the tolerance to 0, because that disables the recency check. Hand-rolled verification code often skips the timestamp check, so confirm yours does it.
A checklist for your payment webhook handler
- Raw body is read as a Buffer before any JSON parsing.
- Signature is verified using the gateway SDK before any business logic runs.
- An invalid or missing signature returns 400 immediately.
- Event ID is checked against a processed-events record before any fulfilment.
- Slow operations happen asynchronously - the handler returns 200 before they complete.
- The gateway-provided timestamp is validated to reject stale replays.
- The webhook secret is stored in environment variables, never in code.
For a broader look at what AI-generated code tends to miss in security-critical paths, see payment and auth security issues in AI-built apps. For the specific case of exposed secrets in generated code, see service role key leaked in the frontend.
What a mistake costs
Skipped signature verification is a critical severity finding in a code audit. An attacker who discovers your webhook URL - and URLs are not secret - can trigger arbitrary payment events. In the worst case that means fraudulent order fulfilment or subscription upgrades without real payments.
Missing idempotency is a data integrity problem that may not surface until your first payment volume spike. Under normal load the retry window is short and often the gateway's retry arrives after your server has recovered - so you see a second order. Under a real load spike or outage, every event during the downtime window will be delivered multiple times when your server comes back up.
Timeout failures show up as failed or pending deliveries in your gateway dashboard (Stripe lists timed-out deliveries there with a fix of returning a response immediately). While they fail, your fulfilment logic is not running reliably.
None of these failures produce an obvious error in your app. They fail silently, which is what makes them dangerous. If your app was built with AI tools and has not had a focused review of its payment integration, run the free code review before you take your first payment.
Questions to ask your developer
If you are a founder reviewing your developer's payment implementation, these questions will surface the common problems without requiring deep technical knowledge:
- Where is the webhook secret stored, and how is the signature verified?
- What happens if the same event ID arrives twice?
- What is the timeout behaviour for the handler - does it respond before or after the downstream work completes?
- Is there a test that sends a known event to the webhook endpoint and checks the result?
- Has the handler been tested with a replay of an already-processed event?
A developer who has thought about these concerns will answer them directly. Vague answers - or answers that circle back to "it works fine in testing" - are worth investigating. For a second opinion on your payment integration, get in touch with our team.
Common questions
Why must I verify payment webhook signatures?
Without signature verification, anyone who knows your webhook URL can POST a fake event, such as charge.succeeded, and trigger fulfilment or unlock features without real money changing hands. Verify the gateway's signature, using the raw request body, before any business logic runs.
What happens if my payment webhook handler is not idempotent?
Gateways retry deliveries, so the same event can arrive more than once. Without recording processed event IDs, a retry can create a duplicate order or send a second email. Check the event ID first and return 200 if already processed.
Who can review my payment webhook handler before I go live?
If your app was built with AI tools and its payment integration has not had a focused review, run the free code review before taking your first payment. For a second opinion on the payment integration, Zovintra's team can look at it with you.
Sources
- Receive Stripe events in your webhook endpoint - Stripe docs, accessed 19 Oct 2026
- Validate and test webhooks - Razorpay docs, accessed 19 Oct 2026



