Inspiration
AI agents are starting to pay real bills. The dangerous moments are not the happy path. They are the edges: an invoice that quietly swaps the PayPal email, a payout that is too big, and the timeout where nobody knows if the money went out. A naive agent retries, and the supplier is paid twice. A careful human checks first. We wanted the agent to behave like the careful human, every time.
What it does
An AI accounts-payable agent reads an invoice inbox and pays through PayPal Payouts. Every payout goes through Railguard first:
- Refuses unknown payees, changed payee details (a classic invoice-fraud trick), wrong currencies, payouts over the cap, and payouts over the daily budget.
- Asks a human above 500 USD. The approval is bound to the exact payee, PayPal account, amount, currency, and invoice, so the agent cannot change the terms after approval.
- Never pays twice. Each invoice gets one fixed
PayPal-Request-Id. If PayPal does not answer, the payout isUNKNOWN, the money is frozen in the budget, retries are blocked, and Railguard reconciles with PayPal using that same request id. - Trusts PayPal, not the agent. The final state comes from PayPal's item
transaction_status. Webhooks are verified with PayPal and then re-read, never trusted on their own.
The demo ends with a hard check: one PayPal payout per invoice.
Live sandbox run (10 Nov 2026): A United States PayPal sandbox business app, driven by a scripted (offline) agent, not Gemini. Three payouts were confirmed at PayPal; three bad invoices were refused; a lost response was recovered as a single payment. Evidence is in the repo under docs/hackathons/paypal.
How we built it
- TypeScript on Bun, inside Railguard's open-source kernel. The new package
packages/rail-paypalimplements Railguard'sExecutionRailinterface (prepare, execute, observe, reconcile) for PayPal Payouts. - PayPal APIs (sandbox): OAuth client credentials,
POST /v1/payments/payoutswithPayPal-Request-Id,GET /v1/payments/payouts/{id}, andPOST /v1/notifications/verify-webhook-signature. - Reconcile by request id: PayPal keeps a request id for 30 days and returns the original result when it is sent again. Railguard replays the byte-identical request (body hash checked) to recover a lost response. If PayPal reports a duplicate
sender_batch_id, Railguard follows PayPal's link to the original batch. - AI: The agent has five tools (read invoices, pay, get payout, reconcile, budget status) and no tool to approve, add payees, or raise limits. Google Gemini 2.5 Flash can drive it when
GEMINI_API_KEYis set. The recorded live sandbox run used the scripted model (scripted-offline) with the same tools; no Gemini key was configured for that run. - Chaos drill: in live mode the client really sends one payout to the PayPal sandbox and then drops the response, so the timeout path is tested against real PayPal.
- Tests: 22 unit and end-to-end tests (
bun test packages/rail-paypal) with a PayPal simulator that copies PayPal's request-id replay and duplicate-batch rules, plus fixtures from the live sandbox error shape.
AI tools used: Coding and packaging for this entry used AI assistance. The demo video narration is a synthetic AI voice. The live sandbox run used the scripted agent, not an LLM.
What changed during the hackathon
Railguard existed before the hackathon as a payment guard for crypto rails. Everything PayPal is new and was built during the submission period: the PayPal client, the Payouts execution rail, request-id reconcile, the status mapping, the budget freeze, webhook verification, the AP agent with Gemini, the PayPal simulator, the chaos drill, the tests, and these docs.
Challenges we ran into
- There is no "find payout by request id" API. We had to reconcile by replaying the exact same request, and prove that this is safe: same request id, same body, inside PayPal's 30-day window.
- "Success" is not the end. A
SUCCESSpayout can later beRETURNEDorREVERSED, andUNCLAIMEDcan last 30 days. The rail keeps watching and only frees or commits money on real states. - Timeouts are ambiguous. Treating a timeout as "failed" causes double payments, and treating it as "paid" hides losses. We made
UNKNOWNa first-class state that freezes money and blocks retries. - Sandbox sender country. A first probe with India-based Default Application credentials was rejected (
PAYOUT_NOT_AVAILABLE). India can receive and withdraw payouts but cannot send them. A United States Sandbox Business account and Merchant app unblocked the live run. - Live PayPal error shape. Replaying a lost create does not always return the original batch body. PayPal answered
USER_BUSINESS_ERROR(duplicatesender_batch_id) with the original-batch HATEOAS link underdetails[].linkonapi.sandbox.paypal.com, not on the top-levellinksarray. The client now reads that shape and rewrites the sandbox host alias.
Accomplishments we're proud of
- A real AI agent that tries to do the wrong thing in five different ways and is stopped each time with a clear reason and next step.
- The double-payment check holds against the simulator (22 unit/e2e tests) and against the PayPal sandbox: one payout per invoice, and the lost-response invoice recovered as a single CONFIRMED batch after an idempotent replay.
What we learned
Idempotency is not a header. It is a promise you must keep across timeouts, retries, restarts, and webhooks.
What's next for Railguard for PayPal
- Store attempts in Railguard's Postgres ledger so a restarted agent resumes reconcile.
- Approval requests in Slack or email with the exact terms attached.
- PayPal Orders for agent purchases (buyer side), with the same UNKNOWN and reconcile rules.
- Per-vendor limits learned from payment history.