solana-pay-kit (Python) — SDK Design Guidelines
Working notes for the surface that unifies x402 and MPP behind a
single Pythonic API. Sibling to ruby/DESIGN.md; same model, Python idioms.
Intent
- One package, one surface, two protocols underneath (x402, MPP). Merchants
shouldn't have to care which protocol settled a request unless they ask.
- Type-hint-driven, IDE-friendly. The signature is the contract.
- Framework-agnostic core. FastAPI, Flask, Django, Starlette are thin shims
over a pure ASGI/WSGI middleware.
Naming
PyPI name and Python import name use the standard split — same logic as the
Ruby gem-vs-module split:
| Surface |
Name |
| PyPI package |
solana-pay-kit |
| Import path |
import pay_kit (lowercase, PEP 8) |
| Distribution dir |
src/pay_kit/ |
| Submodules |
pay_kit.fastapi, pay_kit.flask, pay_kit.schemes, … |
Rationale: PyPI name carries discoverability (pip install solana-pay-kit,
SEO on PyPI); short import keeps call sites readable (pay_kit.configure(...)
beats solana_pay_kit.configure(...)). Same pattern as
beautifulsoup4 → bs4, pillow → PIL, psycopg2-binary → psycopg2.
Decoupling pay_kit from solana in imports keeps the door open for
non-Solana rails later (pay_kit.schemes.lightning) without breaking
imports.
Inspirations
- FastAPI — type hints as the API
surface, Depends() for dependency-injected gating, async-first. The
FastAPI shim borrows the injection pattern directly.
- Stripe Python SDK — the
reference for "payments SDK in Python." We take the exception hierarchy,
resource-orientation, and the discipline around idempotency.
- Pydantic v2 — frozen, validated
data models. Gate, Price, Fee are Pydantic BaseModel subclasses with
frozen=True. Validation happens at construction, not at use.
- Flask — minimal decorator-driven
surface. @require_payment(REPORT) stacks cleanly with @app.route.
- httpx — sync/async parity as a
design principle. Every helper ships in both flavors.
Vocabulary
Pick these terms and use them consistently in code, docstrings, and error
messages.
| Term |
Meaning |
| operator |
Merchant identity: recipient + signer + fee-payer flag. |
| signer |
Ed25519 key source — demo, bytes, file, from_env, gcp, … |
| gate |
A protected unit. Has an amount, optional fees, accepted schemes. |
| amount |
The base amount a gate charges, before any fee_on_top. |
| total |
What the customer pays: amount + sum(fee_on_top). Derived. |
| price |
Value object returned by usd(...): number + denom + settlement. |
| fee_within |
Fee taken out of the amount. pay_to recipient 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. |
| scheme |
Protocol used to prove payment: "x402", "mpp". |
| accept |
Ordered preference list — applies to both schemes and stablecoins. |
| denom |
The fiat unit a price is quoted in ("USD", "EUR"). |
| settlement |
The on-chain asset that actually transfers ("USDC", "USDT"). |
Surface
Boot-time configuration
Function-call form is the Python-idiomatic default. The kwarg names mirror
config sections. Zero-config boots — every field has a working default,
including a hard-coded Signer.demo() so demos and tests run with
import pay_kit; pay_kit.configure(network="solana_localnet") and
nothing else.
import os
import pay_kit
pay_kit.configure(
network = "solana_devnet",
accept = ["x402", "mpp"], # preference order
stablecoins = ["USDC", "USDT"], # preference order
rpc_url = os.environ.get("PAY_KIT_RPC_URL"), # None → default per network
operator = pay_kit.Operator(
recipient = os.environ.get("PAY_KIT_OPERATOR_RECIPIENT"), # None → signer.pubkey
signer = pay_kit.Signer.from_env("PAY_KIT_OPERATOR_KEY"), # None → Signer.demo()
fee_payer = True,
),
# OPTIONAL — when set, PayKit POSTs to this URL's /verify and /settle
# endpoints and never touches the chain itself. When None, PayKit runs
# x402 locally using rpc_url + operator.signer. See "Chain access" below.
x402 = pay_kit.X402Config(
facilitator_url = os.environ.get("PAY_KIT_X402_FACILITATOR_URL"),
scheme = "exact",
),
mpp = pay_kit.MppConfig(
realm = "MyApp",
challenge_binding_secret = os.environ["PAY_KIT_MPP_CHALLENGE_BINDING_SECRET"],
expires_in = 300,
),
)
There is no top-level pay_to and no x402.facilitator_secret_key —
both cascade from operator. See Operator below.
For env-driven config, a Pydantic BaseSettings is supported as a drop-in
alternative — the canonical Python pattern for 12-factor config:
from pydantic_settings import BaseSettings
import pay_kit
class Settings(BaseSettings):
network: str = "solana_devnet"
rpc_url: str | None = None
operator_recipient: str | None = None
operator_key: str | None = None # JSON / base58 / hex; auto-detected
x402_facilitator_url: str | None = None
mpp_challenge_binding_secret: str
class Config:
env_prefix = "PAY_KIT_"
pay_kit.configure_from(Settings())
Operator — merchant identity in one place
An operator bundles the three things a merchant brings to the protocol:
recipient — where settled funds land. Default pay_to for every gate.
signer — the Ed25519 keypair used to sign x402 facilitator challenges,
and (if fee_payer=True) to pay Solana network fees.
fee_payer — whether the operator's signer also pays Solana network
fees on settlement transactions.
operator = pay_kit.Operator(
recipient = "Cs2zdfUNonRdRGsiZUQQLdTxzxVvJZmgiX2mpLYKuEqP",
signer = pay_kit.Signer.file("/etc/paykit/operator.json"),
fee_payer = True,
)
Operator is a Pydantic v2 frozen=True BaseModel. The fields default
to None (recipient, signer) or True (fee_payer); a
@model_validator(mode="after") resolves Nones:
signer → Signer.demo() if None
recipient → signer.pubkey if None
This is the Pythonic equivalent of Ruby's "nil-as-no-op setter
convention": None-as-default-marker. Pass os.environ.get(...)
directly — if the env var is unset, you get None, the validator fills
in the default, and you didn't need an if ENV[...] guard:
operator = pay_kit.Operator(
recipient = os.environ.get("PAY_KIT_OPERATOR_RECIPIENT"), # None ⇒ signer.pubkey
signer = pay_kit.Signer.from_env("PAY_KIT_OPERATOR_KEY"), # None ⇒ Signer.demo()
)
Defaults
The operator is optional. If you never pass operator=..., you get:
pay_kit.Operator(
recipient = None, # → derived from signer.pubkey
signer = pay_kit.Signer.demo(), # → hard-coded demo keypair
fee_payer = True,
)
Signer.demo() returns the package-shipped demo keypair (constant,
base58 pubkey: AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj). The
library calls logging.warning(...) once at boot when the demo signer is
in use, and raises pay_kit.DemoSignerOnMainnetError if combined with
network="solana_mainnet".
Signer factories
Factories split by execution model. Local/in-process signers live as
classmethods on pay_kit.Signer and ship in v1. Remote enclave signers
(KMS, Vault, HSMs) are reserved under pay_kit.kms but not part of
the initial release — they're sketched here so the namespace shape is
locked in before anyone writes against pay_kit.Signer.gcp_kms(...)
and we have to rename later.
Both halves return objects satisfying the same Signer protocol —
.pubkey, .sign(message), .fee_payer — so the rest of PayKit
doesn't care which side of the split you used.
# === Local — synchronous, no I/O on .sign. Ships in v1. ===
pay_kit.Signer.demo() # hard-coded demo keypair
pay_kit.Signer.bytes([1, 2, 3, ..., 64]) # raw 64-byte secret (list[int])
pay_kit.Signer.json("[1,2,3,...,64]") # Solana-CLI JSON-array format
pay_kit.Signer.base58("4PzYg...wuFNQT") # Phantom / Solflare export string
pay_kit.Signer.hex("abcd1234...") # 128-char hex string
pay_kit.Signer.file("/etc/paykit/operator.json") # 64-byte JSON array on disk
pay_kit.Signer.from_env("PAY_KIT_OPERATOR_KEY") # env var (auto-detects json/base58/hex)
pay_kit.Signer.generate() # fresh ephemeral keypair (tests only)
# === Remote enclave — asynchronous, network I/O on .sign. FUTURE WORK. ===
# Sketched here to reserve the namespace shape. Not in v1.
pay_kit.kms.gcp(key_name="...", pubkey="...") # not implemented yet
pay_kit.kms.aws(key_id="...", region="...", pubkey="...") # not implemented yet
pay_kit.kms.vault(addr="...", path="...", pubkey="...") # not implemented yet
Naming follows Python convention: from_* for parsing constructors
(matches datetime.fromisoformat, Path.from_uri). Class-method
factories on Signer; module-level functions on pay_kit.kms (lowercase
module, sub-namespace).
Signer.from_env(name) contract
pay_kit.Signer.from_env("VAR_NAME") is the env-driven loader. It
returns:
- A
Signer parsed from the env var's contents, auto-detecting the
encoding (Solana-CLI JSON array, base58 string, or hex string — same
formats supported by the explicit .json / .base58 / .hex
classmethods).
None if the env var is unset or empty.
- Raises
pay_kit.InvalidKeyError if the var is set but malformed.
Silent fallback on malformed input would mask bugs.
The None return composes with the Operator model_validator —
Operator(signer=Signer.from_env("…")) leaves the default in place when
the env var is unset.
Cascading
The operator is one source of truth for things that used to be
scattered:
| Was |
Now |
pay_to |
operator.recipient |
x402.facilitator_secret_key |
operator.signer (when running x402) |
| (manual SOL fee management) |
operator.fee_payer = True |
Env-var conventions follow the same rename. The old names keep working
for one minor release with a DeprecationWarning, then go away:
| Old |
New |
PAY_KIT_PAY_TO |
PAY_KIT_OPERATOR_RECIPIENT |
PAY_KIT_X402_FACILITATOR_KEY |
PAY_KIT_OPERATOR_KEY |
PAY_KIT_X402_FACILITATOR * |
PAY_KIT_X402_FACILITATOR_URL |
PAY_KIT_MPP_SECRET |
PAY_KIT_MPP_CHALLENGE_BINDING_SECRET |
* The old PAY_KIT_X402_FACILITATOR value in demo configs was actually
a Solana RPC URL, not a facilitator URL — that data should move to the
new PAY_KIT_RPC_URL and the facilitator var should be left unset
unless a real x402 facilitator is being pointed at.
The MPP rename tracks the spec's vocabulary — draft-httpauth-payment-00
§5.1.2.1.1 calls this the "server secret" used for "stateless challenge
binding" (HMAC-SHA256 over challenge parameters).
Gate-level pay_to still overrides the operator's recipient (the
marketplace pattern — operator signs, seller is paid).
Rules
operator.recipient defaults to operator.signer.pubkey when
None. The signer always has a pubkey, so this is a safe default.
- Gate
pay_to overrides operator.recipient per gate.
operator.signer is the x402 facilitator key. Setting
x402.signer = ... is the escape hatch; not advertised in the
getting-started docs.
operator.fee_payer = True (default) means the operator's signer
pays Solana network fees on settlement. Flip to False when fees
come from elsewhere.
- MPP secret stays separate. MPP signs HMACs, not Ed25519, so
mpp.challenge_binding_secret remains explicit — KMS-backed signers
don't apply (the spec calls this the "server secret" / "shared
secret" used for "stateless challenge binding"; see
draft-httpauth-payment-00 §5.1.2.1.1).
- Demo signer warns on every boot. Construction refuses to proceed
if network="solana_mainnet" resolves to the demo signer.
Chain access — rpc_url and x402 modes
Two separate concerns that have been historically confused: how PayKit
talks to Solana (rpc_url) and whether PayKit handles x402
verification/settlement itself (x402.facilitator_url). Different
fields, different layers.
rpc_url — Solana RPC URL
The HTTP endpoint of a Solana RPC node. Used by:
- MPP, always — verifying that a submitted settlement transaction
landed on-chain with the right recipient and amount.
- x402 self-hosted mode — same plus tx construction and submission.
- x402 delegated mode — not used; the facilitator owns the RPC.
rpc_url is optional. When None, PayKit picks a default from
network:
network |
Default rpc_url |
"solana_mainnet" |
https://api.mainnet-beta.solana.com |
"solana_devnet" |
https://api.devnet.solana.com |
"solana_localnet" |
http://localhost:8899 |
Override with rpc_url="https://my-helius-endpoint.example.com" (or
any custom RPC — Helius, QuickNode, private validator). The public
Solana RPCs are rate-limited and unsuitable for production traffic; a
warning fires at boot if network="solana_mainnet" and rpc_url
resolves to the public default.
x402.facilitator_url — optional delegation
Per the x402 spec, a facilitator is a server that facilitates
verification and execution of payments for one or many networks
(coinbase/x402 README). It exposes /verify and /settle HTTP
endpoints; the resource server (merchant) POSTs payment payloads and
the facilitator handles the RPC, gas/fees, and on-chain submission. The
merchant never sees the chain.
Two modes:
# Delegated: PayKit POSTs to the facilitator. No RPC needed for x402.
x402 = pay_kit.X402Config(facilitator_url="https://facilitator.example.com")
# Self-hosted: PayKit runs verification and settlement itself.
# facilitator_url left None; rpc_url + operator.signer do the work.
x402 = pay_kit.X402Config(facilitator_url=None) # the default
Default is self-hosted (None). Setting facilitator_url to any
URL opts into delegation. When delegated:
rpc_url is unused by x402 (still used by MPP if enabled).
operator.signer is still used to authenticate to the facilitator if
the facilitator requires merchant identity (some don't; depends on
the facilitator's auth scheme).
operator.fee_payer is ignored by x402 — the facilitator handles
fees per its own policy.
The previous demo's x402.facilitator = "https://402.surfnet.dev:8899"
was incorrect: that URL is a Solana validator RPC (port 8899), not an
x402 facilitator. Self-hosted mode with rpc_url set to that URL is
the correct shape; the facilitator field stays None in the demo until
a real facilitator is available to point at.
Price helper — denomination vs. settlement
from pay_kit import usd
usd("0.10") # denom USD, settled in configured defaults
usd("0.10", "USDC") # narrow to one stablecoin
usd("0.10", "USDC", "USDT") # ordered preference: USDC first, USDT fallback
usd("0.10", *config.stablecoins) # dynamic
Future-proofed: eur("0.20", "EURC"), gbp(...), etc. slot in identically.
Amounts use decimal.Decimal internally — never float. (usd accepts
str | Decimal | int; float is rejected to avoid the standard FP trap.)
Gates — module-level constants
Python convention is to define gates as module-level Gate objects, imported
where needed. No central registry class — this is a deliberate departure
from the Ruby Pricing < PayKit::Pricing pattern, because Python's import
system already gives you one source of truth per module:
# app/pricing.py
from pay_kit import Gate, usd
REPORT = Gate(amount=usd("0.10"), description="Premium report")
API_CALL = Gate(amount=usd("0.001", "USDC"), accept=["x402"])
PAYWALL = Gate(amount=usd("0.50"), accept=["mpp", "x402"]) # MPP preferred
# Inclusive fee — taken out of the amount. MPP only (x402 auto-disabled).
SALE = Gate(
amount = usd("10.00"),
pay_to = SELLER_WALLET,
fee_within = {PLATFORM_WALLET: usd("1.00")},
)
# Surcharge — added on top of the amount.
TICKET = Gate(
amount = usd("10.00"),
pay_to = SELLER_WALLET,
fee_on_top = {PLATFORM_WALLET: usd("0.50")},
)
For dynamic pricing (price depends on the request), decorate a callable:
from pay_kit import gate
@gate.dynamic
def tiered(req) -> Gate:
tier = req.query_params["tier"]
match tier:
case "basic": return Gate(amount=usd("0.10"))
case "premium": return Gate(amount=usd("5.00"))
Amount and fees
A gate has one amount (the base it charges) and zero or more fees. Two
kwargs cover every real-world case; each takes a {recipient: price} dict,
so one or many recipients use the same syntax:
fee_within: {...} — taken out of the amount. Customer pays the
amount; the pay_to recipient nets less.
fee_on_top: {...} — added on top of the amount. Customer pays
amount + fee; the pay_to recipient nets the full amount.
# Simple, no fees:
REPORT = Gate(amount=usd("0.10"), pay_to=ALICE)
# Customer pays $0.10 ▸ ALICE nets $0.10
# fee_within (Stripe Connect "application_fee" pattern):
SALE = Gate(
amount = usd("10.00"),
pay_to = SELLER,
fee_within = {PLATFORM: usd("0.30")},
)
# Customer pays $10.00 ▸ SELLER nets $9.70 ▸ PLATFORM $0.30
# fee_on_top (surcharge / cardholder-pays):
TICKET = Gate(
amount = usd("10.00"),
pay_to = SELLER,
fee_on_top = {PLATFORM: usd("0.50")},
)
# Customer pays $10.50 ▸ SELLER nets $10.00 ▸ PLATFORM $0.50
# Multiple recipients, same kind — just add dict entries:
TICKET = Gate(
amount = usd("10.00"),
pay_to = SELLER,
fee_on_top = {PLATFORM: usd("0.30"), GATEWAY: usd("0.20")},
)
# Mixed kinds — both kwargs:
COMPLEX = Gate(
amount = usd("100.00"),
pay_to = SELLER,
fee_within = {PLATFORM: usd("3.00")},
fee_on_top = {GATEWAY: usd("0.50")},
)
# Customer pays $100.50 ▸ SELLER nets $97.00 ▸ PLATFORM $3.00 ▸ GATEWAY $0.50
Properties exposed on the Gate model:
gate.amount — declared base
gate.total — what the customer pays (amount + sum(fee_on_top)).
Advertised in the 402 challenge.
gate.payout(to=X) — what recipient X nets after all fees applied.
Rules:
- Fixed amounts only. No basis points, no percentages, no rounding policy.
pay_to is optional and defaults to operator.recipient. Most
gates omit it; marketplace gates set it to route to a seller. Fees
route to their own recipients via the dict kwargs.
- All amounts share one denomination. Mix
usd(...) and eur(...) and
the Gate constructor raises ValidationError at import time.
sum(fee_within values) <= amount. Pydantic validator at construction
— the pay_to recipient can't end up with a negative payout.
- x402 is automatically disabled when
fee_within or fee_on_top is
present. Stock x402 facilitators settle to a single address; multi-
recipient settlement is MPP-only. The resolver strips "x402" from
accept silently. Explicitly setting accept=["x402"] on a gate with
fees raises at construction.
- Stablecoin preference is gate- or config-level, not per-fee.
Per-recipient override via the amount form (usd("3.00", "USDC")) is the
escape hatch.
FastAPI — dependency injection
The killer FastAPI pattern: Depends(require_payment(GATE)) injects a
typed Payment into your handler. Free routes can opportunistically observe
the payment via Depends(get_payment) returning Payment | None:
from fastapi import FastAPI, Depends
from pay_kit.fastapi import require_payment, get_payment, Payment
from app.pricing import REPORT, BULK_REPORT
app = FastAPI()
@app.get("/report")
async def report(payment: Payment = Depends(require_payment(REPORT))):
return {"ok": True, "paid_by": payment.scheme}
@app.get("/stats")
async def stats(payment: Payment | None = Depends(get_payment)):
return {"ok": True, "premium": payment is not None}
# Inline form, no `pricing.py` entry:
@app.get("/oneoff")
async def oneoff(
payment: Payment = Depends(require_payment(Gate(amount=usd("0.25")))),
):
return {"ok": True}
Flask — decorator
Stacks naturally above @app.route. The payment is on flask.g:
from flask import Flask, jsonify, g
from pay_kit.flask import require_payment, is_paid
from app.pricing import REPORT, BULK_REPORT
app = Flask(__name__)
@app.route("/report")
@require_payment(REPORT)
def report():
return jsonify(ok=True, paid_by=g.payment.scheme)
@app.route("/stats")
def stats():
return jsonify(ok=True, premium=is_paid(BULK_REPORT))
Django — decorator + middleware
# settings.py
MIDDLEWARE = [
# ...
"pay_kit.django.PaymentMiddleware",
]
# views.py
from pay_kit.django import require_payment, is_paid
from app.pricing import REPORT
@require_payment(REPORT)
def report(request):
return JsonResponse({"ok": True, "paid_by": request.payment.scheme})
Starlette — pure ASGI middleware
The lowest layer. Framework shims wrap this:
from starlette.applications import Starlette
from pay_kit.starlette import PaymentMiddleware
app = Starlette(middleware=[Middleware(PaymentMiddleware)])
The trio — same names, framework-agnostic semantics
Mirrors Ruby's require_payment! / paid? / payment. Python has no ? /
! suffixes, so prefixes carry the meaning:
| Function |
Returns |
On failure |
require_payment(GATE) |
Payment |
raises 402 |
is_paid(GATE) |
bool |
never |
get_payment() |
Payment | None |
never |
Errors
Exception hierarchy modeled on Stripe's — base class plus typed leaves so
apps can except precisely:
class PayKitError(Exception): ...
class PaymentRequired(PayKitError): ... # → 402
class InvalidProof(PayKitError): ... # → 400 or 402 with detail
class ChallengeExpired(InvalidProof): ...
class SchemeNotSupported(PayKitError): ... # → 406
Framework shims convert these to the appropriate response — FastAPI to
HTTPException, Flask to abort(...), Django to a JsonResponse. Apps
can register their own handlers to customize the response body.
Layers
pay_kit.starlette # ASGI middleware — protocol-level
pay_kit.fastapi # Depends() helpers, Payment dependency
pay_kit.flask # @require_payment decorator, g.payment
pay_kit.django # decorator + middleware, request.payment
pay_kit.gate # Gate Pydantic model
pay_kit.price # Price Pydantic model (returned by usd, eur, …)
pay_kit.fee # Fee Pydantic model (internal)
pay_kit.operator # Operator Pydantic model
pay_kit.signer # Signer class + classmethod factories (demo/bytes/file/...)
pay_kit.kms # Remote-enclave signer factories (FUTURE — reserved namespace)
pay_kit.schemes.x402 # x402 adapter
pay_kit.schemes.mpp # MPP adapter
Design rules — locked in
- A gate is the unit. Amount alone undersells it — gates carry policy
(accepted schemes), metadata (description), and optionally dynamic logic
via @gate.dynamic.
- Strings expand against config defaults; objects carry overrides.
Same hybrid on both axes — schemes (["x402"]) and stablecoins
(["USDC"]). Strings cover ~90%.
- Order is semantic everywhere.
accept and stablecoin lists are
preference order, not sets. The 402 challenge advertises in this order.
- Frozen value objects.
Gate, Price, Fee are Pydantic v2 models
with model_config = {"frozen": True}. Mutation raises. Validation at
construction.
- Denomination and settlement are separate.
usd("0.10", "USDC", "USDT")
means "$0.10 USD, settle in USDC or USDT." Merchants think fiat.
- ASGI first, framework shims on top. FastAPI/Starlette/Flask/Django
pick up for free if the core is pure middleware. No framework is
special — Flask is a first-class citizen, not a documentation
afterthought. Anything FastAPI does (DI lookup, predicate, accessor) must
work identically in Flask and Django.
require_payment raises, is_paid returns bool, get_payment returns
Payment | None. Mirrors Clearance's bang/predicate/accessor split,
adapted to Python verb-prefix convention.
- Errors are typed exceptions in a hierarchy, not 402-shaped dicts.
Apps except PaymentRequired to customize the response.
- One source of truth per axis. Stablecoin mints live in config with
sensible mainnet/devnet defaults — apps shouldn't have to look up
addresses.
- Amount + fees, never splits. Multi-recipient gates are modelled as
one amount plus fee_within / fee_on_top dict kwargs. Fixed amounts
only, MPP only. x402 auto-disabled on any gate with fees because stock
x402 facilitators settle to a single address.
- No central registry class. Module-level
Gate(...) constants in a
pricing.py are the Python idiom. The from app.pricing import REPORT
line is the registry lookup.
- Sync/async parity. Every public helper ships in both flavors, or is
detected and dispatched correctly. FastAPI handlers can be async; Flask
handlers are sync. The middleware lives once.
Decimal, never float. All amount construction goes through
helpers that reject float inputs. Floats are a known footgun for
money; refuse them.
- Operator is the merchant identity, one source of truth. Recipient,
Ed25519 signer, and fee-payer flag live together on Operator.
Gate-level pay_to and X402Config.signer are escape hatches, not
the primary surface. Zero config boots on the in-memory demo signer
with a visible warning; mainnet refuses to start under the demo key.
None-as-default-marker on optional fields. Operator(recipient= os.environ.get("…")) leaves the field as None when the env var is
unset, and a Pydantic model_validator(mode="after") resolves Nones
to the actual default. No if ENV[...] guards in user code.
Signer factories use from_* for parsing constructors. Matches
datetime.fromisoformat / Path.from_uri convention. Signer.demo(),
Signer.bytes(...), Signer.json(...), Signer.base58(...),
Signer.from_env(...). Remote enclave signers (KMS, Vault) live under
pay_kit.kms — separate sub-module to give async backends room to
grow without crowding the local cases. KMS is future work, not
in v1.
Style — Python micro-rules
- Type hints on all public surfaces.
Gate, Payment, Price are
exported with __all__.
Literal types for enums. accept: list[Literal["x402", "mpp"]].
Gives autocomplete in IDEs without runtime cost.
- Stablecoin strings uppercase:
"USDC", not "usdc". Tickers, not
variable names.
- Scheme strings lowercase:
"x402", "mpp", "x402_exact".
- Network strings lowercase + underscore:
"solana_devnet". Validated
with a Literal at configure time.
- Pydantic v2
BaseModel with frozen=True for all value objects.
from __future__ import annotations at the top of every module — PEP
563 deferred evaluation.
- Ruff + Pyright in strict mode. Lint and type-check are part of CI.
- No
__init__.py re-exports beyond the public API. Internal modules
use leading underscore (_resolver.py).
Open questions
Things to decide before we cut code:
- Inline gate form for one-offs. Currently both
require_payment(REPORT) and require_payment(Gate(amount=usd(...)))
work. Cleaner to support both — same call site, just different
arguments. Worth keeping the inline path?
@gate.dynamic vs callable in the Gate constructor. Decorator is
cleaner; Gate(amount=lambda req: ...) would also work. Decorator wins
on readability but adds an import. Vote needed.
configure() vs configure_from(Settings) vs Pydantic-only. All
three are supported in the current sketch. If most users want
env-driven, configure_from could be the only path. Cost is one extra
import; benefit is fewer ways to do the same thing.
- Async-only middleware, with sync shims — or true dual-stack? FastAPI
and Django 4+ are async-capable; Flask 3 has async views; Django sync
views are common. Probably easiest to ship async middleware and let
sync shims block on it via asgiref.sync.async_to_sync. Confirm.
- Should
Gate be hashable? Module-level constants used as dict keys
in user code is plausible. Pydantic v2 frozen models are hashable by
default — keep that, document it.
What we are not building (yet)
- Subscription / recurring billing.
- Custodial wallet management.
- Non-USD-pegged settlement (BTC/SOL-denominated pricing).
- Chargeback / refund flows.
- A CLI —
solana-pay-kit stays library-only; CLI is a separate package
if needed.
If a feature request lands in one of these buckets, push back hard before
expanding scope.
Future work (post-v1)
Reserved here so the design has a place to grow without breaking changes:
pay_kit.kms module — remote enclave signers. gcp, aws,
vault. Async on .sign (network I/O); share the Signer protocol
contract so call sites don't change. Module name is locked in now;
implementation lands after v1.
- Additional fiat denoms.
eur(...), gbp(...) slot into the
existing usd(...) shape.
- Additional remote-signer backends (Turnkey, Privy, Fireblocks,
HSMs) under pay_kit.kms.*.
solana-pay-kit (Python) — SDK Design Guidelines
Working notes for the surface that unifies x402 and MPP behind a
single Pythonic API. Sibling to
ruby/DESIGN.md; same model, Python idioms.Intent
shouldn't have to care which protocol settled a request unless they ask.
over a pure ASGI/WSGI middleware.
Naming
PyPI name and Python import name use the standard split — same logic as the
Ruby gem-vs-module split:
solana-pay-kitimport pay_kit(lowercase, PEP 8)src/pay_kit/pay_kit.fastapi,pay_kit.flask,pay_kit.schemes, …Rationale: PyPI name carries discoverability (
pip install solana-pay-kit,SEO on PyPI); short import keeps call sites readable (
pay_kit.configure(...)beats
solana_pay_kit.configure(...)). Same pattern asbeautifulsoup4→bs4,pillow→PIL,psycopg2-binary→psycopg2.Decoupling
pay_kitfromsolanain imports keeps the door open fornon-Solana rails later (
pay_kit.schemes.lightning) without breakingimports.
Inspirations
surface,
Depends()for dependency-injected gating, async-first. TheFastAPI shim borrows the injection pattern directly.
reference for "payments SDK in Python." We take the exception hierarchy,
resource-orientation, and the discipline around idempotency.
data models.
Gate,Price,Feeare PydanticBaseModelsubclasses withfrozen=True. Validation happens at construction, not at use.surface.
@require_payment(REPORT)stacks cleanly with@app.route.design principle. Every helper ships in both flavors.
Vocabulary
Pick these terms and use them consistently in code, docstrings, and error
messages.
demo,bytes,file,from_env,gcp, …fee_on_top.amount + sum(fee_on_top). Derived.usd(...): number + denom + settlement.pay_torecipient nets less.pay_tonets full."x402","mpp"."USD","EUR")."USDC","USDT").Surface
Boot-time configuration
Function-call form is the Python-idiomatic default. The kwarg names mirror
config sections. Zero-config boots — every field has a working default,
including a hard-coded
Signer.demo()so demos and tests run withimport pay_kit; pay_kit.configure(network="solana_localnet")andnothing else.
There is no top-level
pay_toand nox402.facilitator_secret_key—both cascade from
operator. See Operator below.For env-driven config, a Pydantic
BaseSettingsis supported as a drop-inalternative — the canonical Python pattern for 12-factor config:
Operator — merchant identity in one place
An operator bundles the three things a merchant brings to the protocol:
recipient— where settled funds land. Defaultpay_tofor every gate.signer— the Ed25519 keypair used to sign x402 facilitator challenges,and (if
fee_payer=True) to pay Solana network fees.fee_payer— whether the operator's signer also pays Solana networkfees on settlement transactions.
Operatoris a Pydantic v2frozen=TrueBaseModel. The fields defaultto
None(recipient, signer) orTrue(fee_payer); a@model_validator(mode="after")resolvesNones:signer→Signer.demo()ifNonerecipient→signer.pubkeyifNoneThis is the Pythonic equivalent of Ruby's "nil-as-no-op setter
convention":
None-as-default-marker. Passos.environ.get(...)directly — if the env var is unset, you get
None, the validator fillsin the default, and you didn't need an
if ENV[...]guard:Defaults
The operator is optional. If you never pass
operator=..., you get:Signer.demo()returns the package-shipped demo keypair (constant,base58 pubkey:
AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj). Thelibrary calls
logging.warning(...)once at boot when the demo signer isin use, and raises
pay_kit.DemoSignerOnMainnetErrorif combined withnetwork="solana_mainnet".Signer factories
Factories split by execution model. Local/in-process signers live as
classmethods on
pay_kit.Signerand ship in v1. Remote enclave signers(KMS, Vault, HSMs) are reserved under
pay_kit.kmsbut not part ofthe initial release — they're sketched here so the namespace shape is
locked in before anyone writes against
pay_kit.Signer.gcp_kms(...)and we have to rename later.
Both halves return objects satisfying the same
Signerprotocol —.pubkey,.sign(message),.fee_payer— so the rest of PayKitdoesn't care which side of the split you used.
Naming follows Python convention:
from_*for parsing constructors(matches
datetime.fromisoformat,Path.from_uri). Class-methodfactories on
Signer; module-level functions onpay_kit.kms(lowercasemodule, sub-namespace).
Signer.from_env(name)contractpay_kit.Signer.from_env("VAR_NAME")is the env-driven loader. Itreturns:
Signerparsed from the env var's contents, auto-detecting theencoding (Solana-CLI JSON array, base58 string, or hex string — same
formats supported by the explicit
.json/.base58/.hexclassmethods).
Noneif the env var is unset or empty.pay_kit.InvalidKeyErrorif the var is set but malformed.Silent fallback on malformed input would mask bugs.
The
Nonereturn composes with theOperatormodel_validator—Operator(signer=Signer.from_env("…"))leaves the default in place whenthe env var is unset.
Cascading
The operator is one source of truth for things that used to be
scattered:
pay_tooperator.recipientx402.facilitator_secret_keyoperator.signer(when running x402)operator.fee_payer = TrueEnv-var conventions follow the same rename. The old names keep working
for one minor release with a
DeprecationWarning, then go away:PAY_KIT_PAY_TOPAY_KIT_OPERATOR_RECIPIENTPAY_KIT_X402_FACILITATOR_KEYPAY_KIT_OPERATOR_KEYPAY_KIT_X402_FACILITATOR*PAY_KIT_X402_FACILITATOR_URLPAY_KIT_MPP_SECRETPAY_KIT_MPP_CHALLENGE_BINDING_SECRET* The old
PAY_KIT_X402_FACILITATORvalue in demo configs was actuallya Solana RPC URL, not a facilitator URL — that data should move to the
new
PAY_KIT_RPC_URLand the facilitator var should be left unsetunless a real x402 facilitator is being pointed at.
The MPP rename tracks the spec's vocabulary —
draft-httpauth-payment-00§5.1.2.1.1 calls this the "server secret" used for "stateless challenge
binding" (HMAC-SHA256 over challenge parameters).
Gate-level
pay_tostill overrides the operator's recipient (themarketplace pattern — operator signs, seller is paid).
Rules
operator.recipientdefaults tooperator.signer.pubkeywhenNone. The signer always has a pubkey, so this is a safe default.pay_tooverridesoperator.recipientper gate.operator.signeris the x402 facilitator key. Settingx402.signer = ...is the escape hatch; not advertised in thegetting-started docs.
operator.fee_payer = True(default) means the operator's signerpays Solana network fees on settlement. Flip to
Falsewhen feescome from elsewhere.
mpp.challenge_binding_secretremains explicit — KMS-backed signersdon't apply (the spec calls this the "server secret" / "shared
secret" used for "stateless challenge binding"; see
draft-httpauth-payment-00§5.1.2.1.1).if
network="solana_mainnet"resolves to the demo signer.Chain access —
rpc_urland x402 modesTwo separate concerns that have been historically confused: how PayKit
talks to Solana (
rpc_url) and whether PayKit handles x402verification/settlement itself (
x402.facilitator_url). Differentfields, different layers.
rpc_url— Solana RPC URLThe HTTP endpoint of a Solana RPC node. Used by:
landed on-chain with the right recipient and amount.
rpc_urlis optional. WhenNone, PayKit picks a default fromnetwork:networkrpc_url"solana_mainnet"https://api.mainnet-beta.solana.com"solana_devnet"https://api.devnet.solana.com"solana_localnet"http://localhost:8899Override with
rpc_url="https://my-helius-endpoint.example.com"(orany custom RPC — Helius, QuickNode, private validator). The public
Solana RPCs are rate-limited and unsuitable for production traffic; a
warning fires at boot if
network="solana_mainnet"andrpc_urlresolves to the public default.
x402.facilitator_url— optional delegationPer the x402 spec, a facilitator is a server that facilitates
verification and execution of payments for one or many networks
(coinbase/x402 README). It exposes
/verifyand/settleHTTPendpoints; the resource server (merchant) POSTs payment payloads and
the facilitator handles the RPC, gas/fees, and on-chain submission. The
merchant never sees the chain.
Two modes:
Default is self-hosted (
None). Settingfacilitator_urlto anyURL opts into delegation. When delegated:
rpc_urlis unused by x402 (still used by MPP if enabled).operator.signeris still used to authenticate to the facilitator ifthe facilitator requires merchant identity (some don't; depends on
the facilitator's auth scheme).
operator.fee_payeris ignored by x402 — the facilitator handlesfees per its own policy.
The previous demo's
x402.facilitator = "https://402.surfnet.dev:8899"was incorrect: that URL is a Solana validator RPC (port 8899), not an
x402 facilitator. Self-hosted mode with
rpc_urlset to that URL isthe correct shape; the facilitator field stays
Nonein the demo untila real facilitator is available to point at.
Price helper — denomination vs. settlement
Future-proofed:
eur("0.20", "EURC"),gbp(...), etc. slot in identically.Amounts use
decimal.Decimalinternally — neverfloat. (usdacceptsstr | Decimal | int;floatis rejected to avoid the standard FP trap.)Gates — module-level constants
Python convention is to define gates as module-level
Gateobjects, importedwhere needed. No central registry class — this is a deliberate departure
from the Ruby
Pricing < PayKit::Pricingpattern, because Python's importsystem already gives you one source of truth per module:
For dynamic pricing (price depends on the request), decorate a callable:
Amount and fees
A gate has one amount (the base it charges) and zero or more fees. Two
kwargs cover every real-world case; each takes a
{recipient: price}dict,so one or many recipients use the same syntax:
fee_within: {...}— taken out of the amount. Customer pays theamount; the
pay_torecipient nets less.fee_on_top: {...}— added on top of the amount. Customer paysamount + fee; thepay_torecipient nets the full amount.Properties exposed on the
Gatemodel:gate.amount— declared basegate.total— what the customer pays (amount + sum(fee_on_top)).Advertised in the 402 challenge.
gate.payout(to=X)— what recipientXnets after all fees applied.Rules:
pay_tois optional and defaults tooperator.recipient. Mostgates omit it; marketplace gates set it to route to a seller. Fees
route to their own recipients via the dict kwargs.
usd(...)andeur(...)andthe
Gateconstructor raisesValidationErrorat import time.sum(fee_within values) <= amount. Pydantic validator at construction— the
pay_torecipient can't end up with a negative payout.fee_withinorfee_on_topispresent. Stock x402 facilitators settle to a single address; multi-
recipient settlement is MPP-only. The resolver strips
"x402"fromacceptsilently. Explicitly settingaccept=["x402"]on a gate withfees raises at construction.
Per-recipient override via the amount form (
usd("3.00", "USDC")) is theescape hatch.
FastAPI — dependency injection
The killer FastAPI pattern:
Depends(require_payment(GATE))injects atyped
Paymentinto your handler. Free routes can opportunistically observethe payment via
Depends(get_payment)returningPayment | None:Flask — decorator
Stacks naturally above
@app.route. The payment is onflask.g:Django — decorator + middleware
Starlette — pure ASGI middleware
The lowest layer. Framework shims wrap this:
The trio — same names, framework-agnostic semantics
Mirrors Ruby's
require_payment!/paid?/payment. Python has no?/!suffixes, so prefixes carry the meaning:require_payment(GATE)Paymentis_paid(GATE)boolget_payment()Payment | NoneErrors
Exception hierarchy modeled on Stripe's — base class plus typed leaves so
apps can
exceptprecisely:Framework shims convert these to the appropriate response — FastAPI to
HTTPException, Flask toabort(...), Django to aJsonResponse. Appscan register their own handlers to customize the response body.
Layers
Design rules — locked in
(accepted schemes), metadata (description), and optionally dynamic logic
via
@gate.dynamic.Same hybrid on both axes — schemes (
["x402"]) and stablecoins(
["USDC"]). Strings cover ~90%.acceptand stablecoin lists arepreference order, not sets. The 402 challenge advertises in this order.
Gate,Price,Feeare Pydantic v2 modelswith
model_config = {"frozen": True}. Mutation raises. Validation atconstruction.
usd("0.10", "USDC", "USDT")means "$0.10 USD, settle in USDC or USDT." Merchants think fiat.
pick up for free if the core is pure middleware. No framework is
special — Flask is a first-class citizen, not a documentation
afterthought. Anything FastAPI does (DI lookup, predicate, accessor) must
work identically in Flask and Django.
require_paymentraises,is_paidreturns bool,get_paymentreturnsPayment | None. Mirrors Clearance's bang/predicate/accessor split,adapted to Python verb-prefix convention.
Apps
except PaymentRequiredto customize the response.sensible mainnet/devnet defaults — apps shouldn't have to look up
addresses.
one
amountplusfee_within/fee_on_topdict kwargs. Fixed amountsonly, MPP only. x402 auto-disabled on any gate with fees because stock
x402 facilitators settle to a single address.
Gate(...)constants in apricing.pyare the Python idiom. Thefrom app.pricing import REPORTline is the registry lookup.
detected and dispatched correctly. FastAPI handlers can be async; Flask
handlers are sync. The middleware lives once.
Decimal, neverfloat. All amount construction goes throughhelpers that reject
floatinputs. Floats are a known footgun formoney; refuse them.
Ed25519 signer, and fee-payer flag live together on
Operator.Gate-level
pay_toandX402Config.signerare escape hatches, notthe primary surface. Zero config boots on the in-memory demo signer
with a visible warning; mainnet refuses to start under the demo key.
None-as-default-marker on optional fields.Operator(recipient= os.environ.get("…"))leaves the field asNonewhen the env var isunset, and a Pydantic
model_validator(mode="after")resolvesNonesto the actual default. No
if ENV[...]guards in user code.Signerfactories usefrom_*for parsing constructors. Matchesdatetime.fromisoformat/Path.from_uriconvention.Signer.demo(),Signer.bytes(...),Signer.json(...),Signer.base58(...),Signer.from_env(...). Remote enclave signers (KMS, Vault) live underpay_kit.kms— separate sub-module to give async backends room togrow without crowding the local cases. KMS is future work, not
in v1.
Style — Python micro-rules
Gate,Payment,Priceareexported with
__all__.Literaltypes for enums.accept: list[Literal["x402", "mpp"]].Gives autocomplete in IDEs without runtime cost.
"USDC", not"usdc". Tickers, notvariable names.
"x402","mpp","x402_exact"."solana_devnet". Validatedwith a
Literalatconfiguretime.BaseModelwithfrozen=Truefor all value objects.from __future__ import annotationsat the top of every module — PEP563 deferred evaluation.
__init__.pyre-exports beyond the public API. Internal modulesuse leading underscore (
_resolver.py).Open questions
Things to decide before we cut code:
require_payment(REPORT)andrequire_payment(Gate(amount=usd(...)))work. Cleaner to support both — same call site, just different
arguments. Worth keeping the inline path?
@gate.dynamicvs callable in theGateconstructor. Decorator iscleaner;
Gate(amount=lambda req: ...)would also work. Decorator winson readability but adds an import. Vote needed.
configure()vsconfigure_from(Settings)vs Pydantic-only. Allthree are supported in the current sketch. If most users want
env-driven,
configure_fromcould be the only path. Cost is one extraimport; benefit is fewer ways to do the same thing.
and Django 4+ are async-capable; Flask 3 has async views; Django sync
views are common. Probably easiest to ship async middleware and let
sync shims block on it via
asgiref.sync.async_to_sync. Confirm.Gatebe hashable? Module-level constants used as dict keysin user code is plausible. Pydantic v2 frozen models are hashable by
default — keep that, document it.
What we are not building (yet)
solana-pay-kitstays library-only; CLI is a separate packageif needed.
If a feature request lands in one of these buckets, push back hard before
expanding scope.
Future work (post-v1)
Reserved here so the design has a place to grow without breaking changes:
pay_kit.kmsmodule — remote enclave signers.gcp,aws,vault. Async on.sign(network I/O); share theSignerprotocolcontract so call sites don't change. Module name is locked in now;
implementation lands after v1.
eur(...),gbp(...)slot into theexisting
usd(...)shape.HSMs) under
pay_kit.kms.*.