-
-
The deal is done. The money is the risky part. Caps, payee check, human approval, fail closed.
-
How it works. Every payout passes one check before PayPal is called, and every decision lands in a hash-chained log.
-
The approval page. The rules decide: a small deal is paid (dry run), over the cap or a payee swap is refused, 250 waits for a person.
-
A person approves. The agent cannot pay twice, and a deal that breaks the 24-hour cap is refused.
-
Every decision is logged, and the page verifies the hash chain on every load.
-
Dry run in the terminal. Allowed, over the cap, payee swap. The exact PayPal request is printed and nothing is sent.
-
Human approval, the daily cap, failing closed when the policy is missing, and a tampered log caught.
-
102 tests pass. Stubs only; none of them call PayPal.
Inspiration
I run ArkNet Digital from Atlanta GA. My live app, AI Negotiator, finds the cheapest offers for what you want to buy and writes the script to haggle the price down. Then it stops. You still have to go and pay.
So picture the next step. A small business owner tells her agent: "Get me 500 rolls of packaging tape and 2,000 corner protectors, best price." The agent negotiates both. Now it needs to pay. That's the moment that should worry her, because an agent that can pay is an agent holding her wallet. All it takes is:
- one vendor email swapped in a message thread,
- one retry loop that fires twice, or
- one deal agreed at ten times the budget.
Any of these and the money is gone before she looks.
I wanted the agent to be able to pay, and still never be the one who decides that it's allowed to.
What it does
negotiator-payments is the settlement step for a negotiating agent. Once a price is agreed, it pays the vendor through the PayPal Payouts API. Every payout first has to pass a spending check the agent can't talk its way around:
- Caps. There's a per-payment cap and a rolling 24-hour cap. A 2,400 USD deal against a 500 cap is refused.
- Payee check. Each vendor name is tied to the PayPal email on file. If the name matches but the email doesn't, the payment is refused as a possible payee swap.
- Human approval above a threshold. A 250 USD deal stops and waits. A person approves that exact payment (same vendor, email, amount and deal), and it goes through once.
- No double pay. A deal that already has a payout on record is refused, and retries reuse the same
sender_batch_id. - Fails closed. If the check itself breaks (for example, the policy file is missing), the answer is no and PayPal is never called. Not even for a token.
- Tamper-evident log. Every decision is a hash-chained record. Edit one amount from 2400.00 to 24.00 and verification points to the broken record.
AI's job is narrow on purpose. A Groq-hosted open model (openai/gpt-oss-20b) writes the payout memo the vendor reads. The model never chooses the amount, the recipient, or whether a payment is allowed.
The approval page
python approval_ui.py opens a local page (standard library only, clearly labelled DRY RUN) where the human side is a screen, not a terminal command:
- The agent proposes a payment. Pick one of the negotiated deals, or type a custom one. Each goes through the same check.
- Waiting for a human. Held payments sit in a queue with Approve and Refuse. Approve records your name, and the agent retries that exact payment once.
- Decision log. Every record, newest first, with the hash chain checked on every load. If it stops verifying, the page says TAMPERED and every new payment is refused.
The demo, step by step
python demo.py runs seven real decisions:
- A small deal is allowed and paid.
- A deal over the cap is refused.
- A payee swap is refused.
- A 250 deal is held, a human approves it, and it is paid once. A second attempt is refused.
- A deal that would break the daily cap is refused.
- A broken check refuses.
- The log verifies, and a tampered copy is caught.
In the recorded PayPal sandbox run, two payouts were sent (42.50 USD, and 250.00 USD after approval), and PayPal's API reported SUCCESS for both batches. The five refusals made zero requests to PayPal; the whole run made four requests in total.
How I built it
- PayPal. OAuth2 client credentials plus Payouts (
/v1/payments/payouts) over a thinhttpxclient.- The
sender_batch_idis derived from the approved payment, so a retry can't create a second payout. - Timeouts and 5xx errors are retried up to three times. A 4xx after an unclear attempt is recorded as unknown, not unpaid.
- The client refuses any non-sandbox host unless live mode is explicitly enabled.
- The
- Spending check.
spend_approval.py, with rules in a JSON policy file. Approvals are bound to vendor, email, amount and deal, and expire after 15 minutes.- An optional external approval backend can hold the approver's key separately from the agent's.
- Approval page.
approval_ui.py, Python's built-inhttp.server, bound to 127.0.0.1, always dry run. - Tests. 111 pytest tests with the HTTP layer faked. They cover request shapes, every allow, deny and approval path, failing closed, retries, and the approval page.
Challenges
- Defining "the same payment". An approval must release one specific payment and nothing else.
- Retries after an unclear failure. If PayPal times out, the money may or may not have moved. The code says "unknown" rather than pretending.
- Being honest about local approval. With a local log file, anyone who can write the file can approve. The README says so plainly, and the external approval mode exists to close that gap.
What I'm proud of
- Real sandbox payouts confirmed by PayPal's own API, not a mock.
- Refused payments never touch PayPal.
- I broke seven safety properties on purpose, one at a time, and the test suite caught every one.
What I learned
The hard part of agentic commerce isn't calling the payment API. It's making sure the agent can't be the one who decides it's allowed to pay.
What's next
- Wire this into AI Negotiator so an agreed deal can be paid from the same screen.
- Take the approval page off localhost: a phone-friendly inbox with sign-in, so the owner can approve from anywhere.
- Observe how PayPal answers a repeated batch id in the sandbox, and tighten the retry path to match.
Try it
No keys are needed for a dry run. Every decision is real, and nothing is sent:
pip install -r requirements-dev.txt
python demo.py # dry run without PayPal credentials
python approval_ui.py # approval page, DRY RUN, http://127.0.0.1:8765
python -m pytest # 111 tests, no network
Add sandbox credentials to .env (see .env.example) to send real sandbox payouts. Repo: https://github.com/jayblast-spec/negotiator-payments (Apache-2.0).