Update, 2026-10-28: Pink Agentic AI Payments is now live in production as a decision layer: your agent asks Pink before each payment, and your own Stripe, card or bank pays only if Pink allows it. Pink never holds or moves your money. Anything below that says early access or test money describes the free public sandbox, which still runs on test money. Production pilot on your own tenant: $99/month, pinkwallet.com/agentic/pilot.
28 payments got through. 12 didn't. The cap was $200 a day, and we fired all 40 at the exact same moment on purpose, because we don't trust our own code more than we'd trust yours.
Disclosure: I work on Pink at PinkWallet. Every number below came from a live run against our public sandbox, reproducible with the script linked at the bottom.
That race is the easy failure mode to catch. The harder one: even a cap that holds doesn't matter if the agent is still sitting on a real payment credential it can use on its own. Pink Agentic AI Payments closes both, by keeping the check and the credential off the agent's side of the fence entirely. Here's what each failure looks like and how it's closed.
Failure mode 1: the race
A spending cap usually gets enforced like this, somewhere in application code or inside the agent's own tool call: read the amount spent so far, check it against the limit, and if it's under, let the payment go through. That works for one call at a time. It falls apart the moment an agent (or a swarm of agents, or retried tool calls) fires several payment requests in parallel.
Here's why. Say the limit is $200/day and $190 has already been spent. Ten parallel calls each ask for $7. Each one reads "spent so far: $190," each one computes $190 + $7 = $197, which is under $200, so each one passes its own check. All ten go through. The agent just spent $70 against a $10 remaining budget, and nothing in that check-then-spend logic ever saw it coming, because the check and the spend were not the same atomic step.
This isn't hypothetical. It's the standard "check-then-act" race condition, and it gets worse, not better, as agents get faster and start running tool calls concurrently to save latency.
Failure mode 2: the agent holds the key
Even if you fix the race, a second problem remains: where does the actual payment credential live? In most setups, the agent process holds a real API key, card number, or wallet credential, and the "spending limit" is a piece of logic that runs in the same process, checking the same memory, before the agent is allowed to use that credential.
The issue is that anything enforced inside the agent's own context can potentially be bypassed by what's inside that context, including text the agent reads from a tool result, a webpage, or a document, if that text is crafted to look like an instruction. If the limit-check code and the credential live next to each other and both are reachable from the same prompt, an injected instruction that convinces the agent to skip its own check, or to call the raw payment credential directly, defeats the limit entirely. A spending limit that the agent itself is responsible for enforcing is not really a limit on the agent. It's a suggestion to the agent.
What Pink does differently
Pink Agentic AI Payments enforces per-agent spending policies at the MCP layer, before a payment executes. Concretely:
The agent never holds a standing payment credential, only a Pink agent key, until a payment is allowed. That key can ask Pink to check a policy, request a payment, or read the current budget. It cannot itself move money. When a payment request is allowed, Pink returns a single-use credential (in the sandbox, a test virtual card) that is locked to that specific payee and that specific amount, and expires 15 minutes after issuance. If the request should instead go to a human (for example, above an approval threshold), Pink returns pending_human and no credential is issued until a person approves it.
Policy decisions and spend recording happen as one step on Pink's side, not split between a check and a later update inside the agent. The "read balance, compare to limit, decide" sequence runs as part of evaluating the payment request itself, with no gap in which two concurrent requests can both read the same "under the limit" state. That's what we tested below.
Repeated requests with the same idempotency key return the original decision. An agent that retries a call (its own retry logic, a flaky network, a dropped response) doesn't get a second roll of the dice, and can't turn one approved payment into two by replaying it with a different amount under the same key.
Running the race against the live sandbox
We ran this against our public sandbox, which anyone can create a free workspace on with no real money involved. The setup: a startup template workspace, agent a_eng ("Eng Infra AI"), governed by a rule that reads "API credits: $200 a day per agent, then stop." We fired 40 parallel $7 payment requests at once, all to the same payee, all in the same instant.
# create a disposable sandbox workspace
curl -s -X POST https://agentic-sandbox.pinkwallet.com/v1/sandbox/workspaces \
-H 'content-type: application/json' \
-d '{"template":"startup","company":"race-test"}'
# -> returns agents[], including a_eng with its own agent key
K=<a_eng's key from the response above>
# fire 40 parallel payments, each under the per-payment amount but
# together far over the $200/day rule
for i in $(seq 1 40); do
curl -s -X POST https://agentic-sandbox.pinkwallet.com/v1/payments \
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
-d '{"payee_id":"p_anth","amount":7,"purpose":"race test","local_hour":12}' &
done
wait
curl -s https://agentic-sandbox.pinkwallet.com/v1/budget -H "Authorization: Bearer $K"
40 requests at $7 each total $280, well over the $200/day rule. If the limit held, something close to 28 should be allowed ($196) and the rest blocked.
| Run | Requests | Allowed | Blocked | spent_today after |
|---|---|---|---|---|
| 1 | 40 x $7 | 28 | 12 | $196 |
| 2 | 40 x $7 | 28 | 12 | $196 |
Both runs, fired fresh against a new sandbox workspace each time, landed on the same split: 28 allowed, 12 blocked, and a final spent_today of $196, never above $200. No request saw a stale "you're still under the limit" answer after the limit was effectively reached. The full script, race_test.sh, is linked below; it creates its own workspace, so there's nothing to configure before running it.
A separate run with bigger payments makes the same point: 12 parallel $25 requests against the same $200/day rule came back 8 allowed, 4 blocked, spending exactly $200.
What Pink does not do yet
To be direct about the limits of this, because an agent payment system that overclaims is worse than one that's honest about gaps:
- No count-based cap today. You can set a dollar limit per day or month, a per-payment cap, payee allowlists, time windows, and human-approval thresholds, but not yet "no more than N payments per day" independent of amount. The engine does not support that today.
- Pink doesn't wrap other providers' MCP payment tools. If an agent is already calling another vendor's payment MCP server directly, Pink's rules don't apply to that call; Pink governs payments made through Pink's own MCP server and REST API.
-
The sandbox is the only thing live today. Everything above runs against
agentic-sandbox.pinkwallet.comwith test credentials and no real money. Production access is by early access; nothing here claims production numbers. -
Server clock, not agent clock. The
local_hourfield is how you tell Pink what time it actually is where the payment is happening; some rules (like approval windows) are evaluated on the server's UTC clock unless you pass it, which can surprise you in testing if you skip it.
None of this is a guarantee that nothing can ever go wrong with an agent that can spend money; it's a description of where the current enforcement boundary sits and where it doesn't yet reach.
Try it
The sandbox is free, takes a POST to create a workspace, and the agent keys it hands back work immediately against both the REST API and an MCP endpoint with seven tools (pink.check_policy, pink.request_payment, pink.get_credential, pink.get_budget, pink.list_payees, pink.list_rules, pink.report_receipt).
- Sandbox: https://agentic-sandbox.pinkwallet.com/?utm_source=devto&utm_medium=article&utm_campaign=parallel-payments
- No-signup demo: https://agentic-sandbox.pinkwallet.com/demo?utm_source=devto&utm_medium=article&utm_campaign=40-payments
- Docs for developers: https://pinkwallet.com/agentic/developers/?utm_source=devto&utm_medium=article&utm_campaign=parallel-payments
- The exact script used above: https://github.com/Pink-Agentic-Payments/sandbox-examples/tree/main/08-race-test (run
./race_test.sh 40 7to reproduce the table; it prints no secrets and creates its own disposable workspace)
If you've hit either of these failure modes building agent payments yourself, tell us how you handled it. And if you can make the script above overspend, we want to know.
Try it yourself: we opened a public challenge, can you make an AI agent overspend? Sandbox, test money only, first attempt takes about 2 minutes: overspend-challenge
Reader questions, answered (updated Oct 10, 2026)
@arhancanli suggested two follow-up tests. First, the same payment fired in parallel with one idempotency key. We ran it on the live sandbox: 10 identical requests sent at once came back as a single payment_id, and the budget moved by exactly one payment. Second, a refund racing a new payment. We can't run that one yet, because the sandbox has no refund that credits budget back: a "refund" today is an outbound payment to a customer, and it spends budget like any other payment. So the race you describe doesn't exist yet, and we'd rather say so than guess how it would behave.
@mickyarun asked what happens when a key comes back with a different payload. Until today it silently replayed the original decision, which was the wrong answer. As of Oct 10 a reused key with a different amount, payee, currency or purpose returns HTTP 409. You also asked whether a decline cached under a key stays declined after its cause clears. It does: we blocked a request on the monthly cap, raised the cap, retried with the same key and got the same block back. A caller needs a new key to get a fresh decision. We're keeping that, because it's the usual idempotency contract, but the response should say more clearly that it's a replay.
On holds: creating a pending_human request doesn't reserve budget, so a hold whose caller crashes doesn't strand money. Approval re-checks the limits, though. When we approved two holds that together exceeded the remaining budget, the second approval came back 409 with nothing issued. What's still open is a hold nobody ever approves or declines: it expires after 30 minutes, but only when it's next read.
On exact-amount credentials: you're right. A credential is locked to the exact amount approved, and nothing adjusts it afterwards, so tips, partial shipments and added shipping aren't handled yet. A cap-plus-window model like the one you describe is the better shape, and we're looking at it.
Update Oct 13, 2026. @arhancanli suggested two follow-up tests: the same payment retried with the same idempotency key in parallel, and a refund racing a new payment. We added both to the open suite. Same key fired 10 times at once, repeated 4 times: exactly one payment and one unit of spend every time. The refund race couldn't be run, because the sandbox has no refund or reversal operation yet, so it's recorded as not applicable rather than skipped quietly. @mickyarun's point about order-dependent declines under mixed amounts still stands; uniform $7 payments can't show it. Results and code: https://github.com/Pink-Agentic-Payments/agent-spend-edge-cases
Top comments (2)
Firing all 40 at once on purpose is the right way to test a cap. Sequential tests pass for check-then-act code every time.
For anyone implementing this themselves, the standard fix is to make the check and the spend one atomic statement:
UPDATE budgets SET spent = spent + :amt WHERE id = :id AND spent + :amt <= cap, then treat "0 rows updated" as declined. The database serialises it, so no separate lock is needed. If payments can fail after authorisation, reserve first and release on failure, or the budget leaks the other way.Two cases worth testing next: the same payment retried with the same idempotency key in parallel (should count once), and a refund racing a new payment (does the refund free up budget before or after the cap check?).
@arhancanli is right that the single atomic statement is the fix, and treating zero rows updated as declined is the version I would ship. It closes the race. It does not make the outcome chosen, and that is the part of your 28 and 12 I would put a caveat on.
All 40 requests were $7. With mixed amounts and the same atomic check, which requests survive depends on arrival order, and headroom at the end gets stranded. Four dollars left, the next arrival asks for ten, blocked, and a seven behind it would have fitted but arrives too late. The cap is still correct. Which payments died is now a function of scheduler jitter, and those are real payments to real payees.
That interacts badly with the part of the design I like most. Idempotency keys returning the original decision is right against double-spend. It also means a request declined purely for arriving third is permanently declined under that key. The agent retries, gets the same no, and cannot tell a policy decline from an order decline. Those want different outcomes: one is final, the other should be retryable with a fresh key once headroom frees. Put the reason in the response and give order-contention its own value.
On reserve-first-release-on-failure, the thing to watch is a reservation nobody releases. A crashed caller leaves a hold that is indistinguishable from spend, so the cap shrinks permanently and the only cure is a human. That needs a reservation expiry, and now the expiry has to outlast the slowest capture, which is where your next point lands.
Failure mode 2 is the stronger half of the post and I think it is straightforwardly right. A limit enforced in the same context as the credential is not a limit, and no amount of prompt hardening fixes it, because the thing being hardened is the thing that would have to cooperate. Moving the credential off the agent's side is the only move that survives an injected instruction. Suggestion is your word for the alternative and it is the right one.
Where the single-use credential locked to payee and amount breaks first is not an attack. Authorised amount and final amount differ constantly: shipping added after the fact, a tip, a partial shipment, fuel, hotel incidentals. A credential pinned to an exact figure declines the ordinary case. In UK open banking the equivalent consent carries a per-payment cap and a window rather than an exact amount, which gives up precision to survive the real world. I build payment initiation in the UK, so treat that comparison as coming from someone with a stake in it. I do not know what the answer looks like inside a virtual card model and I would read that post.
Last one is the day boundary. Two hundred a day resets at a moment somebody picked, and an agent firing at 23:59 and again at 00:01 spends four hundred in two minutes with both runs compliant. Scheme velocity limits are rolling windows for exactly this. A calendar day is also awkward to reconcile once reversals land: spent_today built from authorisations, with a refund arriving tomorrow, leaves the counter wrong in the direction that lets more spend through.