Skip to content

PayPal sandbox only. No real money moves.

OperatorsSafety

Webhooks

How a PayPal webhook is verified, stored once, matched to a hold and merged, and why a lost or repeated one does no harm.

Verifiedbacked by a real run or committed evidence

Page last changed 7 Nov 2026 (from the git history)

A webhook is PayPal knocking on GAVEL's door to say "something happened to a hold". GAVEL never takes the knock at its word. It checks who knocked, writes the message down once, asks PayPal what the hold looks like now, and only then updates its own record. The settlement also reads the truth at PayPal itself, so a webhook that never arrives cannot stall a settlement.

One webhook delivery, from the route to the hold

Sandbox: verify (G4)Fake: tampered event
One webhook delivery, from the route to the holdSix stages. The route reads raw bytes and refuses non-POST and bodies over 256 kilobytes. The type filter answers 200 to event types the app does not act on. Verify asks PayPal to check the signature; failures answer 400, an unreachable PayPal or an exhausted call budget answers 503 so PayPal sends it again. Store keeps one row per event id and a repeat stops there. Find the hold matches the authorization id and parks unknown ones for ten minutes. Truth then merge reads the hold at PayPal and only a held hold changes. A sweep applies stored events that have no result.PayPal deliveryclick a stage, or use Next, to read what it doesRoutePOST /webhooks/paypal1not POST: 405over 256 KB: 413else: raw bytes go on,never parsed firstType filteracceptsEventType2VOIDED, DENIED, CAPTURE.*:go onany other type: 200,no verify call, not storedVerifyPayPal's endpoint3SUCCESS: go onforged or malformed: 400PayPal unreachable: 503over call budget: 503Storeinsert if absent, by id4new id: saved, go onseen id: stop, 200cannot store: 503Find the holdby authorization id5found: go onunknown: parked 10 minstill unknown after 10:droppedTruth, then mergeonly a HELD hold moves6status comes from PayPal,never from the bodyread fails: stays stored,the sweep tries againsweep(): every resume pass (default 60 s) appliesstored events that have no result yetThe answer PayPal gets200stored (also: ignored type, repeat id,unknown hold, parked). PayPal stops.400signature failed or body malformed.Nothing stored, no state touched.503could not check or could not store:PayPal sends the event again.

Scroll the figure sideways, or open it full size.

Stage 6 of 6. Arrow keys work too.

6. Truth, then merge. The hold is read at PayPal. Only a hold that is still HELD changes, and the new status is what PayPal says, so a late, early or repeated event cannot move money or state backwards. Capture events also make the settlement read the truth again.

Read from packages/server/src/http/webhook.ts, webhook-guard.ts, packages/payments/src/webhooks/handler.ts, verify.ts and packages/server/src/runtime/webhook-intake.ts. Gate G4 verified six real deliveries through PayPal's endpoint; rejecting a tampered event is covered against the fake PayPal only, and whether a PayPal retry keeps its original transmission headers is UNVERIFIED. The late-event parking is shown by the simulated scenario S7.
Text version of this diagram
  1. Route: POST /webhooks/paypal only (405 otherwise); a body over 256 KB is 413. The body stays raw bytes.
  2. Type filter: AUTHORIZATION.VOIDED, AUTHORIZATION.DENIED, CAPTURE.PENDING, CAPTURE.COMPLETED, CAPTURE.DENIED and CAPTURE.DECLINED are acted on. Any other type is answered 200 with no verify call.
  3. Verify: headers, certificate host, algorithm and time are checked, then PayPal verifies the original bytes. Success continues, a forged or malformed delivery is answered 400, PayPal being unreachable or the call budget being used up is 503 so PayPal sends it again.
  4. Store: insert-if-absent under the event id. A repeat id stops here, answered 200. A store failure is 503.
  5. Find the hold: by authorization id. Unknown is parked for 10 minutes and retried by the sweep, then dropped.
  6. Truth, then merge: read the hold at PayPal; only a HELD hold changes, to the status PayPal reports. A failed read leaves the event stored for the next sweep.

An event for a hold this server does not know is stored, parked and answered 200 so PayPal stops sending it. The answer to PayPal never depends on how the apply step ended: 200 means the event is stored.

In plain words

Check who sent it

Anyone on the internet can POST to a public URL. GAVEL hands the original bytes and five PayPal headers to PayPal's own verify call and accepts only SUCCESS. A forged request is answered 400 and changes nothing.

Write it down once

PayPal gives every event an id. GAVEL stores the event under that id, and a second delivery of the same id is dropped. PayPal does send the same event more than once, so this is routine.

Ask PayPal, do not trust the body

The event only says which hold to look at. The new status is read from PayPal. The amount in the event body is never written anywhere.

Only move forward

A hold that is still HELD can become voided, expired, denied or captured, as PayPal reports it. A hold that is no longer HELD is not changed by an event, so an old event arriving late is harmless.

What each answer means

Prop

Type

The payments handler returns 401 for a failed verification; the HTTP route maps that to 400 (statusFor in packages/server/src/http/webhook.ts).

Which events are acted on

PAYMENT.AUTHORIZATION.VOIDED and PAYMENT.AUTHORIZATION.DENIED can change a hold. PAYMENT.CAPTURE.PENDING, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED and PAYMENT.CAPTURE.DECLINED make the settlement read the truth again, which adopts a completed capture or falls back on a declined one. A capture COMPLETED event can also move the hold to CAPTURED.

Ordering, repeats and strangers

The verification limits

Prop

Type

The certificate URL must be https on paypal.com or a subdomain, and the signature algorithm must be one PayPal documents. Both are checked before PayPal is called.

Setting it up

Registering the webhook is a manual step in the PayPal developer dashboard. The webhook id goes into PAYPAL_WEBHOOK_ID on the host only; it is never committed. The route is POST /webhooks/paypal. Steps are in the deploy guide.

Where this lives in the code

Edit on GitHub

Last updated on