PayPal-Request-Id is set per client, not per call — and nothing ever sets it, so create_order and capture_order have no idempotency (client.ts:122)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 194
- Forks
- 114
- Avg merge
- 3d 13h
- Merged PRs (30d)
- 1
Description
Summary
PayPal-Request-Id — PayPal's idempotency header — is populated from a client-level configuration field, not from anything per-call:
// typescript/src/shared/client.ts:113
async getHeaders(): Promise<Record<string, string>> {
...
if (this._context.request_id) {
headers['PayPal-Request-Id'] = this._context.request_id; // one value, every request
}
...
}
request_id is declared in Configuration (shared/configuration.ts:5, and shared/configuration.py:14) and is never assigned anywhere in the toolkit — the only references in the codebase are the declaration and this read. getHeaders() is the single header path for every call, create_order and capture_order included.
That shape leaves an integrator with two options and no correct one:
Left at its default (unset). No PayPal-Request-Id is sent on any request. create_order and capture_order have no idempotency at all. An agent framework that retries a timed-out tool call — the default behaviour in every framework this toolkit targets — produces a second order, or a second capture on the same order.
Set, as the configuration API invites. The same value is attached to every request for the lifetime of the client. Distinct, legitimate orders then collide on one idempotency key, and PayPal answers the second call with the first call's result. That is worse than the default, because it is silent and returns a plausible wrong answer rather than an error.
There is no third path. The toolkit exposes no way to vary request_id per call, so an integrator cannot express "this order is one purchase, retry it safely."
What I verified
request_idappears exactly four times across both SDKs: declared in the TSConfigurationinterface, declared and stored in the PythonConfiguration.__init__, and read once inclient.ts:122. Nothing writes it.getHeaders()is called from every request site inshared/functions.ts(lines 70, 96, 122, 156, 182, …), so this applies uniformly, not to one endpoint.createOrder(functions.ts:1007) andcaptureOrderroute through the same client, andcapture_orderis dispatched inapi.ts:178.- No tool description or parameter schema mentions idempotency, retry safety, or reusing an identifier.
Why this one is worth more than the usual
Capture is the sharp end. A retried capture_order against an already-captured order is the case PayPal's own PayPal-Request-Id exists to make safe, and it is precisely the call an agent is most likely to repeat after a dropped connection — the money has moved, the reply did not arrive, and nothing in the response tells the caller which happened.
The ceiling on this report
This is a code read. I have not run the toolkit against PayPal's sandbox and observed duplicate orders, and I would want to before claiming a rate rather than a mechanism. What I can show is that the header is per-client by construction, that nothing sets it, and that no per-call path exists.
If PayPal's API deduplicates create_order/capture_order server-side independently of PayPal-Request-Id, this reduces to a documentation gap — and I would say so publicly. I could not determine that from outside.
Direction of a fix
The shape of the fix matters more than the wording here, so briefly:
- Accept
request_idper call, not per client — an optional parameter on the order and capture tools that flows intogetHeaders()for that request only. - Derive a default from the call's own content when the caller does not supply one (for
capture_order, the order id is a natural stable key), so that a retry of the same logical operation carries the same header without the caller having to think about it. - Say it in the tool description: that the value must be stable across retries of the same purchase and distinct between different purchases. Both halves matter — the current API makes it impossible to satisfy them simultaneously.
- If per-client
request_idhas a legitimate use, keep it, but document that it must not be set when the client serves more than one order.
For context on why I look at this seam: I read money paths for the failure where an outcome that could not be determined becomes a second charge. Ten paths over three days; seven could double-charge. The public record with evidence on each row is at https://aurumflux.co/retry-safety/ — nobody is named there while their finding is open, and teams that fix are listed with credit and their time-to-fix.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with typescript/src/shared/client.ts and the Configuration declarations in shared/configuration.ts, then trace getHeaders() callers in shared/functions.ts and the createOrder, captureOrder, and capture_order entry points. The issue provides no test file to run; done should establish and verify a per-call, retry-stable idempotency path and document its use in the relevant tool descriptions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, typescript
- Domain
- api, backend-api-design, payments
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100