-
-
Overview: 13 breaks across orders, captures, refunds, payouts, disputes and invoices, with the money at stake, on one screen.
-
A $349 dispute. The agent gathered evidence through PayPal's MCP server, drafted the packet, and stopped for a person.
-
The person sees the proposal, evidence and the policy's reason, then approves in one click. The executor submits exactly that.
-
Every agent run is a trace: each tool call, the policy decision and token cost, so "why?" always has an answer.
-
An $18.50 duplicate checkout refunded on its own, within policy, with an idempotency key so a retry can never refund twice.
-
How it fits together: ingest, ledger, reconcile, agent, policy gate, then a person or the executor. One Postgres underneath.
Inspiration
I work on data pipelines at a healthcare company, and a lot of my week is explaining why two systems disagree about a number. Payments has the same disease. A merchant finds out that a capture never matched its order, or that a refund webhook came in twice, or that a dispute deadline passed, days later, and usually from a customer or a bank rather than their own tooling.
What got me is that PayPal already exposes every fact you'd need to catch this: webhooks, Orders, Payments, Transaction Search, Disputes, Payouts, Invoicing. It's all there. Nobody joins it, and nobody is watching at 2 a.m. I didn't want to build a dashboard on top of that. I wanted the merchant to have their own books, and something that notices the moment PayPal and those books disagree and deals with it, within rules the merchant wrote.
What it does
Every PayPal webhook and API read goes into an append-only ledger. Nothing in it is ever updated. Thirteen rules run on each event and open a "break" when the ledger and PayPal don't agree: what I expected, what I saw, and the money in between.
For each break an agent investigates. It reads the ledger, asks PayPal through the official MCP server, and has to answer with a structured proposal: the action, the amount, the ids, how confident it is. No prose. Then a policy file decides. An $18.50 duplicate checkout gets refunded automatically. Anything involving a dispute, or over the limit, or with low confidence, stops and waits for a person, who sees the proposal, the evidence and the reason the policy stopped it, and clicks approve or decline.
Only one piece of code can write to PayPal. Each approval gets one idempotency key, so a retry can't refund twice. Whatever gets executed goes back into the ledger as a new event.
How we built it
Python, FastAPI, Postgres, React. Postgres does more than usual here. It holds the ledger and the projections, it's the job queue (FOR UPDATE SKIP LOCKED, nothing fancier was needed), and it stores the LangGraph checkpoints. That last part matters: the "wait for a human" step is a real LangGraph interrupt, so a proposal can sit for days and the service can restart underneath it.
On the PayPal side: Webhooks with signature verification, Orders v2 with card payment_source so I could seed the sandbox without a browser, Payments v2, Transaction Search, Disputes v1, Payouts v1, Invoicing v2, and @paypal/mcp as the agent's tools. All money is integers in minor units. I didn't want to debug a float.
Render runs it as a web service, a worker and a managed Postgres, from one Blueprint. On first boot the worker replays a recorded fixture so whoever opens it sees real breaks straight away. 32 tests run against a real Postgres in CI, plus an eval that replays the fixture and checks what the agent proposed against what it should have.
Challenges we ran into
Getting an agent to propose a refund took an afternoon. Making it something I'd let near money took the rest of the time. The rules I ended up with: the model never gets a write tool. Every proposal is a typed object checked against the ledger before anything runs. A refund can't exceed its capture, full stop. Anything a buyer typed is wrapped as untrusted text before the model reads it. If the model errors out mid-run, the run ends as an escalation, not a crash and not a quiet nothing.
Webhook vs API drift was the subtle one. My first version let API snapshots overwrite state that came from webhooks, which destroyed the evidence I needed to detect drift in the first place. Now the ledger keeps both and the rule compares what the ledger implies with what PayPal says today.
And the sandbox. You can't open a dispute from the merchant API, and a lot of flows assume someone is clicking. I wrote a small simulator that creates orders, payouts and invoices from the command line, and a recorder, so every scenario in the demo replays the same way every time.
Accomplishments that we're proud of
Clone it, run four commands, and in about five minutes with no keys it opens nine breaks across six scenarios, refunds the one the policy allows, and leaves seven proposals waiting with the evidence attached. I then pointed it at a real sandbox account and it did the same with real transactions.
What we learned
Keeping the merchant's own event log changed what the AI part even means. Instead of "the model thinks this is a duplicate" it's "here are the two capture events, 31 seconds apart, same cart". Everyone who looked at it asked why before they asked anything else. The proposal, the policy's reasons and the tool trace answer that, and honestly that mattered more than any prompt work I did.
What's next for Ledgerline
Real sign-in with roles instead of a shared token, and the approver's name on each approval. Attachments on dispute evidence (text only today). Braintree and Venmo as sources. Editing the policy from the console. And a nightly report a finance person could sign off on.
Built With
- ag-grid
- claude
- docker
- fastapi
- langchain
- langgraph
- mcp
- paypal
- postgresql
- python
- react
- render
- sqlalchemy
- typescript
- vite

Log in or sign up for Devpost to join the conversation.