Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

Idempotency and request ids

How GAVEL can repeat a money call after a timeout or a crash without charging twice.

Code-checkedread against the code it describes; nothing was run

Verified againstRead against the request-id code listed at the foot of the page (PR #1954). Proven against the fake PayPal and a simulation, not on a real host.

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

Pressing a lift button twice does not call two lifts. The second press carries the same intent as the first, and the system treats them as one. PayPal offers the same thing for money calls: a header named PayPal-Request-Id. If GAVEL sends the same id twice, PayPal does the work once and answers the repeat with the first result.

That only helps if GAVEL sends the same id on every try of one call, and a different id for a genuinely different call. This page shows how the id is made and where it is reused.

Request ids: one call, one id, however many tries

Sandbox: R5 repeat captureFake: replay and conflict
Request ids: one call, one id, however many triesTop: five parts (auction id, operation, hold id, amount in cents, generation) are hashed as a JSON array with SHA-256, written in base32, cut to 40 characters and prefixed gv1-, giving a 44 character id. Below, four stages: the intent is saved with the id before the call; the transport retries with the same id; a restart reuses the saved id after reading the truth; a changed amount, hold or operation, or the next reauthorization generation, gives a different id. At the bottom, what PayPal does with a repeated id: the sandbox answered a second capture send with the same capture, the fake replays or reports a conflict, and the truth read protects against a forgotten id.buildRequestId(parts)auctionIdauc1opcaptureholdIdhold-1amountCents35000generation0JSON array[auc1,capture,...]SHA-25632 bytesbase32first 40 characters"gv1-" + 4044 charactersgv1-N6WFTAIWCCILC45XMRS4ZE3E535MCVQCA5WCJ4JGexample from made-up parts1Intent savedjournal, before the callsame id: gv1-N6WFTAIW...WCJ4JGid built once, generation 0saved with the amount, then sentwrite-ahead: no save, no call2Retry in the transportone logical requestsame id: gv1-N6WFTAIW...WCJ4JGnetwork error, 429, 500, 502, 503, 504:up to 4 attempts, 10 s each,backoff random(0, min(4 s, 250 ms x 2^n))same PayPal-Request-Id on every attempt3Restart and resumeprocess died mid-callsame id: gv1-N6WFTAIW...WCJ4JGsaved intent is reused, id repeatstruth is read first: a capturealready there is adopted, not sent again4A different callparameters changednew id: gv1-O4ZTTPFJ...CMIZPother amount, hold or operation:a different idreauthorization: generation + 1never on retry or resumeWhat happens to a repeated idPayPal sandbox (R5)second capture send with the same id:HTTP 200, the same capture.retention time of an id: not measuredFake PayPalsame id, same parameters: stored resultreplayed. Same id, other parameters:conflict, which the saga sends to review.The id is not the only guardIf the id table is forgotten the retry runsagain, so the saga reads the truth first.simulated: S6 ends with one charge

Scroll the figure sideways, or open it full size.

Step 5 of 5. Arrow keys work too.

4. Only a changed call changes the id. A reauthorization uses the next generation number; the capture, void and authorize ids stay at generation 0.

Read from packages/payments/src/requestId.ts, transport.ts, saga/capture.ts, saga/reauth.ts and fake/idempotency.ts. The sandbox nightly run 42200708552 recorded spec R5: a second capture send answered 200 with the same capture. How long PayPal remembers a request id has not been measured here. The crash variants with a forgotten id table are the simulated scenario S6.
Text version of this diagram

Derivation: auction id, operation (order, authorize, capture, void or reauth), hold id, amount in cents and generation are put in a JSON array, hashed with SHA-256, written in base32, cut to 40 characters and prefixed gv1-. The example gv1-N6WFTAIWCCILC45XMRS4ZE3E535MCVQCA5WCJ4JG comes from made-up parts.

  1. Intent saved: the capture intent, with the id, is saved in the journal before the PayPal call.
  2. Retry: the transport makes up to 4 attempts with a 10 second timeout each and full-jitter backoff from 250 ms to 4 s, all with the same PayPal-Request-Id.
  3. Restart: the saved intent is reused, so the id repeats; the truth is read first and a capture already there is adopted.
  4. A different call: another amount, hold or operation gives another id; a reauthorization uses generation + 1.

PayPal sandbox: a second capture send answered 200 with the same capture (R5). Fake PayPal: the same operation, id and request id with equal parameters replays the stored result; different parameters conflict, which the saga treats as an invariant fault and sends to review. If the id table is forgotten the retry would run again, so the saga reads the truth first.

How the id is made

The id is a fingerprint of the call, not a random number. Five parts go into a JSON array, which is hashed with SHA-256 and written in base32. The first 40 characters get the prefix gv1-, so every id is 44 characters long.

PartExampleWhy it is in the fingerprint
auctionIdauc1A call in one auction never matches another auction
opcaptureAn authorize and a capture of one hold get different ids
holdIdhold-1Each bidder's hold has its own calls
amountCents35000A call for another amount is another call
generation0The only part that changes on purpose, see below

Hashing a JSON array, not a joined string, means no choice of ids can produce the same input as another tuple.

Where each operation uses it

Prop

Type

Same id on every try

Save the intent, then call

The capture saves its intent in the journal first: operation, hold, amount and request id. If the process dies after that, the intent survives.

Retry inside one request

The HTTP transport retries network errors and the statuses 429, 500, 502, 503 and 504, up to four attempts of ten seconds each. The wait between attempts is random between zero and the smaller of four seconds and 250 ms times two to the power of the retry number. A Retry-After header is honored up to 30 seconds. Every attempt carries the same PayPal-Request-Id.

Restart and resume

A resume finds the saved intent and sends it again, with the same id. Before it sends anything it reads the truth of the hold at PayPal: a capture that is already there is adopted and no second call is made.

A new id only for a new call

If the parameters change, the id changes. A reauthorization raises the generation by one. The generation never changes on a retry or a resume.

Why the id is not the only guard

An id only helps while PayPal remembers it

If PayPal had forgotten the id, a repeated capture could run again. GAVEL therefore reads the truth before every capture and adopts what is already there. The simulation scenario S6 forgets the fake's id table at each crash point and still ends with one charge. How long the real PayPal remembers a request id has not been measured in this project.

If the same id is ever sent with different parameters, the fake PayPal answers with a conflict. The saga treats that as an invariant fault, a bug or a configuration problem, and stops in FAILED_NEEDS_REVIEW instead of trying another bidder.

What is proven

More on how failures are sorted: retries and unknown outcomes and crash recovery.

Where this lives in the code

Edit on GitHub

Last updated on