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:

  1. Guardrails, locally - per-transaction cap, rolling daily cap, currency allowlist, and an approval threshold. A blocked or unapproved purchase produces zero PayPal calls.
  2. Order creation - POST /v2/checkout/orders with intent: CAPTURE, PayPal-Request-Id for idempotency, and optionally a payment_source.card.
  3. Settlement - either the card settles during creation (status: COMPLETED) or the order comes back CREATED and the payer must approve on the payer-action link before POST .../capture is legal.
  4. 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, PayPalAuthError on 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_create or paypal.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_APPROVED and ORDER_ALREADY_CAPTURED paths).

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 returns 422 UNPROCESSABLE_ENTITY / ORDER_NOT_APPROVED. The original code assumed create-then-capture and would have shipped broken.
  • payment_source needs an idempotency key. Posting a card without PayPal-Request-Id returns 400 PAYPAL_REQUEST_ID_REQUIRED.
  • Cards settle at creation. A card order returns 201 with status COMPLETED, and a second capture is rejected with ORDER_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 at awaiting_payer and 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

Share this project:

Updates

Submission history