How to Receive PayPal Webhooks
PayPal verifies signatures by making you call PayPal, which means your handler now depends on their API being up. Setup, verification, and not charging anyone twice.
Jason Warner
August 2, 2026
How to Receive PayPal Webhooks
If you're relying on the buyer landing back on your success page to know a payment went through, you're losing orders. People close the tab. Phones lose signal mid-redirect. Someone pays from a device that never comes back to your site at all.
The webhook is the source of truth. And PayPal's differs from Stripe's in one structural way that shapes the whole handler.
Setting It Up
In the PayPal Developer Dashboard, pick your app, add a webhook URL, and subscribe to what you need — usually PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.REFUNDED, and the BILLING.SUBSCRIPTION.* family if you sell subscriptions. Save the Webhook ID; verification doesn't work without it.
Sandbox and live are completely separate worlds: different apps, different webhook IDs, different dashboards. Nothing you configure in one has any effect on the other. When someone tells me verification worked perfectly in sandbox and fails in production, it's the webhook ID roughly nine times out of ten.
The Structural Difference
Stripe gives you a signature you check locally against your secret. PayPal expects you to send the headers and body back to PayPal and ask whether they're genuine:
const res = await fetch(
'https://api-m.paypal.com/v1/notifications/verify-webhook-signature',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
auth_algo: req.headers['paypal-auth-algo'],
cert_url: req.headers['paypal-cert-url'],
transmission_id: req.headers['paypal-transmission-id'],
transmission_sig: req.headers['paypal-transmission-sig'],
transmission_time: req.headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
webhook_event: rawBodyAsJson,
}),
}
);
const { verification_status } = await res.json();
if (verification_status !== 'SUCCESS') return res.status(401).end();
Sit with what that means for a second. Your webhook handler cannot validate anything without a network round trip to PayPal. If their API is slow, your handler is slow. If it's down, you can't verify at all, and you're stuck choosing between rejecting real payments and accepting unverified ones. You also need an OAuth token before you can even ask, so that's a second call unless you cache it — and you should, they last hours.
You can verify locally by walking the certificate chain from cert_url, and it removes the dependency. It's also fiddly enough that most teams try it once and go back to the API.
Not Charging People Twice
Payment webhooks are the worst possible place to be non-idempotent, because the failure mode is money.
Two things conspire to produce duplicates. Retries, obviously — you did the work, your response timed out, PayPal tries again. But also overlapping event types: one payment can produce both CHECKOUT.ORDER.APPROVED and PAYMENT.CAPTURE.COMPLETED, and if both paths credit the order, you've credited it twice without a single retry involved.
Guard on the event ID and on the state:
if (await alreadyProcessed(event.id)) return res.status(200).end();
await db.transaction(async (tx) => {
const order = await tx.orders.findUnique({ where: { id: orderId } });
if (order.status === 'paid') return; // nothing to do
await tx.orders.update({ where: { id: orderId }, data: { status: 'paid' } });
await tx.processedEvents.create({ data: { eventId: event.id } });
});
The transaction is doing real work there. Record the event ID and change the order state together, or a crash between the two leaves you having credited the order with no record of the event — and the retry credits it again.
While we're here: fulfil on capture completed, not on approved. Approved means the buyer said yes. Completed means the money moved.
Retries and the Window
PayPal retries with backoff for up to three days, which is genuinely generous. The catch is that every retry re-runs your verification round trip, so a slow handler compounds — you're spending your response budget on network calls before doing any work.
Verify, store the raw event, return 200. Fulfilment, receipt emails, and warehouse syncs all belong in a background job.
Where a Buffer Helps
The awkward shape of PayPal's design is that your endpoint has to be up, fast, and able to reach PayPal's API, all inside the response window. Bluejay Relay gives you slack in each direction: the event is captured the instant it arrives, so a failure anywhere downstream — verification, your app, your database — doesn't lose the payload. Deliveries retry on your schedule rather than PayPal's, and when your fulfilment logic turns out to have been broken all afternoon, you replay the affected captures instead of trying to reconstruct them.
Mostly, though, it's the permanent log. When a customer insists they paid and your database disagrees, having the exact event PayPal sent settles it in about thirty seconds.
Common Snags
Verification returns FAILURE in production, SUCCESS in sandbox. Wrong webhook ID, almost certainly. Live IDs only work against the live API host.
How long does PayPal retry? Three days with backoff. You can also resend recent events from the developer dashboard, which is a nice touch other providers don't offer.
Can I skip the verification call? Only by validating the certificate chain yourself. It's supported, it's more work, and it's easy to implement in a way that looks correct and isn't.
Capture every payment event, even when your app is down. Try Bluejay Relay free.
Build more reliable webhook workflows.
Capture, transform, and retry webhooks with full observability. Free to start, no credit card.