Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/kind-bees-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
mpp: minor
---

Added a Stripe Shared Payment Token (SPT) example demonstrating the full 402 → challenge → credential → retry flow using Stripe's payment method. Includes a server with SPT proxy endpoint and a headless client using a test card.
10 changes: 8 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow.

| Example | Description |
|---------|-------------|
| [basic](./basic/) | Payment-gated Fortune Teller API |
| [basic](./basic/) | Payment-gated Fortune Teller API (Tempo) |
| [stripe](./stripe/) | Payment-gated Fortune Teller API (Stripe SPT) |
| [axum-extractor](./axum-extractor/) | Axum extractors with per-route pricing (`MppCharge<C>`) |
| [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel |
| [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE |
Expand All @@ -16,11 +17,16 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow.
Each example is a standalone Cargo crate with a server and client binary.

```bash
# Basic example
# Basic example (Tempo)
cd examples/basic
cargo run --bin basic-server # Terminal 1
cargo run --bin basic-client # Terminal 2

# Stripe example
cd examples/stripe
STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server # Terminal 1
cargo run --bin stripe-client # Terminal 2

# Axum extractor (per-route pricing)
cd examples/axum-extractor
cargo run --bin axum-server # Terminal 1
Expand Down
23 changes: 23 additions & 0 deletions examples/stripe/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "stripe-example"
version = "0.1.0"
edition = "2021"
publish = false

[[bin]]
name = "stripe-server"
path = "src/server.rs"

[[bin]]
name = "stripe-client"
path = "src/client.rs"

[dependencies]
mpp = { path = "../..", features = ["server", "client", "stripe"] }
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
rand = "0.9"
base64 = "0.22"
76 changes: 76 additions & 0 deletions examples/stripe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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_stripe()` 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

- Rust 1.80+
- A Stripe test-mode secret key (`sk_test_...`)

## Running

**Start the server:**

```bash
export STRIPE_SECRET_KEY=sk_test_...
cargo run --bin stripe-server
```

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

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

```bash
cargo run --bin stripe-client
# 🥠 A smooth long journey! Great expectations.
# Payment receipt: pi_3Q...
```

## Testing Manually

**Without payment** (returns 402):

```bash
curl -i http://localhost:3000/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. mpp 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
100 changes: 100 additions & 0 deletions examples/stripe/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! # Stripe Fortune Teller CLI Client
//!
//! A CLI client that fetches a fortune from the payment-gated Fortune Teller API
//! using Stripe's Shared Payment Token (SPT) flow.
//!
//! Uses a test card (`pm_card_visa`) for headless operation — no browser needed.
//!
//! ## Running
//!
//! ```bash
//! # First start the server:
//! STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server
//!
//! # Then in another terminal:
//! cargo run --bin stripe-client
//!
//! # Or target a different server:
//! cargo run --bin stripe-client -- --server http://localhost:8000
//! ```

use mpp::client::{Fetch, StripeProvider};
use mpp::protocol::methods::stripe::CreateTokenResult;
use mpp::{parse_receipt, MppError};
use reqwest::Client;

#[tokio::main]
async fn main() {
let server_url = std::env::args()
.skip_while(|a| a != "--server")
.nth(1)
.unwrap_or_else(|| "http://localhost:3000".to_string());

let server_base = server_url.trim_end_matches('/').to_string();
let spt_url = format!("{server_base}/api/create-spt");

let provider = StripeProvider::new(move |params| {
let spt_url = spt_url.clone();
Box::pin(async move {
let resp = Client::new()
.post(&spt_url)
.json(&serde_json::json!({
"paymentMethod": "pm_card_visa",
"amount": params.amount,
"currency": params.currency,
"expiresAt": params.expires_at,
}))
.send()
.await
.map_err(|e| MppError::Http(e.to_string()))?;

if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(MppError::Http(format!("SPT creation failed: {body}")));
}

let json: serde_json::Value = resp
.json()
.await
.map_err(|e| MppError::Http(e.to_string()))?;

let spt = json["spt"]
.as_str()
.ok_or_else(|| MppError::Http("missing spt in response".to_string()))?
.to_string();

Ok(CreateTokenResult::from(spt))
})
});

let fortune_url = format!("{server_base}/api/fortune");
println!("Fetching {fortune_url} ...");

let resp = Client::new()
.get(&fortune_url)
.send_with_payment(&provider)
.await
.expect("request failed");

println!("Status: {}", resp.status());

if let Some(receipt_hdr) = resp.headers().get("payment-receipt") {
if let Ok(receipt_str) = receipt_hdr.to_str() {
if let Ok(receipt) = parse_receipt(receipt_str) {
println!("Payment receipt: {}", receipt.reference);
}
}
}

let body = resp.text().await.expect("failed to read response body");

if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(fortune) = json.get("fortune").and_then(|v| v.as_str()) {
println!("\n🥠 {fortune}");
} else {
println!("\nResponse: {json}");
}
} else {
println!("\nResponse: {body}");
}
}
Loading