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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Python integration for InFlow payments using the Machine Payments Protocol (MPP)
and x402. The Python distribution and import namespace are both `inflowpay`.
Python 3.11 or newer is required.

Start with the [runnable Sandbox examples](examples/README.md) for MPP and x402
Buyers and Sellers, including account setup, commands, and expected results.

## Working with the repository

Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
Expand Down
147 changes: 147 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Run a Sandbox payment

These four programs connect to **InFlow Sandbox**. The Sellers run on your computer;
configuration, approvals, and payments use Sandbox accounts. They are not simulated
payments. Run the commands from the repository root with Python 3.11 or newer.

## Set up your accounts

1. Register at [InFlow Sandbox](https://sandbox.inflowpay.ai). Accepting payments
requires a **Seller** account and an API key from its dashboard. A Developer
key cannot be used as a Seller key.
2. Use a separate account and key for the Buyer so the two sides are easy to follow.
A Developer account can act as a Buyer; Seller accounts can also buy.
3. For a balance-funded request, have at least **0.01 USDC** in the Buyer's
[Sandbox Balances](https://sandbox.inflowpay.ai/balances/). Use
[Deposit](https://sandbox.inflowpay.ai/transactions/deposit/) to choose USDC and
an available network. Use test assets on the network configured for Sandbox,
not mainnet funds. Wait for the balance to be credited. If USDC or a deposit
network is unavailable, resolve that account setup before paying. An API key
does not provide funds or guarantee approval.
4. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and run:

```sh
make sync
```

This installs the locked dependencies, including FastAPI, Uvicorn, HTTPX, and both
payment libraries. For an application outside this checkout, install
`inflowpay[mpp,fastapi]` or `inflowpay[x402,fastapi]` and `uvicorn` for a Seller;
Buyers need only the corresponding `inflowpay[mpp]` or `inflowpay[x402]` extra.

## MPP

Start the [Seller](mpp_seller.py) in one terminal. Generate a private challenge key
once; keep it separate from the InFlow API key:

```sh
export INFLOW_API_KEY='your-sandbox-seller-key'
export MPP_SECRET_KEY="$(uv run python -c 'import secrets; print(secrets.token_hex(32))')"
uv run --locked python -m examples.mpp_seller
```

The server listens at `http://127.0.0.1:3000`. Check both routes without paying:

```sh
curl -i http://127.0.0.1:3000/free
curl -i http://127.0.0.1:3000/api/widgets
```

`/free` returns HTTP 200 and `{"ok":true}`. `/api/widgets` returns HTTP 402 with
a `WWW-Authenticate: Payment ...` challenge for **0.01 USDC**.

In a second terminal, run the [Buyer](mpp_buyer.py):

```sh
export INFLOW_API_KEY='your-sandbox-buyer-key'
uv run --locked python -m examples.mpp_buyer
```

Keep the [Sandbox Approvals](https://sandbox.inflowpay.ai/approvals/) page open and
approve if requested. The Buyer prints HTTP 200, `{"widgets":[1,2,3]}`, and the
method and reference decoded from the Seller's `Payment-Receipt`.

The Buyer obtains a payment credential through InFlow and retries the resource
once. The Seller verifies and broadcasts the payment before its widgets handler
runs. This example uses one charge offer. MPP Seller subscriptions and composed
InFlow offers are not supported by this integration; see the
[upstream limitations](../README.md#seller-route-limitations).

## x402

Start the [Seller](x402_seller.py) in one terminal:

```sh
export INFLOW_API_KEY='your-sandbox-seller-key'
uv run --locked python -m examples.x402_seller
```

It listens at `http://127.0.0.1:3001` and builds **0.01 USDC** offers from the
Seller's configured balance and exact payment methods. It stops at startup if no
matching offers are available. Check the routes without paying:

```sh
curl -i http://127.0.0.1:3001/free
curl -i http://127.0.0.1:3001/api/widgets
```

`/free` returns HTTP 200. `/api/widgets` returns HTTP 402 with a `PAYMENT-REQUIRED`
header describing the available offers.

Run the [Buyer](x402_buyer.py) in a second terminal:

```sh
export INFLOW_API_KEY='your-sandbox-buyer-key'
uv run --locked python -m examples.x402_buyer
```

Approve in [Sandbox Approvals](https://sandbox.inflowpay.ai/approvals/) if requested.
Success prints HTTP 200, `{"widgets":[1,2,3]}`, and the success, network, and
transaction fields decoded from `PAYMENT-RESPONSE`.

The Buyer selects a supported offer, obtains its payment payload through InFlow,
and sends one paid request. The Seller verifies it, runs the widgets handler, and
settles before releasing the response. The handler only returns content: payment
middleware does not make database writes or other application side effects atomic
with settlement. No external wallet or retry-recovery hook is registered here.

## Settings and safe testing

| Variable | Used by | Meaning |
| ----------------- | ---------- | ----------------------------------------------------------- |
| `INFLOW_API_KEY` | All | Sandbox key; Sellers require a Seller account key |
| `MPP_SECRET_KEY` | MPP Seller | Private key for signing challenges |
| `TARGET_URL` | Buyers | Defaults to the matching local Seller's `/api/widgets` |
| `INFLOW_BASE_URL` | All | Optional platform override; leave unset to use Sandbox |

The programs read exported variables, not `.env` files. Never commit real keys.
Leave `INFLOW_BASE_URL` unset unless intentionally testing a private deployment:
the API key is sent there. It is not sent to `TARGET_URL`.

Each Buyer allows up to fifteen minutes for its resource request and payment flow.
Press Ctrl-C to cancel. A pending approval is cancelled when the SDK has its
identifier; cancellation does not reverse a completed payment. A second 402, a
failed settlement receipt, or another HTTP error stops the program rather than
starting another payment. After a timeout or network error, check the account's
transactions before deciding whether to retry.

To exercise a free response, set `TARGET_URL` to the Seller's `/free` route.
The Buyer reports that no receipt was returned. A missing receipt alone does not
prove that no payment occurred; decoding a receipt also does not independently
verify settlement. These programs print neither payment credentials nor signatures.

The Sellers bind only to loopback and have no application login. Add your own
application authentication when required; paying is not a substitute for logging in.

## Adapt the examples

- [Manual MPP waiting and cancellation](../README.md#waiting-errors-and-shutdown)
- [Displaying an x402 approval before waiting](../README.md#show-an-approval-before-waiting)
- [Metered x402 settlement](../README.md#prices-payment-methods-and-metering)
- [External wallets](../README.md#external-wallets) and
[EIP-7702 sponsorship](../README.md#eip-7702-sponsorship)

`make verify` checks the example code's formatting, typing, line and branch coverage,
and Buyer-to-Seller flows through the actual payment middleware. Platform responses
in these tests are scripted; they do not establish live settlement. The commands
above are the walkthrough for checking your own Sandbox configuration.
1 change: 1 addition & 0 deletions examples/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Runnable Sandbox payment examples."""
56 changes: 56 additions & 0 deletions examples/mpp_buyer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import asyncio
import os
import sys

import httpx

from inflowpay import ClientOptions
from inflowpay.mpp import decode_receipt
from inflowpay.mpp.buyer import BuyerMethod, payment_transport


async def run() -> None:
key = os.environ.get("INFLOW_API_KEY")
if not key:
raise ValueError("Set INFLOW_API_KEY to your Sandbox buyer API key.")
target = os.environ.get("TARGET_URL", "http://127.0.0.1:3000/api/widgets")
print("Requesting resource; approve in the Sandbox dashboard if requested.", flush=True)
# The platform key belongs to BuyerMethod, never to the merchant HTTP client.
async with (
BuyerMethod(
ClientOptions(
environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL")
)
) as method,
httpx.AsyncClient(transport=payment_transport([method]), follow_redirects=False) as http,
):
async with asyncio.timeout(900):
response = await http.get(target)
print(f"HTTP {response.status_code}\n{response.text}")
receipt = response.headers.get("payment-receipt")
if receipt is not None:
decoded = decode_receipt(receipt)
print(f"Seller receipt: {decoded['method']} reference={decoded['reference']}")
else:
print("No seller receipt returned; this does not establish whether payment occurred.")
# A second 402 is an error, not permission to purchase again.
response.raise_for_status()


def main() -> int:
try:
asyncio.run(run())
except KeyboardInterrupt:
print("Cancelled. A completed payment is not reversed.", file=sys.stderr)
return 130
except Exception as error:
print(
f"Request failed: {error}. Do not automatically retry an uncertain payment.",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
75 changes: 75 additions & 0 deletions examples/mpp_seller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import asyncio
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI, Request
from mpp import Credential, Receipt
from mpp.server.decorator import pay
from starlette.responses import JSONResponse

from inflowpay import ClientOptions
from inflowpay.mpp.seller import Seller


@asynccontextmanager
async def application(options: ClientOptions, secret: str) -> AsyncIterator[FastAPI]:
# Keep the Seller open for the whole server lifetime, not just startup.
async with await Seller.create(options) as seller:
app = FastAPI()

@app.get("/api/widgets")
@pay(
intent=seller,
method=seller.method,
request=seller.charge_request({"amount": "0.01", "currency": "USDC"}),
realm="localhost",
secret_key=secret,
)
async def widgets(
request: Request, credential: Credential, receipt: Receipt
) -> JSONResponse:
# pympp verifies and broadcasts before reaching this handler.
return JSONResponse(
{"widgets": [1, 2, 3]}, headers={"Payment-Receipt": receipt.to_payment_receipt()}
)

@app.get("/free")
async def free() -> dict[str, bool]:
return {"ok": True}

yield app


async def run() -> None:
key, secret = os.environ.get("INFLOW_API_KEY"), os.environ.get("MPP_SECRET_KEY")
if not key or not secret:
raise ValueError(
"Set INFLOW_API_KEY (Sandbox Seller key) and MPP_SECRET_KEY (private challenge key)."
)
options = ClientOptions(
environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL")
)
async with application(options, secret) as app:
print(
"MPP: http://127.0.0.1:3000/api/widgets costs 0.01 USDC; /free requires no payment.",
flush=True,
)
await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3000)).serve()


def main() -> int:
try:
asyncio.run(run())
except KeyboardInterrupt:
return 130
except Exception as error:
print(f"Seller failed: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
63 changes: 63 additions & 0 deletions examples/x402_buyer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import asyncio
import os
import sys

import httpx
from x402.http import decode_payment_response_header
from x402.http.clients.httpx import x402AsyncTransport

from inflowpay import ClientOptions
from inflowpay.x402.buyer import Buyer


async def run() -> None:
key = os.environ.get("INFLOW_API_KEY")
if not key:
raise ValueError("Set INFLOW_API_KEY to your Sandbox buyer API key.")
target = os.environ.get("TARGET_URL", "http://127.0.0.1:3001/api/widgets")
print("Requesting resource; approve in the Sandbox dashboard if requested.", flush=True)
# No external wallet or recovery hook is registered: a failed paid retry stops here.
async with (
await Buyer.create(
ClientOptions(
environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL")
)
) as buyer,
httpx.AsyncClient(transport=x402AsyncTransport(buyer), follow_redirects=False) as http,
):
async with asyncio.timeout(900):
response = await http.get(target)
print(f"HTTP {response.status_code}\n{response.text}")
receipt = response.headers.get("payment-response") or response.headers.get(
"x-payment-response"
)
if receipt is not None:
settled = decode_payment_response_header(receipt)
print(
f"Seller settlement: success={settled.success} network={settled.network} "
f"transaction={settled.transaction}"
)
if not settled.success:
raise ValueError(f"Settlement failed: {settled.error_reason}")
else:
print("No seller receipt returned; this does not establish whether payment occurred.")
response.raise_for_status()


def main() -> int:
try:
asyncio.run(run())
except KeyboardInterrupt:
print("Cancelled. A completed payment is not reversed.", file=sys.stderr)
return 130
except Exception as error:
print(
f"Request failed: {error}. Do not automatically retry an uncertain payment.",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading