Building blocks for Agentic payments (x402, MPP, AP2)
Project description
solana-pay-kit
Charge stablecoins (USDC, USDT, PYUSD, ...) for any HTTP endpoint, in Python. One package, one surface, two protocols underneath: x402 and the Machine Payments Protocol. FastAPI, Flask, and Django ride on top of a framework-agnostic core.
You do not need to know anything about Solana to use this. Pick a currency, give it your wallet address, gate a route in two lines. The SDK handles the challenge, the on-chain verification, the broadcast, and the settlement.
Quick start
Three progressively-realistic snippets. Each one runs as-is, copy, paste, hit the URL. Flask is the framework here; the same surface works in FastAPI and Django (see Examples).
1. Smallest possible app
Gate one route with an inline price. Save the snippet as app.py and boot
with python app.py. Zero-config: the package uses a published demo
keypair as the recipient and the hosted Surfpool sandbox at
https://402.surfnet.dev:8899 as the RPC.
# app.py
from flask import Flask, jsonify
import solana_pay_kit
from solana_pay_kit import usd
from solana_pay_kit.flask import require_payment
solana_pay_kit.configure(network="solana_localnet")
app = Flask(__name__)
@app.get("/report")
@require_payment(usd("0.10"))
def report():
return jsonify(content="premium content")
app.run(host="127.0.0.1", port=8000)
solana_pay_kit.configure(...) builds the process-wide config once at boot.
@require_payment(usd("0.10")) answers a 402 with a payment challenge when
no valid proof was sent, or runs the view if one was.
Hit /report with pay curl and the customer walks
through a USDC payment.
2. Multiple gates via a registry
When more than one route is paid, lift the prices into a single
:class:Pricing registry. Routes reference gates by string handle.
# app.py
from flask import Flask, jsonify
import solana_pay_kit
from solana_pay_kit import Gate, Protocol, Pricing, usd
from solana_pay_kit.flask import require_payment
solana_pay_kit.configure(network="solana_localnet")
class Catalog(Pricing):
def __init__(self):
defaults = {
"default_pay_to": solana_pay_kit.config().effective_recipient(),
"accept_default": solana_pay_kit.config().accept,
}
self.report = Gate.build(name="report", amount=usd("0.10"),
description="Premium report", **defaults)
self.api_call = Gate.build(name="api_call", amount=usd("0.001"),
accept=(Protocol.X402,), **defaults)
catalog = Catalog()
app = Flask(__name__)
@app.get("/report")
@require_payment("report", pricing=catalog)
def report():
return jsonify(content="premium content")
@app.get("/api/data")
@require_payment("api_call", pricing=catalog)
def api_data():
return jsonify(data=[])
app.run(host="127.0.0.1", port=8000)
Gates are validated in Gate.build at boot, wrong currency, missing
recipient, fee math that doesn't add up, so configuration errors surface
before any traffic. accept= is an allowlist; the api_call gate here
refuses to settle over MPP.
3. Production-shape config
Snippet 2's demo recipient and public sandbox are fine for poking around.
Production wants explicit keys, a dedicated RPC, and a list of accepted
stablecoins. The Flask app is unchanged, only the configure call grows.
# app.py, same routes as snippet 2.
import solana_pay_kit
from solana_pay_kit import Gate, Operator, Pricing, Signer, Stablecoin, usd
PLATFORM = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY"
solana_pay_kit.configure(
network="solana_mainnet",
stablecoins=(Stablecoin.USDC, Stablecoin.PYUSD),
operator=Operator(signer=Signer.file("operator.json")),
rpc_url="https://mainnet.helius-rpc.com/?api-key=YOUR_HELIUS_KEY",
)
class Catalog(Pricing):
def __init__(self):
defaults = {"default_pay_to": solana_pay_kit.config().effective_recipient()}
self.report = Gate.build(name="report", amount=usd("0.10"),
description="Premium report", **defaults)
# Platform-fee pattern: customer pays $10.00,
# operator nets $9.70, PLATFORM nets $0.30. x402 auto-disabled.
self.marketplace_sale = Gate.build(
name="marketplace_sale",
amount=usd("10.00"),
fee_within={PLATFORM: usd("0.30")},
**defaults,
)
configure reads literal values here; in real deployments pull the signer
and RPC URL from your environment (Signer.env("OPERATOR_KEY"),
os.getenv("RPC_URL")) or drive the whole thing from env vars with
solana_pay_kit.configure_from().
Two safety rails fire at boot:
network="solana_mainnet"plus the published demo signer raisesDemoSignerOnMainnetError, no real funds get routed to a publicly known address by accident.- Missing
mpp.challenge_binding_secret? Preflight resolves one from the environment, falling back to./.env, generating and persisting one if neither exists, so the HMAC stays stable across restarts. Override viaPAY_KIT_MPP_CHALLENGE_BINDING_SECRETto control it from your secret manager.
Run the example
Three runnable examples ship with this package, one per framework. Each one boots zero-config against the Surfpool sandbox.
Boot the server:
git clone https://github.com/solana-foundation/pay-kit
cd pay-kit/python
pip install -e ".[flask]"
python examples/flask/app.py
Consume with pay curl:
# Install the pay CLI:
brew install pay
# or npm install -g @solana/pay
# Fail with 402, payment required
curl -i http://127.0.0.1:8000/report
# Succeed with 200, payment provided
pay curl -i http://127.0.0.1:8000/report
x402
x402 revives HTTP 402 Payment Required as a
client-server payment handshake. Your server gates a route; a paying client
receives the 402 with payment instructions, signs a Solana transaction
off-chain, and replays the same request with a PAYMENT-SIGNATURE header.
The server verifies the signature, broadcasts the transaction, and returns
the original response with a PAYMENT-RESPONSE header carrying the on-chain
settlement signature.
x402 is single-recipient by design: the server's facilitator pays the
network fees, the customer's signed transaction settles funds to pay_to.
Gates with fee_within or fee_on_top recipients auto-disable x402,
because stock x402 facilitators settle to one address.
| Scheme | Client | Server |
|---|---|---|
exact |
✅ | ✅ |
upto |
✅ | ✅ |
batch-settlement |
— | — |
upto charges for actual usage up to a ceiling: the client opens a payment
channel depositing the authorized maximum, the handler meters the response and
reports it via the Charge dependency, then the gate settles the actual amount
and refunds the remainder. It is gated with require_usage / RequireUsage
(rather than require_payment) and needs an operator signer.
Client
Pay an x402-gated endpoint with the auto-pay transport (the Go NewClient
ergonomics): hand it a signer and an RPC and you get back an
httpx.AsyncClient that replays any 402 with a signed PAYMENT-SIGNATURE
payment, then returns the paid response.
import asyncio
from solana_pay_kit import Signer
from solana_pay_kit._paycore.rpc import SolanaRpc
from solana_pay_kit.protocols.x402.client import x402_async_client
async def main():
signer = Signer.file("payer.json") # the payer's keypair
rpc = SolanaRpc("https://api.devnet.solana.com")
async with x402_async_client(signer, rpc) as http:
resp = await http.get("https://api.example/report") # 402 -> pay -> 200
print(resp.status_code, resp.headers.get("payment-response"))
asyncio.run(main())
The low-level building blocks are exposed too, mirroring the Rust/Go client:
parse_x402_challenge(headers, body, selection) selects an offer, and
build_payment_header(signer, rpc, offer) returns the base64 PAYMENT-SIGNATURE
value for callers that drive their own HTTP. See
examples/x402-client/.
MPP
The Machine Payments Protocol is the broader HTTP Payment Authentication scheme, the same 402 handshake, but the challenge carries a richer intent shape that supports multi-recipient splits, server-side fee accounting, and a separate fee-payer signer.
Use MPP when:
- Your gate has a platform or gateway fee (the Stripe-Connect "application fee" pattern).
- You want the server to subsidize the customer's network fee.
- You want one challenge per gate instead of per-mint-quoted offers.
| Intent | Client | Server |
|---|---|---|
charge/pull |
✅ | ✅ |
charge/push |
— | ✅ |
session |
✅ | ✅ |
subscription |
— | — |
session ships both sides. Client: ActiveSession voucher signing, the
challenge-driven pull/clientVoucher payment-channel openers (fee payer =
challenge operator, pending-server-signature placeholder), the metered
SessionConsumer, and the SSE streaming helpers (MeteredSseSession,
MeteredSseStream, HttpCommitTransport). Server: the session method
(new_session) issuing challenges and verifying credentials/vouchers, the
server-broadcast open path (openTxSubmitter=server, the operator co-signs and
broadcasts the open), the reserve/commit metering side channel
(session_routes), the shared channel store, the idle-close watchdog, and
on-chain settle-at-close (when a signer and RPC are configured, a closed
channel's settledSignature carries the real on-chain signature; without them
the close is a state-flip and the signature stays null). Not yet ported:
pull/operatedVoucher (multi-delegate) opens.
The MPP server owns the full lifecycle: it issues signed challenges with a
fresh recentBlockhash, parses and validates the Authorization: Payment
credential, pins the echoed charge request, decodes the client-signed
transaction and checks recipient, amount, mint, splits, ATA, memos, and
compute budget, rejects Surfpool-signed transactions on non-localnet
networks, optionally fee-payer co-signs (legacy + v0), broadcasts via
sendTransaction, consumes the signature in the replay store, awaits
confirmation, and emits payment-receipt with the on-chain signature.
Vocabulary
| Term | Meaning |
|---|---|
| gate | A protected unit. Has an amount, optional fees, accepted protocols. |
| amount | The base amount a gate charges, before any fee_on_top. |
| total | What the customer pays: amount + sum(fee_on_top). Derived via Gate.total(). |
| price | Value object returned by usd(...): number + currency + settlement. |
| fee_within | Fee taken out of the amount. pay_to nets less. |
| fee_on_top | Fee added to the amount. Customer pays more; pay_to nets full. |
| payment | Proof submitted by the client to pass a gate. |
| protocol | Protocol.X402 or Protocol.MPP (top-level dispatch). |
| scheme | x402 sub-form: exact. MPP sub-form: charge. |
| currency | Fiat unit a price is quoted in (USD, EUR, GBP). |
| accept | Ordered preference list (protocols and stablecoins both). |
| settlement | On-chain asset that actually transfers (USDC, USDT). |
Three primitives
The framework-agnostic trio, importable from the top level for imperative gating inside a handler, mirrored by the per-framework shims:
| Function | Purpose |
|---|---|
require_payment(request) |
Returns the verified Payment, raises PaymentRequiredError if unpaid |
is_paid(request) |
Predicate, never raises |
get_payment(request) |
The verified Payment, None until paid |
Each framework shim also exposes its own decorator/dependency form:
solana_pay_kit.flask.require_payment, solana_pay_kit.fastapi.RequirePayment, and
solana_pay_kit.django.require_payment.
Inline pricing
For one-off endpoints that don't warrant a registry entry, skip the gate name and pass a price directly:
@app.get("/oneoff")
@require_payment(usd("0.25"))
def oneoff():
return jsonify(ok=True)
The bare Price is wrapped into an inline Gate using the configured
default recipient and accept list.
Gate DSL
Each gate is a frozen value object built via Gate.build with an amount, an
ordered list of accepted protocols, and zero or more named fees.
SELLER = "Ay..."
PLATFORM = "CX..."
# Simple. Customer pays $0.10, pay_to nets $0.10.
Gate.build(name="report", amount=usd("0.10"), description="Premium report")
# x402-only.
Gate.build(name="api_call", amount=usd("0.001"), accept=(Protocol.X402,))
# Stripe-Connect "application fee". Customer pays $10.00,
# SELLER nets $9.70, PLATFORM nets $0.30. x402 auto-disabled.
Gate.build(name="marketplace_sale", amount=usd("10.00"),
pay_to=SELLER, fee_within={PLATFORM: usd("0.30")})
# Surcharge. Customer pays $10.50, SELLER nets $10.00, PLATFORM nets $0.50.
Gate.build(name="ticket", amount=usd("10.00"),
pay_to=SELLER, fee_on_top={PLATFORM: usd("0.50")})
# Dynamic per-request pricing.
@gate("tiered")
def tiered(request):
tier = request.args.get("tier")
return usd("5.00") if tier == "premium" else usd("0.10")
Boot-time validations (all raise ConfigurationError or a subclass):
pay_tois required (gate kwarg or a configured operator recipient).- Fee recipient must differ from
pay_to. Fold the fee into the amount instead. - All fee prices share one denomination with the amount.
sum(fee_within) <= amount.accept=(Protocol.X402,)on a fee-bearing gate raisesProtocolIncompatibleError.
Framework-first
solana_pay_kit carries no web-framework dependency in the base install. The
framework shims live in optional submodules imported on demand:
solana_pay_kit.flask(installsolana_pay_kit[flask]), a@require_paymentview decorator plusis_paid/paymentrequest accessors.solana_pay_kit.fastapi(installsolana_pay_kit[fastapi]), aRequirePaymentdependency forDepends(...)plusinstall_exception_handler(app).solana_pay_kit.django(installsolana_pay_kit[django]), arequire_paymentview decorator and an optionalPaymentMiddlewarestack form.
Every shim delegates protocol/scheme dispatch and 402-challenge assembly to
the host-neutral PayCore; the shim only translates the outcome into its
framework's response idioms. A verified Payment is attached to the request
(request.state on FastAPI, flask.g on Flask, request.payment on
Django) and its settlement headers are merged onto the success response.
# Imperative gating, no decorator, any framework:
from solana_pay_kit import require_payment
def view(request):
payment = require_payment(request) # raises PaymentRequiredError if unpaid
...
Examples
Runnable examples ship with this package:
examples/simple-server/server.py, the smallest solana_pay_kit server: stdlibhttp.serverwith one gated endpoint over the unifiedsolana_pay_kitsurface, no web framework.examples/fastapi/app.py, FastAPI server using theRequirePaymentdependency andinstall_exception_handler.examples/flask/app.py, Flask server gated with the unifiedsolana_pay_kitsurface (@require_paymentdecorator and thePricingregistry).examples/django/views.py, Django views + URLconf snippet using the@require_paymentdecorator.
All examples default to solana_localnet, USDC, and the demo recipient.
Override the RPC with rpc_url= / PAY_KIT_RPC_URL.
Coverage
cd python
pip install -e ".[dev]"
ruff check src tests
ruff format --check src tests
pyright
pytest --cov=solana_pay_kit --cov-fail-under=90
The solana_pay_kit surface is gated at 90 percent line coverage in CI. The
solana_pay_kit.preflight module is omitted from the gate: it wraps live Solana
RPC + Surfnet cheatcodes that cannot run inside the offline unit suite, and
its two opt-out knobs are covered separately against a stubbed run/RPC.
Harness
The Python server has a direct harness adapter at
harness/python-server/server.py, a
dual-protocol server that settles both MPP charge and x402-exact. Focused
harness commands:
cd harness
MPP_HARNESS_CLIENTS=typescript MPP_HARNESS_SERVERS=python pnpm test
MPP_HARNESS_CLIENTS=rust MPP_HARNESS_SERVERS=python pnpm test
Spec
This SDK implements the Solana Charge Intent for the HTTP Payment Authentication Scheme, plus the x402 exact scheme on Solana. The wire format, error grammar, and challenge / credential shape are all defined at paymentauth.org.
Repo layout
python/
├── src/solana_pay_kit/ unified surface over x402 + MPP
│ ├── config.py, operator.py, signer.py, price.py, fee.py, gate.py,
│ │ pricing.py, payment.py, preflight.py, errors.py # umbrella surface
│ ├── _paycore/ Currency / Network / Protocol / Stablecoin / Mints / Solana
│ ├── _middleware.py host-neutral resolver + require_payment/is_paid/get_payment
│ ├── fastapi.py, flask.py, django.py framework shims
│ ├── kms.py reserved remote-enclave signer namespace
│ └── protocols/
│ ├── x402/ x402-exact adapter (__init__) + verifier/wire shapes (verify.py)
│ └── mpp/ MPP-charge adapter (__init__) over the consolidated wire layer
│ ├── core/ canonical JSON, headers, challenge, types, errors, RPC, store
│ ├── intents/charge.py charge intent
│ ├── server/ charge handler, middleware, network check, defaults, payment page
│ └── client/ charge + transport
├── examples/simple-server/ stdlib http.server, one gated endpoint
├── examples/{fastapi,flask,django}/ solana_pay_kit framework examples
├── tests/ pytest suite
└── pyproject.toml
Coding convention
This SDK follows the
skills.sh/mindrally/skills/python
best-practice skill. Small modules, frozen pydantic value objects, explicit
error types with canonical codes, deterministic wire serialization (RFC 8785
canonical JSON), defensive payment verification, and branch tests on
security-sensitive paths.
The repo-level pay-sdk-implementation skill remains the protocol source of
truth: Rust spec wire format first, Python idioms second.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file solana_pay_kit-0.2.0.tar.gz.
File metadata
- Download URL: solana_pay_kit-0.2.0.tar.gz
- Upload date:
- Size: 513.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f619ffdea98c399d0b8b1f3bcffb6bad533ae69e6a4c0ee7272c36ab3a8a6c42
|
|
| MD5 |
00e737f7c299cb51ab96b9f80901cf39
|
|
| BLAKE2b-256 |
171ecb7e6b882e5ae7244fa2652027afacb15ba5d603a0345214db4935ebb390
|
Provenance
The following attestation bundles were made for solana_pay_kit-0.2.0.tar.gz:
Publisher:
pypi-publish.yml on solana-foundation/pay-kit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
solana_pay_kit-0.2.0.tar.gz -
Subject digest:
f619ffdea98c399d0b8b1f3bcffb6bad533ae69e6a4c0ee7272c36ab3a8a6c42 - Sigstore transparency entry: 2037104219
- Sigstore integration time:
-
Permalink:
solana-foundation/pay-kit@b8b2fd467ec363dd715a0523cf95e679a7169a69 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/solana-foundation
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi-publish.yml@b8b2fd467ec363dd715a0523cf95e679a7169a69 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file solana_pay_kit-0.2.0-py3-none-any.whl.
File metadata
- Download URL: solana_pay_kit-0.2.0-py3-none-any.whl
- Upload date:
- Size: 308.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
de0623ccac4837df64756ae112cfbbb470ef359a3df5ffdb6640516b0001a445
|
|
| MD5 |
e00e48a4fe8be93ddcd632f23459a6b0
|
|
| BLAKE2b-256 |
88fae1540b39dce849161f4ede9bdbdf41670c68160a391dfe3a9281a16562a4
|
Provenance
The following attestation bundles were made for solana_pay_kit-0.2.0-py3-none-any.whl:
Publisher:
pypi-publish.yml on solana-foundation/pay-kit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
solana_pay_kit-0.2.0-py3-none-any.whl -
Subject digest:
de0623ccac4837df64756ae112cfbbb470ef359a3df5ffdb6640516b0001a445 - Sigstore transparency entry: 2037105031
- Sigstore integration time:
-
Permalink:
solana-foundation/pay-kit@b8b2fd467ec363dd715a0523cf95e679a7169a69 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/solana-foundation
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi-publish.yml@b8b2fd467ec363dd715a0523cf95e679a7169a69 -
Trigger Event:
workflow_dispatch
-
Statement type: