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.
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
Scroll the figure sideways, or open it full size.
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.
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
- Route: POST /webhooks/paypal only (405 otherwise); a body over 256 KB is 413. The body stays raw bytes.
- 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.
- 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.
- Store: insert-if-absent under the event id. A repeat id stops here, answered 200. A store failure is 503.
- Find the hold: by authorization id. Unknown is parked for 10 minutes and retried by the sweep, then dropped.
- 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 second copy finds its id already stored and stops. It is still answered 200. This is the insert-if-absent step in packages/server/src/runtime/webhook-intake.ts; the simulated scenario S7 checks that a duplicate delivery is dropped.
The authorization id matches no hold yet, so the event is parked for 10 minutes. Every resume pass (default every 60 seconds) tries stored events again. After the window it is closed as dropped.
After a reauthorization the hold record points at the new authorization. An event for the old one matches nothing and is parked, so it cannot void the live hold. In the simulation (S7) the late event for the old authorization is parked and the reauthorized hold stays HELD.
PayPal wins. The status written to the hold comes from the truth read, never from the event body, and the intake does not read an amount from the body at all.
The event is stored and has no result, so the next sweep applies it. The answer to PayPal is 200 once the event is stored, whether or not applying it worked.
Verification calls go to PayPal, so they are budgeted. By default 120 calls per minute, with a smaller share for one source and for deliveries with an old timestamp, so forged traffic cannot crowd out real events. An identical repeated request is answered from memory without a new call. Over budget is answered 503.
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
- packages/server/src/http/webhook.tsthe route, raw body, status mapping
- packages/server/src/http/webhook-guard.tssize limit and logging
- packages/payments/src/webhooks/handler.tstype filter, verify, 200, 401 and 503
- packages/payments/src/webhooks/verify.tssignature check and the call budget
- packages/server/src/runtime/webhook-intake.tsstore, find hold, park, truth, merge, sweep
- packages/payments/src/webhooks/merge.tsthe per-hold write queue the intake shares with other hold writers
- docs/DEPLOY.mdregistration steps
Last updated on