Skip to content
5 changes: 5 additions & 0 deletions .changelog/quiet-cows-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added Stripe payment method (`mpp.methods.stripe`) supporting the Shared Payment Token (SPT) flow for HTTP 402 authentication. Includes client-side `StripeMethod` and `stripe()` factory, server-side `ChargeIntent` for PaymentIntent verification via Stripe SDK or raw HTTP, Pydantic schemas, and a `stripe` optional dependency group.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Code examples for using the Machine Payments Protocol (pympp).
|---------|-------------|
| [fetch/](fetch/) | CLI tool for fetching URLs with automatic payment handling |
| [mcp-server/](mcp-server/) | MCP server with payment-protected tools |
| [stripe/](stripe/) | Stripe SPT payment flow (server + headless client) |

## Documentation Examples

Expand Down
84 changes: 84 additions & 0 deletions examples/stripe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Stripe Example

A pay-per-fortune API using Stripe's Shared Payment Token (SPT) flow.

## What This Demonstrates

- Server-side payment protection with `Mpp.create()` and the Stripe method
- SPT proxy endpoint (secret key stays server-side)
- Headless client using a test card (`pm_card_visa`)
- Full 402 → challenge → credential → retry flow

## Prerequisites

- Python 3.12+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
- A Stripe test-mode secret key (`sk_test_...`)

## Installation

```bash
cd examples/stripe
uv sync
```

## Running

**Start the server:**

```bash
export STRIPE_SECRET_KEY=sk_test_...
uv run python server.py
```

The server starts at http://localhost:8000.

**Run the client** (in another terminal):

```bash
uv run python client.py
# 🥠 A smooth long journey! Great expectations.
# 📝 Receipt: pi_3Q...
```

## Testing Manually

**Without payment** (returns 402):

```bash
curl -i http://localhost:8000/api/fortune
# HTTP/1.1 402 Payment Required
# WWW-Authenticate: Payment ...
```

## How It Works

```
Client Server Stripe
│ │ │
│ GET /api/fortune │ │
├──────────────────────────────> │ │
│ │ │
│ 402 + WWW-Authenticate │ │
│<────────────────────────────── │ │
│ │ │
│ POST /api/create-spt │ │
├──────────────────────────────> │ Create SPT (test helper) │
│ ├─────────────────────────────> │
│ spt_... │ │
│<────────────────────────────── │<───────────────────────────── │
│ │ │
│ GET /api/fortune │ │
│ Authorization: Payment <cred> │ │
├──────────────────────────────> │ PaymentIntent (SPT + confirm)│
│ ├─────────────────────────────> │
│ │ pi_... succeeded │
│ 200 + fortune + receipt │<───────────────────────────── │
│<────────────────────────────── │ │
```

1. Client requests the fortune → server returns 402 with a payment challenge
2. pympp client calls `create_token` → POSTs to `/api/create-spt` → server creates SPT via Stripe
3. Client retries with a credential containing the SPT
4. Server creates a PaymentIntent with `shared_payment_granted_token` and `confirm=True`
5. On success, returns the fortune with a receipt
78 changes: 78 additions & 0 deletions examples/stripe/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""CLI client that pays for a fortune using Stripe SPTs.

Uses a test card (pm_card_visa) for headless operation — no browser needed.

Usage:
export STRIPE_SECRET_KEY=sk_test_...
python client.py [--server http://localhost:8000]
"""

import argparse
import asyncio
import sys

import httpx

from mpp.client import Client
from mpp.methods.stripe import stripe


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="stripe-fortune",
description="Fetch a fortune with automatic Stripe payment",
)
parser.add_argument(
"--server",
default="http://localhost:8000",
help="Server base URL (default: http://localhost:8000)",
)
return parser.parse_args()


async def run(args: argparse.Namespace) -> int:
server_url = args.server.rstrip("/")

async def create_token(params):
"""Proxy SPT creation through the server."""
async with httpx.AsyncClient() as http:
response = await http.post(
f"{server_url}/api/create-spt",
json={
"paymentMethod": params.payment_method,
"amount": params.amount,
"currency": params.currency,
"expiresAt": params.expires_at,
"networkId": params.network_id,
"metadata": params.metadata,
},
)
response.raise_for_status()
return response.json()["spt"]

method = stripe(
create_token=create_token,
payment_method="pm_card_visa",
intents={},
)

async with Client(methods=[method]) as client:
response = await client.get(f"{server_url}/api/fortune")

if response.status_code >= 400:
print(f"Error {response.status_code}: {response.text}", file=sys.stderr)
return 1

data = response.json()
print(f"🥠 {data['fortune']}")
print(f"📝 Receipt: {data['receipt']}")
return 0


def main() -> None:
sys.exit(asyncio.run(run(parse_args())))


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions examples/stripe/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "stripe-example"
version = "0.1.0"
description = "Stripe payment example using the Machine Payments Protocol"
requires-python = ">=3.12"
dependencies = [
"pympp[stripe,server]",
"fastapi",
"uvicorn",
"httpx",
]

[tool.uv.sources]
pympp = { path = "../.." }
108 changes: 108 additions & 0 deletions examples/stripe/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Stripe payment-protected API server.

Demonstrates the Machine Payments Protocol with Stripe's Shared Payment
Token (SPT) flow. Two endpoints:

- POST /api/create-spt — proxy for SPT creation (requires secret key)
- GET /api/fortune — paid endpoint ($1.00 per fortune)
"""

import base64
import os
import random

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from mpp import Challenge
from mpp.methods.stripe import ChargeIntent, stripe
from mpp.server import Mpp

app = FastAPI(title="Stripe Fortune Server")

SECRET_KEY = os.environ["STRIPE_SECRET_KEY"]

server = Mpp.create(
method=stripe(
network_id=os.environ.get("STRIPE_NETWORK_ID", "internal"),
payment_method_types=["card"],
currency="usd",
decimals=2,
recipient=os.environ.get("STRIPE_ACCOUNT", "acct_default"),
intents={"charge": ChargeIntent(secret_key=SECRET_KEY)},
),
)

FORTUNES = [
"A beautiful, smart, and loving person will come into your life.",
"A dubious friend may be an enemy in camouflage.",
"A faithful friend is a strong defense.",
"A fresh start will put you on your way.",
"A golden egg of opportunity falls into your lap this month.",
"A good time to finish up old tasks.",
"A light heart carries you through all the hard times ahead.",
"A smooth long journey! Great expectations.",
]


@app.post("/api/create-spt")
async def create_spt(request: Request):
"""Proxy endpoint for SPT creation.

The client calls this with a payment method ID and challenge details.
We call Stripe's test SPT endpoint using our secret key.
"""
body = await request.json()

params = {
"payment_method": body["paymentMethod"],
"usage_limits[currency]": body["currency"],
"usage_limits[max_amount]": body["amount"],
"usage_limits[expires_at]": str(body["expiresAt"]),
}

import httpx

auth_value = base64.b64encode(f"{SECRET_KEY}:".encode()).decode()
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens",
headers={
"Authorization": f"Basic {auth_value}",
"Content-Type": "application/x-www-form-urlencoded",
},
data=params,
)
if not response.is_success:
return JSONResponse(status_code=502, content=response.json())
result = response.json()

return {"spt": result["id"]}


@app.get("/api/fortune")
async def fortune(request: Request):
"""Paid endpoint — returns a fortune for $1.00."""
result = await server.charge(
authorization=request.headers.get("Authorization"),
amount="1",
)

if isinstance(result, Challenge):
return JSONResponse(
status_code=402,
content={"error": "Payment required"},
headers={"WWW-Authenticate": result.to_www_authenticate(server.realm)},
)

credential, receipt = result
return {
"fortune": random.choice(FORTUNES),
"receipt": receipt.reference,
}


if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=8000)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ tempo = [
"pytempo>=0.2.1",
"pydantic>=2.0",
]
stripe = [
"pydantic>=2.0",
]
server = ["pydantic>=2.0", "python-dotenv>=1.0"]
mcp = ["mcp>=1.1.0"]
dev = [
Expand Down
4 changes: 4 additions & 0 deletions src/mpp/_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Shared defaults for all payment methods."""

DEFAULT_TIMEOUT = 30.0
"""HTTP request timeout in seconds."""
41 changes: 41 additions & 0 deletions src/mpp/methods/stripe/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Stripe payment method for HTTP 402 authentication.

Uses Stripe's Shared Payment Token (SPT) flow for one-time charges.

Example:
# Client-side
from mpp.client import get
from mpp.methods.stripe import stripe, ChargeIntent

async def create_spt(params):
# Proxy to your server endpoint that creates an SPT
...
return spt_token

response = await get(
"https://api.example.com/resource",
methods=[stripe(
create_token=create_spt,
payment_method="pm_card_visa",
intents={},
)],
)

# Server-side
from mpp.server import Mpp
from mpp.methods.stripe import stripe, ChargeIntent

server = Mpp.create(
method=stripe(
network_id="bn_...",
payment_method_types=["card"],
currency="usd",
decimals=2,
intents={"charge": ChargeIntent(secret_key="sk_...")},
),
)
"""

from mpp.methods.stripe.client import StripeMethod, stripe
from mpp.methods.stripe.intents import ChargeIntent
from mpp.methods.stripe.schemas import ChargeRequest, StripeCredentialPayload
3 changes: 3 additions & 0 deletions src/mpp/methods/stripe/_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Default constants for Stripe payment method."""

STRIPE_API_BASE = "https://api.stripe.com/v1"
Loading
Loading