Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

Registration and holds

How a buyer joins an auction, what a hold is in plain words, and every step from choosing a ceiling to HELD.

Verifiedbacked by a real run or committed evidence

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

In plain words

A hold is a reservation on a buyer's PayPal funds. PayPal calls it an authorization. It is not a payment: nothing leaves the buyer's account when the hold is placed. Think of a hotel asking for a card at check-in. The hotel reserves an amount, and only charges what you actually owe when you leave.

In GAVEL every buyer places one hold before bidding, for a ceiling they choose: the most they can ever be charged in that auction. Bids are checked against that ceiling. When the hammer falls, only the winner is charged, and only the winning price. Everyone else's hold is released.

The flow, step by step

Registration and hold: from choosing a ceiling to HELD

Sandbox: G2 PASS
Registration and hold: from choosing a ceiling to HELDA sequence diagram with four lanes: the PayPal popup, the buyer browser, the GAVEL server and the PayPal Orders API. The browser asks the server for a hold order, the server creates an AUTHORIZE order at PayPal and stores a PENDING_APPROVAL hold, the popup lets the buyer approve, the browser asks the server to authorize the order, and the hold becomes HELD. Below, the hold states PENDING_APPROVAL, HELD, CAPTURED, VOIDED, EXPIRED, DENIED and UNKNOWN.PayPal popupbuyer approvesBuyer browserpackages/webGAVEL serverPOST /api/holdsPayPal Orders APIsandbox or fakename + ceiling preset (cents)POST /api/holds {ceilingCents}open? ceiling in cap and presets?POST /v2/checkout/orders AUTHORIZEorder id, CREATED201 {holdId, orderId}hold: PENDING_APPROVALstart popup for orderIdonApprove {orderId}cancel or decline:DELETE /api/holds/:idPOST .../approve {orderId, name}authorize orderauthorization id200 {HELD, ceilingCents}Hold record states (hover or focus a box)PENDING_APPROVALHELDCAPTUREDVOIDEDEXPIREDDENIEDUNKNOWNfinal: VOIDED, EXPIRED, DENIEDread from PayPal, replaced laterheldchargedreleasedneeds review

Scroll the figure sideways, or open it full size.

Step 1 of 8. Arrow keys work too.

Choose a name and a ceiling. The buyer types a display name and picks one of the room presets (the web default is 100, 250, 500 or 1,000 dollars, in cents 10000, 25000, 50000, 100000). The ceiling is the most they can ever be charged. Nothing has been sent yet.

Hover or focus a state box below for what it means and which file sets it.

Messages, checks and states are read from packages/server/src/http/holds.ts, packages/payments/src/holds/service.ts, packages/payments/src/ceiling.ts, packages/web/src/payments/paypalV6Adapter.ts and packages/web/src/screens/register. Evidence: gate G2 (sandbox, 5 Nov 2026) shows a wallet buyer and a guest card buyer both reaching onApprove and authorizing in SDK v6. Capture of a guest card authorization has not been run on the sandbox.
Text version of this diagram
  1. Choose a name and a ceiling. The buyer types a display name and picks one of the room presets (the web default is 100, 250, 500 or 1,000 dollars, in cents 10000, 25000, 50000, 100000). The ceiling is the most they can ever be charged. Nothing has been sent yet.
  2. Ask the server for a hold order. The browser sends POST /api/holds with the auction id and the chosen ceiling in cents. It sends no bidder id: the server takes the bidder from the session token. Web state: preparing.
  3. The server creates the AUTHORIZE order. The server refuses a closed auction, then checks the ceiling: a whole number of cents, at least 100, at most the cap, and one of the auction presets. Only then does it create an Orders v2 order with intent AUTHORIZE for exactly that amount, under a fixed request id, and store a hold in PENDING_APPROVAL. The answer is 201 with holdId and orderId.
  4. The PayPal popup opens. The browser loads the sandbox SDK only now and starts a one-time payment session in a popup for that order id. Web state: awaiting_buyer. The funding choice, PayPal wallet or guest card, is made inside the popup; this code does not branch on it.
  5. The buyer approves. onApprove hands back an order id. The adapter accepts it only if it is the order this attempt created; any other id is an error (order_mismatch) and the unapproved hold is released. A closed popup (cancelled) or a declined instrument also releases it with DELETE /api/holds/:id.
  6. Authorize the approved order. The browser sends POST /api/holds/:holdId/approve with the order id and the name. The server checks that the session owns the hold and the auction still takes holds, then authorizes the order under a fixed request id and stores the authorization id PayPal returns. Web state: confirming.
  7. The hold is HELD. The record becomes HELD. Its ceiling is the smaller of the ceiling asked for and the amount PayPal authorized. The browser shows only the amount the server confirmed, and treats any other amount as an error (hold_mismatch). The bidder is then registered in the room. Web state: held.
  8. Other paths: what else can happen. Popup blocked or failed: the adapter can redirect to an approve link and finish on the return route, but only when the order answer carries approveUrl, which the server route does not return today. A lost authorize answer: the order is read back and an existing CREATED authorization is adopted. Later: capture, void, expiry, denial, or an unknown reading.

Hold states: PENDING_APPROVAL then HELD, then CAPTURED (the winner) or VOIDED. An unapproved order that is abandoned becomes EXPIRED. PayPal can also report DENIED, and an unreadable authorization reads UNKNOWN. VOIDED, EXPIRED and DENIED are final.

The buyer picks a name and a ceiling

The register screen asks for a display name and one of the room's ceiling presets. The web default presets are 10000, 25000, 50000 and 100000 cents (100, 250, 500 and 1,000 dollars); a room can set its own list, and the server accepts only a ceiling from the auction's list whatever the screen offers. The screen explains the choice: the amount is held, not charged.

The browser asks for a hold order

POST /api/holds carries the auction id and the ceiling in cents. It carries no bidder id: the server reads the bidder from the session token, so a buyer can never ask for a hold in someone else's name. The route refuses an auction that no longer takes holds (hammered, or a settlement already exists).

The server checks the ceiling and creates an AUTHORIZE order

validateCeiling runs before any PayPal call. The ceiling must be a whole number of cents, at least 100, at most the configured cap (GAVEL_MAX_CEILING_CENTS) and one of the auction presets. Then the server creates an Orders v2 order with intent AUTHORIZE for exactly that amount, under a request id derived from the auction, the hold and the amount, and stores a hold in PENDING_APPROVAL. The browser contributes nothing to the amount except the choice among presets.

The buyer approves in the PayPal popup

The browser loads the PayPal SDK (sandbox host only) when it is needed, not at page load, and opens a one-time payment session for that order. The buyer chooses how to pay inside the popup.

The browser asks the server to authorize

POST /api/holds/:holdId/approve carries the order id and the name. The server checks that the session owns the hold and the auction still takes holds, then authorizes the order and stores the authorization id PayPal returns. The record becomes HELD. Its ceiling is the smaller of the ceiling asked for and the amount PayPal authorized: a hold never exceeds what PayPal reserved.

The bidder is registered in the room

When the approval names the buyer, the room registers the bidder with the hold. The browser shows only the amount the server confirmed; a different amount is treated as an error (hold_mismatch).

Wallet or guest card

Both ways of paying go through exactly the same server flow. The code does not branch on the funding choice: it is made inside PayPal's popup.

The buyer signs in to PayPal and approves. Evidence: gate G2 on the sandbox (5 Nov 2026) reached onApprove and authorized an order for a wallet buyer. Gate G1 captured with PayPal-account buyers (one funded by a card, but still a PayPal account).

What if something goes wrong

The four routes

RouteWhat it doesWho may call
POST /api/holdsCreates the hold order. Answers 201 with holdId and orderId.the session bidder
POST /api/holds/:holdId/approveAuthorizes the approved order; answers the HELD hold.the session that owns the hold
GET /api/holds/:holdIdReads holdId, status, ceilingCents.the session that owns the hold
DELETE /api/holds/:holdIdAbandons an unapproved hold. Idempotent.the session that owns the hold

A guessed id and somebody else's id look the same to the caller: both answer order_not_found.

Error codeHTTP status
ceiling_out_of_range400
hold_exists409
order_not_found404
order_not_approved409
paypal_unavailable503
kill_switch_engaged503
payments_not_sandbox503

Hold states

Prop

Type

Webhooks and reconciliation merge in later readings in a fixed order: PENDING_APPROVAL, then HELD, then CAPTURED; VOIDED, EXPIRED and DENIED are final, and CAPTURED against a final state is a conflict that is not applied. See Webhooks and Settlement saga.

Where this lives in the code

Next: Settlement walkthrough shows what happens to these holds at the hammer.

Edit on GitHub

Last updated on