MPP-SPT — Stripe¶
Pay a paywalled API with a Stripe Shared Payment Token (SPT) using the MPP
(Machine Payment Protocol) with method=stripe. No crypto required.
Prerequisites¶
| Requirement | Notes |
|---|---|
| Stripe account (buyer) | Secret key sk_test_... or sk_live_... |
| Stripe customer id | cus_<id> belonging to the buyer's account |
| Saved payment method | pm_<id> — card or bank account linked to the customer |
| Seller Stripe profile | network_business_profile id of the merchant's Stripe account |
| MPP-SPT endpoint | A server that sends WWW-Authenticate: Payment method="stripe" on 402 |
How Stripe SPT works¶
The buyer mints a Shared Payment Token scoped to the seller's Stripe account. The seller redeems it via a Stripe PaymentIntent on their side. The buyer never shares card details with the seller — Stripe mediates the transfer.
Buyer (you): Stripe API → mint SPT scoped to seller's network_business_profile
↓
Authorization: Payment <credential with spt_id>
↓
Seller server: Stripe API → PaymentIntent with SPT → charge succeeds
↓
Payment-Receipt: { reference: "pi_...", status: "success" }
Stripe test mode setup¶
export STRIPE_TEST_API_KEY=sk_test_... # buyer's test key
export STRIPE_TEST_CUSTOMER=cus_... # buyer's test customer id
export STRIPE_TEST_PAYMENT_METHOD=pm_... # buyer's saved test card
export STRIPE_TEST_SELLER_PROFILE=... # seller's network_business_profile id
In Stripe test mode, use pm_card_visa (or the pm_... id of a test card attached
to your test customer) and a test network_business_profile.
Basic usage¶
import asyncio
import os
from routeweiler import Routeweiler, Funding
from routeweiler.trace.sink_sqlite import TraceSink
async def main():
funding = Funding.stripe(
api_key=os.environ["STRIPE_TEST_API_KEY"],
customer=os.environ["STRIPE_TEST_CUSTOMER"],
payment_method=os.environ["STRIPE_TEST_PAYMENT_METHOD"],
)
async with Routeweiler(
funding=[funding],
trace_sink=TraceSink.sqlite("rw.db"),
agent_id="stripe-agent",
) as client:
response = await client.get("https://your-api.com/paid-endpoint")
print(f"HTTP {response.status_code}: {response.json()}")
asyncio.run(main())
Mixed funding (Stripe + crypto)¶
Routeweiler picks the best rail per request. If an API accepts both x402 and MPP-SPT, configure both funding sources and let the routing engine decide:
from eth_account import Account
from routeweiler import Routeweiler, Funding
signer = Account.from_key(os.environ["WALLET_PK"])
async with Routeweiler(
funding=[
Funding.base_usdc(wallet=signer), # x402 rail
Funding.stripe( # MPP-SPT rail
api_key=os.environ["STRIPE_API_KEY"],
customer=os.environ["STRIPE_CUSTOMER"],
payment_method=os.environ["STRIPE_PM"],
),
],
) as client:
response = await client.get("https://api.example.com/data")
Using StripeFundingSource directly¶
from routeweiler.funding.stripe import StripeFundingSource
funding = StripeFundingSource(
api_key=os.environ["STRIPE_TEST_API_KEY"],
customer=os.environ["STRIPE_TEST_CUSTOMER"],
payment_method=os.environ["STRIPE_TEST_PAYMENT_METHOD"],
currency="usd", # ISO 4217 lowercase; must match the seller's challenge currency
)
What the flow looks like¶
GET https://your-api.com/paid
← 402 Payment Required
WWW-Authenticate: Payment id="charge-abc",
realm="api.example.com",
method="stripe",
intent="charge",
request="<b64url JCS payload>",
expires="2026-01-01T00:00:00Z"
Routeweiler:
1. Parses MPP-SPT challenge (charge_id, amount, currency, seller_profile)
2. Checks policy and budget
3. Calls Stripe API: create SPT with usage_limits scoped to seller_profile
→ Returns spt_id ("spt_...")
4. Sends Authorization: Payment <b64url credential>
GET https://your-api.com/paid
Authorization: Payment <b64url credential with spt_id>
← 200 OK
Payment-Receipt: <b64url JCS receipt with PaymentIntent id>
The seller's server calls POST /v1/payment_intents with the SPT to charge
the buyer's saved payment method. No card details are ever transmitted to the seller.
What to check in the trace¶
import sqlite3, json
conn = sqlite3.connect("rw.db")
rows = conn.execute(
"SELECT * FROM trace_events WHERE selected_rail = 'mpp-spt'"
).fetchall()
conn.close()
for row in rows:
p = json.loads(row["payload"])
payment = p["payment"]
print(f"SPT id: {payment['proofValue']}") # spt_...
print(f"Proof type: {payment['proofType']}") # "spt_id"
print(f"Amount: {payment['amountNative']} {payment['amountNativeCurrency']}")
proofType is "spt_id" and proofValue is the spt_... token minted by
the buyer. The seller's PaymentIntent id (pi_...) is in the response body
(returned by the merchant) — it is not stored in the trace, which tracks the
buyer-side proof.
Testing without hitting Stripe¶
In unit tests, inject a FakeSptCreator to avoid real Stripe API calls:
from routeweiler.funding.stripe import StripeFundingSource
from tests.fixtures.fake_stripe import FakeSptCreator
funding = StripeFundingSource(
api_key="sk_test_fake",
customer="cus_fake",
payment_method="pm_fake",
currency="usd",
spt_creator=FakeSptCreator(),
)
Error handling¶
from routeweiler import (
SptCreationError,
MppChargeFailedError,
MppReceiptVerificationError,
NoFeasibleRailError,
)
try:
response = await client.get(url)
except SptCreationError as exc:
print(f"Stripe SPT creation failed: {exc}")
except MppChargeFailedError as exc:
print(f"Merchant charge failed: {exc}")
except MppReceiptVerificationError as exc:
print(f"Receipt verification failed: {exc}")
except NoFeasibleRailError:
print("No MPP-SPT funding source matched this challenge.")
See also¶
- Stripe Shared Payment Tokens
StripeFundingSource,Funding.stripein API Reference- Concepts: Payment Rails
- Live e2e test:
packages/routeweiler/tests/rails/test_mpp_spt_e2e_live.py