paygent-agent-sdk
Signing, KYA, and the MCP envelope for building a Paygent wallet agent — the primitives of the payment-agent contract, packaged so you do not have to vendor a reference implementation to get them right.
The contract itself is Paygent's payment-agent guide, which they give you at onboarding. This package implements it; it does not replace reading it.
This is not the platform's client SDK
Read this first, because the names are close enough to cost you a day.
| Your code | Direction | The package you want |
|---|---|---|
| calls Paygent | outbound | the platform's /v1 client SDK |
| is called by Paygent | inbound | this one |
This package is for the third party on the far end of the contract: you publish an agent card (§1), verify Paygent's inbound RFC 9421 signatures and sign your own responses (§2), speak §3's JSON-RPC envelope, run §7's KYA checks on a mandate and an approval proof, and answer the §4 tool calls out of your own ledger. The two packages are disjoint in direction and in dependency — neither is a superset of the other, and installing the wrong one gets you an API that faces the wrong way.
A bare § below is a section of that guide. paygent_agent.CONTRACT_VERSION names the contract
revision this package implements (1.1), not the version of any agent built on it.
Install
pip install paygent-agent-sdk
Dependencies are cryptography and pydantic, and deliberately nothing else. No FastAPI, no
uvicorn, no httpx: the envelope takes and returns bytes and dicts, so the web framework is yours to
pick. The constraint is asserted, not just intended: the package's own test suite imports every
module in an environment where the frameworks are unimportable.
Build a minimal agent
A conformant agent is two HTTP endpoints — POST /mcp and the card at
/.well-known/agent_card.json — and the SDK covers everything between the bytes arriving and the
bytes leaving. Four pieces follow: the tools, the endpoint, the card, and the check that you agree
with Paygent on the bytes.
The examples are Starlette for brevity; only the request and response objects are
framework-specific. Every upper-case name is yours to supply — MCP_URL is the endpoint URL you
publish on your card, KEY_ID and PRIVATE_KEY are your response-signing keypair, and
PAYGENT_PUBLIC_KEYS is the key id → public key mapping Paygent gives you at onboarding. So is
my_ledger: that is your own store, and the SDK never touches it.
The fragments below are excerpts from a complete, working agent. All ten §4 tools, idempotency,
the redemption lifecycle, and §4.11's approval flow are built out in
examples/minimal/
— roughly 650 lines, on Starlette, scoring 16 of 20 on the §8 harness with the remaining four
reported not-implemented rather than failed. Read it when a quickstart snippet stops being enough.
1. The tools you actually implement
Everything the guide says a tool does lives behind ToolRegistry. The envelope never sees your
balances, your stores, or your keys — which is what makes it reusable by an agent that shares no
code with the reference one.
from paygent_agent.envelope import InvalidToolCall
from paygent_agent.errors import ErrorCode, WalletError
class Wallet: # satisfies `envelope.ToolRegistry` structurally; no base class to inherit
def descriptors(self, *, sandbox: bool) -> list[dict]:
"""§3's `tools/list`. Advertise §4.10 if and only if `sandbox` is true."""
return [
{
"name": "wallet.loyalty_balance",
"description": "Report the user's spendable and reserved loyalty points.",
"inputSchema": {
"type": "object",
"properties": {"user_ref": {"type": "string"}},
"required": ["user_ref"],
},
}
]
def call(self, name, arguments) -> dict:
"""Run one tool. `name` and `arguments` arrive exactly as sent, untyped — validating them
is your job, not the envelope's."""
if name != "wallet.loyalty_balance" or not isinstance(arguments, dict):
# Unanswerable, not a money outcome: this becomes JSON-RPC `-32602`.
raise InvalidToolCall(name)
user_ref = arguments.get("user_ref")
if not isinstance(user_ref, str):
raise InvalidToolCall(name)
balance = my_ledger.loyalty_balance(user_ref)
if balance is None:
# A §6 domain failure. It leaves as a *result* with `isError: true` and HTTP 200 —
# never a JSON-RPC error, which §3 reserves for "the peer is unusable".
raise WalletError(ErrorCode.UNKNOWN_INSTRUMENT)
# The plain payload. The envelope applies `tool_result` for you on the way out — wrapping
# it here too would nest one result inside another.
return {
"user_ref": user_ref,
"available_points": balance.available,
"reserved_points": balance.reserved,
"currency": balance.currency,
}
A spend-affecting tool runs §7's mandate check before it touches the ledger:
from paygent_agent.kya import MandatedCall, verify_mandate
mandate = verify_mandate(
arguments.get("mandate"),
MandatedCall(user_ref=user_ref, points=points),
)
A refusal raises KyaRefusal, which is a WalletError carrying mandate_invalid or
approval_invalid. Any handler that already turns WalletError into a §6 result needs no new
branch; the reason attribute is the fine-grained detail, and it stays on your side of the wire —
§6 allows only the code itself out.
2. The endpoint
import anyio.to_thread
from starlette.responses import Response
from paygent_agent.envelope import MAX_REQUEST_BODY_BYTES, MCPEnvelope, read_capped_body
from paygent_agent.signing import SignatureError, sign_response, verify_request
envelope = MCPEnvelope(Wallet(), server_name="acme-wallet", sandbox=False)
def signed(answer):
"""Sign the exact bytes the envelope produced, and send them untouched.
Never re-encode a body after signing: the digest covers the bytes as they were, and a
re-serialisation is a response whose digest covers something else.
"""
headers = sign_response(
status=answer.status, body=answer.body, key_id=KEY_ID, private_key=PRIVATE_KEY
)
return Response(
answer.body,
status_code=answer.status,
media_type="application/json" if answer.is_json else None,
headers=headers,
)
async def mcp(request):
body = await read_capped_body(request.stream())
if body is None:
# Over `MAX_REQUEST_BODY_BYTES` (1 MiB), so it can never be verified. `unauthenticated`
# like any other unverifiable request — a distinct code would only tell an attacker where
# the cap sits.
return signed(envelope.unauthenticated())
try:
verify_request(
method=request.method,
# Your card's `url`, not a reconstructed request URI. Behind a TLS-terminating proxy
# the two differ, and only the published value is what Paygent could have signed.
target_uri=MCP_URL,
body=body,
headers={k.lower(): v for k, v in request.headers.items()},
known_keys=PAYGENT_PUBLIC_KEYS,
)
except SignatureError:
# §2: 401 with a *signed* body carrying `unauthenticated`. The reason stays in your logs —
# only the §6 code reaches the wire.
return signed(envelope.unauthenticated())
# Off the event loop: `dispatch` is blocking, and a registry that fsyncs a durable record
# would otherwise stall every other request in flight for the length of each call.
return signed(await anyio.to_thread.run_sync(envelope.dispatch, body))
Three things the envelope deliberately leaves to you:
- Verify before dispatching.
dispatchtrusts its input; §2 verification is the gate in front of it. - Sign everything you return, the 401s and the 500 included. Paygent verifies your responses, so an unsigned rejection is indistinguishable from an attacker's.
- Keep
dispatchoff the event loop. It is blocking by contract, so a registry that touches a durable store stalls everything else if it runs on the loop. The snippet usesanyio.to_thread.run_sync; outside ASGI, any worker thread will do. Once calls run in threads, your registry needs its own lock, asexamples/minimal/shows.
3. The card
Served at /.well-known/agent_card.json, and unsigned: it is the trust root, and its authenticity
rests on TLS to your origin (§7.4).
from paygent_agent.card import AgentCardSpec, build_agent_card
from paygent_agent.keys import public_key_to_base64
card = build_agent_card(
AgentCardSpec(
name="acme-wallet",
url=MCP_URL,
signing_keys={KEY_ID: public_key_to_base64(PUBLIC_KEY)},
sandbox=False,
)
)
signing_keys takes every key a verifier should accept, so a rotation can publish both halves at
once. The spec rejects anything that is not a base64 raw 32-byte Ed25519 key at construction,
rather than letting the typo surface as an unexplainable signature failure on a caller.
4. Prove you agree on the bytes
The published wire vectors ship with the package, so byte agreement with §2 and §7.3 is a test in your own suite rather than a cloned repo:
from paygent_agent.testing import replay_vectors
def test_wire_vectors():
failed = [result for result in replay_vectors() if not result.ok]
assert not failed, failed
replay_vectors() re-derives every published value with this package. If your signing or hashing
is your own rather than the SDK's, load_vector() and vector_bytes() hand back the published
document and its raw bytes to diff against instead.
That is a unit check, not conformance. The §8 conformance harness is what says an agent is
conformant; it speaks only HTTP to a --base-url, so it runs against any agent in any language.
Modules
In guide order, so the table doubles as a reading order for the contract itself.
| Module | § | What it covers |
|---|---|---|
paygent_agent |
— | CONTRACT_VERSION and PROTOCOL_VERSION, and nothing else — importing the root pulls in no submodule |
paygent_agent.card |
§1 | The agent card — AgentCardSpec, build_agent_card, CAPABILITY |
paygent_agent.keys |
§1 | Ed25519 encodings — raw 32-byte base64, in and out |
paygent_agent.signing |
§2 | Server half — verify_request, sign_response, the signature bases, Content-Digest |
paygent_agent.signing_client |
§2 | Client half — RequestSigner, verify_response, for integration tests and for verifying Paygent's responses |
paygent_agent.envelope |
§3 | JSON-RPC framing, initialize / tools/list / tools/call dispatch, the capped body read, the §6 → JSON-RPC boundary |
paygent_agent.errors |
§6 | The error vocabulary — ErrorCode, WalletError, tool_result, tool_error |
paygent_agent.kya |
§7 | verify_mandate, items_hash, sign_approval, verify_approval |
paygent_agent.canonical |
§7.3 | RFC 8785 / JCS canonical JSON |
paygent_agent.testing |
— | The published vectors as package data, plus replay_vectors() |
Every module states its surface in __all__, and py.typed ships, so a type checker sees real
types rather than Any.
Bind your own approval domain
sign_approval and verify_approval prefix the bytes they sign with a domain separator, which
defaults to APPROVAL_SIGNATURE_CONTEXT — the string "paygent-wallet-agent/approval/v1".
§7.3 makes the approval scheme vendor-internal: the value never leaves your process, and Paygent does not verify it. So the default crosses no trust boundary and interoperates with nothing — but it does name the reference implementation's repository rather than your service. Pass your own instead, and pass the same one to both calls:
from paygent_agent.kya import sign_approval, verify_approval
APPROVAL_CONTEXT = "acme-wallet/approval/v1"
proof = sign_approval(APPROVAL_KEY, approval, context=APPROVAL_CONTEXT)
verify_approval(
proof,
checkout_ref=checkout_ref,
user_ref=user_ref,
public_key=APPROVAL_PUBLIC_KEY,
context=APPROVAL_CONTEXT,
)
Choose the string before you mint anything in production. Changing it later invalidates every
approval signed under the old one, and the only symptom is approval_invalid on a proof that
verified yesterday. Pin it with a test asserting the exact literal, the way this repo pins its own.
Versioning
The version tracks the contract, not any agent: paygent_agent.CONTRACT_VERSION is "1.1" and
the distribution is 1.1.x. A patch is a fix in this package; a minor is a contract minor. Contract
1.x is frozen in the sense that matters — existing shapes, error codes, and verification rules
will not change under it, and 1.1 is additive over 1.0.
Provenance
This package is extracted from
paygent-wallet-agent, a clean-room
reference implementation built from the guide alone. Every surface here is argued from
docs/PAYMENT-AGENT-GUIDE.md; the reference agent dogfoods the package and gates its releases
against the §8 conformance harness, so the SDK and a conformant agent are never checked apart.
Metadata
Release files for paygent-agent-sdk 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| paygent_agent_sdk-1.1.0.tar.gz | 51.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| paygent_agent_sdk-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 95.3 kB
Release files / paygent_agent_sdk-1.1.0.tar.gz
| Download URL | paygent_agent_sdk-1.1.0.tar.gz |
|---|---|
| Size | 51.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cac0b0383a1e8fdca080f223ddf58cabdeaf42473dd1f17cebc76515ae876bf2
|
|
BLAKE2b-256 checksum How to use checksums |
a1b53ccc17edf993cd002b9b67f55d1a173292873d8d6832d8211d58133e2d31
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Release files / paygent_agent_sdk-1.1.0-py3-none-any.whl
| Download URL | paygent_agent_sdk-1.1.0-py3-none-any.whl |
|---|---|
| Size | 43.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c02b4e9624e86356bfd6ad7dc612d8b63785b894d040e7b6b4bf7f1433104a35
|
|
BLAKE2b-256 checksum How to use checksums |
464b4e0464e08a8e9e6cd47c3b15d704315f56bdc73d90d1172633baafc0beb2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|