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. 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 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 method.
- 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?

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.4 and Laravel 12 or 13.
composer require square1/laravel-mpp
php artisan vendor:publish --tag=mpp-config
The mpp middleware alias is registered automatically, so the smallest useful example is attaching a price of $1 to a route:
use Illuminate\Support\Facades\Route;
Route::get('/resource', fn () => response()->json([
'result' => 'some paid data',
]))->middleware('mpp:1.00,USD');
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
Out of the box, the response includes the WWW-Authenticate: Payment challenge header and an application/problem+json body:
HTTP/2 402 Payment Required
WWW-Authenticate: Payment id="chal_...", method="stripe", intent="charge", amount="1.00", currency="USD", network_id="profile_...", payment_method_types="card", grants="1", scope="resource", expires_at="2026-07-03T12:00:00Z", sig="..."
Content-Type: application/problem+json
Cache-Control: no-store
{
"type": "https://paymentauth.org/problems/payment-required",
"title": "Payment Required",
"status": 402,
"detail": "Payment is required to access this resource.",
"challengeId": "chal_...",
"accepts": [
{
"method": "stripe",
"amount": "1.00",
"currency": "USD",
"network_id": "profile_...",
"payment_method_types": ["card"],
"grants": 1,
"scope": "resource",
"expiresAt": "2026-07-03T12:00:00Z",
"sig": "..."
}
]
}
The important part is the signature. The response challenge here spells out the economic terms: challenge id, amount, currency, method, network id, grant count, scope, and expiry. Just like in the real world, a client cannot do something like generate a 10c payment for this request, and expect it to be accepted.
One thing to note here is that the signature format varies depending on the payment rails and method of payment verification for our server. The Stripe provider adds the accepts information, while Tempo's response is much shorter, presenting just the challengeId:
HTTP/2 402
WWW-Authenticate: Payment id="abcd...", realm="payforgoals.com", method="tempo", intent="charge", request="zyxw...", expires="2026-07-03T09:47:37.972Z"
Content-Type: application/problem+json
Cache-Control: no-store, private
{
"type": "https://paymentauth.org/problems/payment-required",
"title": "Payment Required",
"status": 402,
"detail": "Payment is required.",
"challengeId": "4nab..."
}
What Happens on the Paid Retry
The paid retry carries an Authorization: Payment header. For Stripe, that means presenting a Shared Payment Token:
curl -si https://example.com/resource \
-H 'Authorization: Payment method="stripe", challengeId="chal_...", sig="...", spt="spt_..."'
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 signature against the offered payment method.
- 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. That distinction matters, because our server still has to create the PaymentIntent, confirm it, check it succeeded, and verify the amount and currency match the signed challenge.
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: id="rcpt_...", challengeId="chal_...", method="stripe", amount="1.00", currency="USD", ref="pi_3Q...", settledAt="..."
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
}
}
The ref is the settlement reference. On Stripe it is the PaymentIntent id. On Tempo it is the transaction hash.
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()->lock('mpp:settle:'.$challenge->id, 10);
That matters when two identical paid retries arrive at nearly the same time. Only one request should get to the rail settlement call. The other should wait or be re-challenged, not race into a second charge.
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.
There is an important boundary here: preventing a duplicate charge is not the same thing as replaying the exact same successful response forever. The Laravel package linked above protects settlement. It returns receipts and sessions. It does not try to cache every response body your application might generate after payment.
That's generally the right division of labour. Payment middleware should decide whether the request is paid. Your application should decide whether the paid resource itself needs response replay, durable download grants, object-level fulfilment records, or some other product-specific recovery model.
If the resource is deterministic and cheap to regenerate, serving it again after a valid session may be enough. If it is a one-time generated file, expensive report, or external fulfilment action, store a grant or fulfilment record in your own domain model. Idempotency around money is the important thing here - idempotency around product delivery is dependent on the context of your application.
Pricing Routes
The middleware accepts the price inline:
Route::get('/scores/match/{id}', [ScoreController::class, 'match'])
->whereNumber('id')
->middleware('mpp:1.00,USD,method=stripe,scope=stripe.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,method=stripe,grants=3,scope=stripe.classics');
That means one payment grants three accesses. The paid response includes a Payment-Session header:
Payment-Session: id="sess_...", remaining="2", scope="stripe.classics", expiresAt="..."
The client can then reuse that session without paying again:
curl -si https://example.com/api/v1/stripe/scores/classics/90s \
-H 'Authorization: Payment method="stripe", 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. That means the storage choice matters. A single-server local cache is fine for a toy demo and a poor idea for a real metered product running on multiple workers.
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.
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,method=stripe,scope=stripe.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,method=stripe,scope=stripe.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.
Stripe and Tempo Are Not the Same Shape
PayForGoals exposes the same product over two rails:
// Tempo: on-chain pathUSD on testnet.
Route::get('/tempo/scores/match/{id}', [ScoreController::class, 'match'])
->middleware('mpp:0.01,USD,method=tempo,scope=tempo.match,preconditions=matchchecker');
// Stripe: Shared Payment Token settled as a PaymentIntent.
Route::get('/stripe/scores/match/{id}', [ScoreController::class, 'match'])
->middleware('mpp:1.00,USD,method=stripe,scope=stripe.match,preconditions=matchchecker');
The business logic is identical. The settlement mechanics are 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:
- Stripe card-backed routes need to respect card minimums, so PayForGoals prices Stripe examples at dollars rather than fractions of a cent.
- Tempo testnet can demonstrate smaller values because it is not running through card economics.
- Stripe SPTs are still preview-era infrastructure and the live buyer flow is gated.
- Tempo in this package is testnet-oriented unless you have a separate mainnet plan.
This is why the package treats payment methods as verifiers behind a common protocol layer. MPP defines the negotiation, but the rail defines how money actually moves.
Testing the Stripe Loop
For development, you do not need to wait for a full wallet flow. You can mint a test SPT and drive the whole 402 -> token -> retry -> 200 loop yourself.
First request the protected resource and copy challengeId and the Stripe accept sig:
curl -s https://example.com/api/v1/stripe/scores/match/1
Then mint a test SPT from a Stripe test account:
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))"
Finally replay the original request:
curl -si https://example.com/api/v1/stripe/scores/match/1 \
-H 'Authorization: Payment method="stripe", challengeId="chal_...", sig="...", spt="spt_..."'
If everything is configured correctly, the response is 200 OK with a Payment-Receipt header and the receipt's ref points at the Stripe PaymentIntent.
This test flow is a bit manual, but that is partly the point. It shows each moving part clearly: the server mints the challenge, the buyer side obtains a scoped credential, and the server settles it.
Testing the Tempo Loop
Tempo is simpler to try from a command line because the stock mppx client handles the challenge, payment, and retry loop.
Protect a route:
Route::get('/tempo-test', fn () => response()->json([
'paid' => true,
'at' => now()->toIso8601String(),
]))->middleware('mpp:0.01,USD,method=tempo,scope=tempo.test');
Configure a recipient:
TEMPO_RECIPIENT=0x...
Then pay it:
npx mppx https://example.com/tempo-test --network testnet --account main
The successful receipt contains the on-chain transaction hash. That makes it a useful rail 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 shared 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.
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 live Link-based buyer flows are still US-gated at the time of writing (July 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!