Inspiration
An agent that can spend money is only useful if you can still explain, after the fact, why it spent it. Most "agentic commerce" demos stop at a mocked checkout; the hard part is the chain of decisions before the first API call and the evidence afterwards.
What it does
A purchase goes through four gates, and each one is recorded:
- Guardrails, locally - per-transaction cap, rolling daily cap, currency allowlist, and an approval threshold. A blocked or unapproved purchase produces zero PayPal calls.
- Order creation -
POST /v2/checkout/orderswithintent: CAPTURE,PayPal-Request-Idfor idempotency, and optionally apayment_source.card. - Settlement - either the card settles during creation (
status: COMPLETED) or the order comes backCREATEDand the payer must approve on thepayer-actionlink beforePOST .../captureis legal. - Ledger - committed only after settlement, so a rejected purchase or a failed capture cannot consume budget.
x402 support builds and verifies HTTP 402 challenges (x-payment-required header, base64 requirement, nonce and amount checks) for paid resources an agent reaches before it ever opens a PayPal order.
How we built it
src/paypal/client.ts- OAuth2 client-credentials with expiry-aware token caching, orders create/get/capture, 12 s timeout, 429/5xx retry with jitter,PayPalAuthErroron 401/403 with no retry.src/guardrails/spending.ts- deterministic rules, each with a rule id, evaluated before any network call.src/agent.ts- the orchestration and the trace:guardrail.evaluate,paypal.create_order,paypal.settled_on_createorpaypal.await_payer_approval,paypal.capture_order,guardrail.commit.scripts/contract-server.mjs- Orders v2 shapes over real sockets (Basic and Bearer auth,CREATED -> APPROVED -> COMPLETED, 429 injection,ORDER_NOT_APPROVEDandORDER_ALREADY_CAPTUREDpaths).
Challenges we ran into
The sandbox corrected three assumptions that a mocked client would have hidden forever:
- Capture needs approval. A wallet order is
CREATED; capturing it returns422 UNPROCESSABLE_ENTITY / ORDER_NOT_APPROVED. The original code assumed create-then-capture and would have shipped broken. payment_sourceneeds an idempotency key. Posting a card withoutPayPal-Request-Idreturns400 PAYPAL_REQUEST_ID_REQUIRED.- Cards settle at creation. A card order returns
201with statusCOMPLETED, and a second capture is rejected withORDER_ALREADY_CAPTURED.
Accomplishments that we are proud of
- A real sandbox settlement: order
4VB84323BA045715C, 1.00 USD,COMPLETED, two HTTP calls, 4156 ms round trip. - A real wallet order (
3AK81340R0019721S) that stops atawaiting_payerand hands back the approval link instead of pretending capture worked. - 9 tests, including that an unapproved purchase issues zero order calls and that a 429 is retried before succeeding.
What we learned
The API is the specification. Three of the four design decisions in this project came from a status code the sandbox returned, not from reading documentation.
What is next
Webhook verification for PAYMENT.CAPTURE.COMPLETED (currently settlement is confirmed by response only), a durable ledger instead of in-memory state, and x402 settlement on top of the same approval flow.
Built With
- ai-agent
- node.js
- orders-v2
- paypal
- spending-guardrails
- typescript
- x402