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.
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
Scroll the figure sideways, or open it full size.
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.
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 web adapter reports cancelled or declined and calls DELETE /api/holds/:holdId. The service marks the unapproved hold EXPIRED. If PayPal already holds an authorization on that order (a lost answer), it is voided first. The call is safe to repeat: an already authorized hold is left alone and the route says so.
onApprove is accepted only for the order this attempt created. Any other id ends the attempt with order_mismatch and releases the unapproved hold.
Calls for one auction and bidder run one after the other, so a second call sees the first one's record and gets the same order back instead of creating a second authorization. The web app keeps the registration, order and hold ids in sessionStorage per room, so a reload or the return from PayPal reuses them.
The service reads the order back. An authorization with status CREATED and a known amount is adopted; PENDING is not held yet and DENIED never will be, so neither is adopted. A repeated approve call for the same hold shares one authorize request.
The adapter can send the buyer to PayPal's own approval page and finish on the return route (/r/:roomId/return). That path needs an approve link in the order answer, and the POST /api/holds route returns only holdId and orderId today, so the redirect fallback has no link to follow unless approveUrl is added. This is an open item, not a verified path.
An approval that passed the open check just before the hammer may return after the settlement plan was cut. The server tells the auction service the hold was written HELD, and the service runs the settlement again so that hold is voided.
The four routes
| Route | What it does | Who may call |
|---|---|---|
POST /api/holds | Creates the hold order. Answers 201 with holdId and orderId. | the session bidder |
POST /api/holds/:holdId/approve | Authorizes the approved order; answers the HELD hold. | the session that owns the hold |
GET /api/holds/:holdId | Reads holdId, status, ceilingCents. | the session that owns the hold |
DELETE /api/holds/:holdId | Abandons 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 code | HTTP status |
|---|---|
ceiling_out_of_range | 400 |
hold_exists | 409 |
order_not_found | 404 |
order_not_approved | 409 |
paypal_unavailable | 503 |
kill_switch_engaged | 503 |
payments_not_sandbox | 503 |
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
- packages/server/src/http/holds.tsthe four /api/holds routes and their checks
- packages/payments/src/holds/service.tscreateHoldOrder, completeApproval, abandonHold
- packages/payments/src/ceiling.tsvalidateCeiling
- packages/payments/src/orders.tsthe Orders v2 client (AUTHORIZE order)
- packages/shared/src/hold-contract.tsrequest, response and error codes shared with the web app
- packages/web/src/payments/paypalV6Adapter.tsthe only file that knows the PayPal SDK v6
- packages/web/src/screens/register/RegisterScreen.tsxname and ceiling
- packages/web/src/payments/registrationStore.tsreload and return handling
- packages/payments/src/webhooks/merge.tsthe order of hold states
Next: Settlement walkthrough shows what happens to these holds at the hammer.
Last updated on