Charging AI Agents Per Request: A Practical Guide to the Machine Payments Protocol
Agentic commerce is one of those phrases that is currently doing a lot of work.
It can mean an assistant buying a jacket for you, an AI travel agent assembling a trip, a coding agent purchasing one API call, or a research agent paying to unlock a single dataset. Those are very different products, but they share one important shift: the buyer is no longer necessarily a human in a browser, clicking a checkout button.
As with all heavily-hyped new tech, it's possible to get lost in the clouds, stuck at a high level of abstraction. What's been more interesting for me as an implementor has been looking at the concrete details on the ground - what does my server actually receive, what does it verify, and when is it safe to return the thing being bought?
At Square1, we recently published a broader explainer on agentic commerce, machine payments and PayForGoals. This post is the more technical sibling. It's less "why this matters", more "what happens when we put a price on an HTTP resource and someone tries to pay it".
The concrete implementation here is square1/laravel-mpp, a Laravel package for protecting routes with the Machine Payments Protocol. Laravel is the example framework here, but the design questions apply just as much if you are building this in Rails, Express, Go, or the like. Multiple payment gateways also exist. The most practical ones currently are Stripe and Tempo, so we focus on them in the examples below.
The demo app is PayForGoals, a small API that sells historic football scorelines one paid request at a time. It returns the score, though not yet the team names (MVP approach here - team names Coming Soon!)
Where MPP Fits
There are a few layers forming around agentic commerce.
At the higher level, protocols like Universal Commerce Protocol are about discovery, product data, carts, policies, and checkout. They answer questions like "how does an assistant discover what a merchant sells, compare options, and place an order?"
Machine Payments Protocol sits lower down. It is not a shopfront, a catalogue, or a marketplace. It is closer to a payment primitive for HTTP resources.
The core Payment HTTP Authentication Scheme is currently an IETF Internet-Draft, not a final RFC. This is an important note to be aware of! The package targets the published draft rather than inventing its own almost-the-same protocol, but both the spec and the surrounding SDKs are still moving.
The unit of work is deliberately small:
- A client requests a resource.
- The server replies with
402 Payment Required. That response describes the price and accepted payment methods.
The client obtains the required payment credential or signed payment artifact.
The client retries the request with
Authorization: Payment ....The server verifies settlement and only then returns the resource.
MPP at a high level
Step 1
Client requests resource
No payment credential yet.
Step 2
Server returns 402
Signed challenge: price, scope, rail, expiry.
Step 3
Client retries with payment
Server verifies settlement, then serves.
MPP standardises the negotiation around the paid request. The settlement rail decides how money actually moves.
There is no hosted checkout page in that loop. There may still be a wallet, mandate, Link account, or some other human-approved spending authority behind the scenes, but the merchant's application is dealing with a paid HTTP request.
That is a useful place to start because it keeps the questions concrete. What is being bought? What is the price? What proof is presented? Who settles the payment? What happens if the request is retried?

MPP - a slightly lower-level flow
Those questions are much easier to answer for one Laravel route than for a full autonomous shopping ecosystem.
Laravel as a Concrete Implementation
The package requires PHP 8.3 or newer and Laravel 12 or 13.
composer require square1/laravel-mpp
php artisan vendor:publish --tag=mpp-config
The mpp middleware alias is registered automatically. Here we attach a price of $1 and explicitly offer both configured methods:
use Illuminate\Support\Facades\Route;
Route::get('/resource', fn () => response()->json([
'result' => 'some paid data',
]))->middleware('mpp:1.00,USD,methods=stripe|tempo,scope=resource');
If you are not using Laravel, the framework-specific piece is "run this payment gate before the handler". Middleware is just the convenient Laravel way to express that.
An unpaid request now gets a 402:
curl -si https://example.com/resource
The response includes an application/problem+json body and one WWW-Authenticate: Payment challenge for each method the route offers:
HTTP/2 402 Payment Required
Content-Type: application/problem+json
Cache-Control: no-store, private
WWW-Authenticate: Payment id="jR4...", realm="example.com", method="stripe",
intent="charge", request="<base64url>", expires="2026-08-19T12:05:00Z",
opaque="<base64url>", Payment id="kT8...", realm="example.com",
method="tempo", intent="charge", request="<base64url>",
expires="2026-08-19T12:05:00Z", opaque="<base64url>"
{
"type": "https://paymentauth.org/problems/payment-required",
"title": "Payment Required",
"status": 402,
"detail": "Payment is required.",
"challengeId": "jR4..."
}
Stripe and Tempo use the same outer MPP challenge shape. The route can offer both in one response, and the client answers one.
The rail-specific information lives inside request, which is base64url-encoded JSON. Decoded, the Stripe request looks roughly like this:
{
"amount": "100",
"currency": "usd",
"methodDetails": {
"networkId": "profile_...",
"paymentMethodTypes": ["card"]
}
}
The Tempo request carries the same economic fields plus its chain-specific details:
{
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"methodDetails": {
"chainId": 42431,
"memo": "0x...",
"supportedModes": ["pull"]
},
"recipient": "0x..."
}
One dollar is 100 in Stripe's minor units and 1000000 in a six-decimal token. Different settlement payloads, same HTTP grammar. The challenge id is bound to the method, request, expiry, route and opaque data, so changing the quoted amount or swapping the request onto another resource invalidates it. Just like in the real world, a client cannot pay 10c and insist it was close enough.
Choosing a Method
With no preference, the server returns every method the route offers. A client can narrow or rank the response with the standard Accept-Payment header:
GET /resource HTTP/1.1
Accept-Payment: tempo/charge, stripe/charge;q=0.3
That asks for Tempo first and Stripe second. Accept-Payment: tempo/charge returns only the Tempo challenge. It is a hint rather than a payment instruction: the challenges actually returned in WWW-Authenticate remain authoritative.
There is also a machine-readable menu at /openapi.json. The package walks the live Laravel router and adds an x-payment-info offer for each method on each paid route. This is an excerpt containing one path from that document:
{
"paths": {
"/resource": {
"get": {
"x-payment-info": {
"offers": [
{
"method": "stripe",
"intent": "charge",
"amount": "100",
"currency": "usd"
},
{
"method": "tempo",
"intent": "charge",
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000"
}
]
}
}
}
}
}
Discovery is advisory. It helps an agent find the vending machine; the live 402 still tells it what the machine will accept right now.
What Happens on the Paid Retry
The paid retry carries one Authorization: Payment credential. The credential is itself base64url JSON containing the challenge exactly as issued and a method-specific payload. For Stripe, that payload contains a Shared Payment Token:
Authorization: Payment <base64url-json>
Decoded, that credential has this shape:
{
"challenge": {
"id": "jR4...",
"realm": "example.com",
"method": "stripe",
"intent": "charge",
"request": "<base64url>",
"expires": "2026-08-19T12:05:00Z",
"opaque": "<base64url>"
},
"payload": { "spt": "spt_..." }
}
Tempo uses the same credential envelope with a signed transaction in payload and a payer DID in source. The common protocol gets the credential to the right verifier; it does not pretend a card token and an on-chain transaction are secretly the same object.
On the server side, our job is to do the below before our controller runs:
- Find the stored challenge.
- Check it has not expired or already been used.
Verify the challenge binding and exact echoed fields.
- Acquire a settlement lock for that challenge.
Ask the rail-specific verifier to settle the payment.
- Serve the resource only if settlement succeeds.
- Attach a
Payment-Receiptresponse header.
For Stripe, the verifier creates and confirms a PaymentIntent using the SPT. The SPT is not the payment itself. It is a scoped credential that allows the seller to charge within its limits. This is an important flow point to remember, as it's different from Tempo. In this case, our server still has to create the PaymentIntent, confirm it, check it succeeded, and verify the amount and currency match the signed challenge, before any funds get transferred.
When settlement succeeds, the response comes back with the paid resource and a receipt. In PayForGoals, that looks something like this:
HTTP/2 200 OK
Payment-Receipt: <base64url-json>
Content-Type: application/json
{
"tier": "pay-per-view",
"scoreline": {
"id": 1,
"home_score": 7,
"away_score": 1,
"year": 2014,
"stage": "World Cup semi-final",
"decade": "00s",
"teams": null
}
}
Decoded, the receipt contains status, method, timestamp, reference, challengeId, amount and currency. The reference is the settlement reference: a PaymentIntent id on Stripe or a transaction hash on Tempo.
Idempotency: Payment Safety First
Any paid HTTP protocol has to deal with the most common failure case on the internet: the server did the work, but the client never saw the response.
With a normal API, that might mean the client retries and gets duplicate data. Annoying, but usually survivable. With a paid API, a retry can become a second charge unless the settlement path is deliberately idempotent.
We can handle this in a few layers.
First, treat challenges as single-use. Once a challenge has settled successfully, it is burned from the challenge store. A replay of the same challenge cannot settle again.
Second, settlement for a challenge is guarded by a lock:
$lock = $this->cache
->store(config('mpp.cache_store'))
->lock('mpp:settle:'.$challenge->id, $this->settleLockTtl);
That matters when two identical paid retries arrive at nearly the same time. Only one request should get to the rail settlement call. While it is in flight, the other gets 409 Conflict with Retry-After: wait, do not pay again.
Third, the Stripe verifier uses the challenge id as the Stripe idempotency key:
$paymentIntent = $this->client()->paymentIntents->create($params, [
'idempotency_key' => $challenge->id,
]);
That gives a rail-level backstop. Even if the same settlement attempt reaches Stripe twice, Stripe sees it as the same operation rather than two independent PaymentIntents.
For metered routes (e.g. buy 10 accesses on first request, then use them one by one, without additional payment), a successful settlement creates a server-side prepaid session. Spending from that session is also atomic. The cache-backed store decrements the remaining count first and rejects the request if it would go below zero, so concurrent callers cannot overspend the bundle.
The package also keeps a short-lived settlement ledger. If the payment succeeds but the client loses the 200, a matching retry gets the recorded status, headers, body and original receipt instead of a fresh bill. The match includes the credential, request body and concrete target, so knowing a challenge id is not a free voucher for nearby URLs.
That replay is deliberately bounded. By default it lasts five minutes and snapshots buffered responses up to 256KB. Streams, binary downloads and larger bodies are not replayed. More importantly, there is still a small crash window after settlement and controller execution but before the ledger is written.
Payment middleware can make the ordinary lost-response case much safer. It cannot give arbitrary application side effects exactly-once semantics across Stripe, a cache and your database. For one-time generated files, expensive reports or external fulfilment, keep an application-level idempotency key or durable fulfilment record as well. Distributed systems remain annoyingly unwilling to become simple just because we put an AI agent in front of them.
Pricing Routes
The middleware accepts the price inline:
Route::get('/scores/match/{id}', [ScoreController::class, 'match'])
->whereNumber('id')
->middleware('mpp:1.00,USD,methods=stripe|tempo,scope=match');
You can also issue a metered bundle by setting grants:
Route::get('/scores/classics/{decade}', [ScoreController::class, 'classics'])
->where('decade', '80s|90s|00s')
->middleware('mpp:3.00,USD,methods=stripe|tempo,grants=3,scope=classics');
That means one payment grants three accesses. The paid response includes a Payment-Session header:
Payment-Session: id="sess_...", remaining="2", scope="classics", expires="..."
The client can then reuse that session without paying again:
curl -si https://example.com/api/v1/scores/classics/90s \
-H 'Authorization: Payment session="sess_..."'
Sessions are server-side balances. The agent holds only the session id; the server stores the remaining credit count and decrements it atomically. A single-server local cache is fine for a toy demo, but for a real metered product running across multiple servers, a more robust caching mechanism would be needed.
For production, point the cache driver at shared infrastructure such as Redis, or use the database session driver:
MPP_SESSION_DRIVER=database
php artisan vendor:publish --tag=mpp-migrations
php artisan migrate
If you sell ten accesses, concurrent requests must not be able to spend eleven.
Attributes and Price Books
Within Laravel, middleware is nice and explicit, protecting routes at source. Some people prefer to use attributes, particularly in larger applications, to keep all of the pricing logic close to the thing it is pricing:
use Square1\Mpp\Attributes\RequiresPayment;
class ReportController
{
#[RequiresPayment(amount: '5.00', currency: 'USD', grants: 10, scope: 'report.basic')]
public function __invoke()
{
// One payment grants ten accesses.
}
}
Then wire the route with the bare middleware:
Route::get('/report', ReportController::class)->middleware('mpp');
Or enable automatic attribute enforcement:
MPP_ATTRIBUTES_ENABLED=true
With that enabled, controller actions carrying #[RequiresPayment] are protected without adding mpp to each route. The package skips routes that already have the middleware, so you do not accidentally charge twice.
For repeated prices, a price book keeps route files from filling up with magic numbers:
// config/mpp.php
'price_book' => [
'report.basic' => ['amount' => '5.00', 'currency' => 'USD', 'grants' => 10],
],
Route::get('/report', ReportController::class)
->middleware('mpp:report.basic');
The key becomes the default scope unless you override it.
When the Request Decides the Price
Static prices are useful until someone asks for a pro discount, regional pricing, a bigger partner bundle, or staff access for free. At that point, duplicating routes for every possible price is a strong signal that the route file has become an accidental spreadsheet.
Named price resolvers handle those cases. Register a normal container-resolved class in config/mpp.php:
'pricing' => [
'resolvers' => [
'tiered' => [\App\Mpp\Pricing\TieredPrice::class, 'price'],
],
'global' => [],
],
The resolver receives the request and the payment spec as it currently stands. It can override the amount, currency, grants or scope, explicitly waive the charge, or return null to leave the price alone:
namespace App\Mpp\Pricing;
use Illuminate\Http\Request;
use Square1\Mpp\Payment\PaymentSpec;
class TieredPrice
{
public function price(Request $request, PaymentSpec $spec): ?array
{
return match ($request->user()?->tier) {
'pro' => ['amount' => '2.00'],
'partner' => [
'amount' => '18.00',
'grants' => 25,
'scope' => 'report.partner',
],
'staff' => ['free' => true],
default => null,
};
}
}
Then attach it to the route:
Route::get('/report', ReportController::class)
->middleware('mpp:5.00,USD,scope=report,pricing=tiered');
Here $5.00 is the list price. The resolver can replace it for a recognised request, and null means "I have no opinion", not "give it away". If the resolver should own the price completely, omit the amount: mpp:scope=report,pricing=tiered. A request that no resolver can price then fails closed instead of improvising a bargain.
The amount finally minted into the signed 402 is the amount settlement verifies. Re-running the resolver on the paid retry cannot quietly move the goalposts after the buyer has accepted the quote.
Discovery cannot know a caller-specific price in advance, so a resolver-priced route advertises its methods with "amount": null. The live 402, minted after the resolver runs, contains the actual price. This is another reason discovery is useful but advisory.
A price resolver cannot change the offered methods. Pricing decides what the request costs; method= or methods= decides how it can be paid. Keeping those jobs separate avoids a resolver quietly turning on a settlement integration the route owner never offered.
One less obvious detail: metered sessions are bearer balances bound to a scope. If a resolver changes the price or bundle size, give that tier a different scope as well. Otherwise a cheap session and an expensive session occupy the same credit pool, which is the sort of loyalty programme nobody intended to launch.
Preconditions: Do Not Charge for a 404
This is the implementation detail I would expect to trip people up first.
The payment middleware runs before your controller. That is the point. It should not let the controller serve the paid resource until payment has cleared.
But this creates an awkward edge case. Imagine a route like this:
Route::get('/scores/match/{id}', [ScoreController::class, 'match'])
->middleware('mpp:1.00,USD,methods=stripe|tempo,scope=match');
What happens when the match id does not exist?
If the existence check lives only inside the controller, the first request gets a 402, the client pays, the retry settles, and only then does the controller say 404. That is technically explainable, but commercially not great. Now we need to worry about angry customers and refund flows.
One way to get around this is with with a precondition:
Route::get('/scores/match/{id}', [ScoreController::class, 'match'])
->whereNumber('id')
->middleware('mpp:1.00,USD,methods=stripe|tempo,scope=match,preconditions=matchchecker');
The check is registered in config/mpp.php:
use App\Mpp\Checks\MatchChecker;
'preconditions' => [
'checks' => [
'matchchecker' => [MatchChecker::class, 'check'],
],
],
And the checker returns a response when the request should be rejected before payment:
namespace App\Mpp\Checks;
use App\Data\Scorelines;
use Illuminate\Http\Request;
use Square1\Mpp\Payment\PaymentSpec;
use Symfony\Component\HttpFoundation\Response;
class MatchChecker
{
public function check(Request $request, PaymentSpec $spec): ?Response
{
$id = (int) $request->route('id');
if (Scorelines::find($id)) {
return null;
}
return response()->json([
'error' => 'No such scoreline.',
'detail' => "We have no record of match #{$id}.",
], 404);
}
}
Preconditions run before a challenge is minted and before a paid retry is settled. They are for things you can know before payment: the resource exists, the user is allowed to buy it, the account is not blocked, the requested format is supported.
Anything that can only be discovered after settlement becomes refund territory, and refund territory is rarely where you want your agentic commerce prototype to begin.
Of course, this won't be applicable for all cases - if you're pricing your API as it's computationally-expensive to do any kind of lookup in the first place, doing inference etc, then preconditions don't make as much sense as you're still doing the work - but in the more traditional gated application model, preconditions can be a way to avoid expensive mistakes.
One Endpoint, Two Settlement Shapes
PayForGoals offers the same product over both methods from one route:
Route::get('/scores/match/{id}', [ScoreController::class, 'match'])
->middleware('mpp:1.00,USD,methods=stripe|tempo,scope=match,preconditions=matchchecker');
If most paid routes offer the same set, MPP_ACCEPT=stripe|tempo makes that the house default and the route can omit methods=.
One request can therefore return two challenges. A Stripe-capable client answers the Stripe one; a Tempo-capable client answers the Tempo one. The controller, URL, product and outer credential format stay the same. The settlement mechanics do not.
With Stripe, the buyer presents an SPT, and the seller creates and confirms the PaymentIntent. This is familiar Stripe territory in one sense, because the seller is still creating a PaymentIntent. It is unfamiliar in another, because the payment method is a scoped credential brought by an agent or wallet rather than a card collected in your checkout UI.
With Tempo, the client signs a pathUSD transfer for the challenge. The package verifies the signed transaction, broadcasts it, waits for confirmation, and then serves the resource.
Two rails, two settlement shapes
Stripe SPT
Buyer wallet grants a scoped token.
Client sends SPT back to merchant API.
Merchant creates and confirms a PaymentIntent.
Merchant serves only after Stripe reports success.
Tempo pathUSD
Client signs a transfer for the challenge.
Client sends signed transaction to merchant API.
Merchant validates, broadcasts, and waits for confirmation.
Merchant serves only after the rail confirms settlement.
Stripe gives the merchant a scoped credential to charge. Tempo gives the merchant a signed transfer to verify and broadcast.
That difference leads to practical differences:
A shared route has one product price. If one offered method is card-backed, that price still needs to clear Stripe's minimum charge.
Each request builder translates that price into the method's units: cents for Stripe, token base units for Tempo.
Stripe SPTs remain private-preview infrastructure. Test mode is useful broadly; live buyer availability is still gated.
Tempo defaults to the Moderato testnet. Mainnet is supported, but its RPC, chain id and token address must be changed together.
This is why the package has both a common payment gate and method-specific request builders and verifiers. MPP defines negotiation and credential transport. The rail defines what gets signed, how money moves, and what the server must verify.
Testing the Stripe Loop
For development, you do not need to wait for a full wallet flow. Stripe's test helper can mint an SPT, and the mppx CLI can drive the 402 -> token -> retry -> 200 loop.
First inspect only the Stripe challenge:
curl -si https://example.com/api/v1/scores/match/1 \
-H 'Accept-Payment: stripe/charge'
The decoded request includes the amount, currency and seller networkId. A buyer-side test key can mint an SPT scoped to those terms:
curl -s -u "sk_test_buyer_...:" -H "Stripe-Version: 2026-05-27.preview" \
-X POST https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens \
-d payment_method=pm_card_visa \
-d "usage_limits[currency]=usd" \
-d "usage_limits[max_amount]=100" \
-d "usage_limits[expires_at]=$(($(date +%s)+300))" \
-d "seller_details[network_id]=profile_..."
Use the networkId from the challenge rather than pasting a seller id from somewhere else and hoping the universe is feeling generous.
For a complete test-mode payment, the CLI can perform the mint and replay for us:
MPPX_STRIPE_SECRET_KEY=sk_test_buyer_... \
npx mppx https://example.com/api/v1/scores/match/1 \
-H 'Accept-Payment: stripe/charge' \
-M paymentMethod=pm_card_visa
If everything is configured correctly, the response is 200 OK with a Payment-Receipt, whose decoded reference points at the seller's PaymentIntent. The buyer key will not be able to retrieve that PaymentIntent from Stripe; it belongs to the seller account. That separation is the point of the SPT rather than an inconvenient missing permission.
Testing the Tempo Loop
Tempo is simpler to try from a command line because the stock mppx client handles the challenge, signed transfer, payment and retry loop.
Configure a recipient:
TEMPO_RECIPIENT=0x...
Then pay it:
npx mppx account create
npx mppx account fund --network testnet
npx mppx https://example.com/api/v1/scores/match/1 \
-H 'Accept-Payment: tempo/charge' \
--network testnet --account main
The explicit preference matters on a route that offers several methods: it tells this Tempo client not to wander into the Stripe challenge and start asking where its card details went. The successful receipt contains the on-chain transaction hash, which makes Tempo useful for seeing the protocol mechanics without a browser wallet or checkout UI.
Things To Watch Out For
The package makes the happy path small, but a paid API has more edge cases than a normal API.
Challenge secrets need to be stable and identical across workers. By default the package derives a signing key from APP_KEY, but I would set MPP_CHALLENGE_SECRET explicitly in production so it can be rotated independently. Rotating it invalidates in-flight challenges, not already issued sessions.
The challenge store, settlement ledger and locks also need a shared atomic cache on a multi-node deployment. Set MPP_CACHE_STORE to Redis, Memcached or a database-backed store; a local file cache cannot stop two different workers settling the same challenge.
Challenge TTLs should be short. The default is five minutes. Long-lived unpaid challenges are not ideal - they are a stale pricing problem waiting to happen.
Idempotency should be designed at both layers. The payment layer should prevent duplicate settlement. The product layer should decide how to recover if fulfilment succeeded but the response was lost.
Rail configuration should fail loudly when it would create an unsafe challenge. For example, a Tempo route without a recipient address is not merely incomplete; it is unpayable. Stripe is a little different: the package can emit a 402 without a secret key, but settlement cannot work until STRIPE_SECRET_KEY is set.
And, more generally, do not trust the client. The client can present a credential. It cannot tell you the resource is paid for. The server verifies settlement against the signed challenge and the rail's own source of truth.
The EMEA Reality Check
The frustrating part, writing from Dublin, is that a lot of the most interesting payment plumbing is still very US-centric.
Stripe Shared Payment Tokens are the obvious example. Test mode is useful from anywhere, and it is enough to build the seller-side integration, but Stripe's agentic-commerce programme remains in private preview and some live buyer flows are limited to approved US businesses at the time of writing (August 2026). The challenge for EMEA teams today is to be ready to move on these rails, ahead of them becoming more widely available.
The useful work today is to get the server-side shape right:
- Can your application price a resource clearly?
Can it reject impossible requests before asking for payment?
Can it verify settlement without trusting the client?
- Can it handle retries without double-charging?
- Can it issue and spend metered access safely?
Can it swap rails without rewriting the application feature?
They are the application questions underneath the payment rail. Getting them right now means being less flat-footed when the buyer side opens up.
Why This Feels Worth Building Now
The agentic commerce story is still uneven. Some parts are production-ready, some are preview APIs, some are testnet rails, and some are still mostly conference slides with better typography than the average RFC.
But the paid-request primitive is real enough to build against. It forces a useful discipline: define a resource, put a price on it, explain what proof you accept, verify settlement, and return a receipt.
That is a much smaller problem than "make our whole business agent-ready", and a much better one to learn from.
In Laravel, the nice thing is that the integration point is mundane. It is middleware. Routes go in, 402s come out, and your controller only runs once the request is either paid or backed by a valid prepaid session. In another stack, the shape is the same even if the vocabulary changes: put a payment gate before the handler, bind the challenge to the thing being bought, verify settlement, then serve.
That is not the whole future of commerce, but it is a practical place to start today!