Skip to content

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