Skip to main content
Version: Next

Agent Payment Protocol

The Agent Payment Protocol standardizes how two agents negotiate and finalize a payment. It defines message types, valid interaction paths, and clear roles for a buyer and a seller. This guide explains the models, interaction rules, and shows working examples for both roles.

Understanding the Payment Protocol

1. Core Models

Funds

class Funds(Model):
# amount of funds
amount: str

# currency of the funds (USDC, FET, etc.)
currency: str

# payment method (skyfire, fet_direct, stripe, …)
payment_method: str = "fet_direct"
  • amount: amount expressed as a string (supports arbitrary formats/precisions)
  • currency: the asset or token symbol
  • payment_method: payment rail/method identifier (conventional string; not an enum)

Payment methods

The payment protocol is payment-rail agnostic. Use Funds.payment_method to describe how the buyer will pay.

Common values (conventional strings — library comments may list a subset such as skyfire / fet_direct):

  • stripe: card payment via Stripe Checkout (recommended for fiat). In practice, CommitPayment.transaction_id is usually set to the Stripe Checkout Session ID (e.g. cs_test_...) so the seller can verify payment status via Stripe.
  • skyfire: USDC payment via Skyfire.
  • fet_direct: direct on-chain FET transfer.

You can also include rail-specific UI/verification hints via RequestPayment.metadata.

For example, to support Stripe embedded Checkout experiences, you can include a stripe object in metadata:

{
"stripe": {
"ui_mode": "embedded",
"publishable_key": "pk_test_...",
"client_secret": "cs_test_...",
"checkout_session_id": "cs_test_...",
"currency": "usd",
"amount_cents": 100
}
}
Protocol-only demo vs real rails

The seller/buyer scripts below are a protocol-only demo (payment_method="skyfire" + a fake transaction_id). They intentionally omit rail metadata. For a real Stripe path, see Stripe Horoscope Agent (Payment Protocol).

RequestPayment

class RequestPayment(Model):
# list of Funds options the counterparty may pay with
accepted_funds: list[Funds]

# recipient address or identifier
recipient: str

# deadline for the payment request in seconds
deadline_seconds: int

# optional reference for the payment
reference: str | None = None

# optional description of the payment
description: str | None = None

# optional metadata for the payment
metadata: dict[str, str | dict[str, str]] | None = None
  • accepted_funds: list of Funds options the requester accepts for payment (the invoice’s accepted rails)
  • recipient: where funds should be sent (address, handle, etc.)
  • deadline_seconds: validity window of the request
  • Optional: reference, description, metadata

RejectPayment

class RejectPayment(Model):
# optional reason for rejecting the payment
reason: str | None = None
  • Sent by the buyer to decline a RequestPayment

CommitPayment

class CommitPayment(Model):
# funds to be paid
funds: Funds

# recipient address or identifier
recipient: str

# unique transaction token or hash
transaction_id: str

# optional reference for the payment
reference: str | None = None

# optional description of the payment
description: str | None = None

# optional metadata for the payment
metadata: dict[str, str | dict[str, str]] | None = None
  • Buyer’s commitment response: the exact funds chosen and a transaction_id to track the payment

CancelPayment

class CancelPayment(Model):
# unique transaction token or hash
transaction_id: str | None = None

# optional reason for canceling the payment
reason: str | None = None
  • Seller may cancel after a commit (instead of completing). Prefer echoing CommitPayment.transaction_id when known; None is allowed when no transaction was recorded yet.

CompletePayment

class CompletePayment(Model):
# unique transaction token or hash
transaction_id: str | None = None
  • Seller confirms the payment completed (typically after verifying the commit). Prefer echoing CommitPayment.transaction_id; None is allowed only when there is no transaction to reference.

Glossary (who invoices vs who pays)

MessageMeaningTypical sender
RequestPaymentInvoice / request to be paidSeller
CommitPaymentPayer commits funds (with transaction_id)Buyer
RejectPaymentPayer declines the requestBuyer
CompletePaymentMerchant confirms completionSeller
CancelPaymentMerchant cancels after commitSeller

2. Protocol Specification

payment_protocol_spec = ProtocolSpecification(
name="AgentPaymentProtocol",
version="0.1.0",
interactions={
RequestPayment: {CommitPayment, RejectPayment},
CommitPayment: {CompletePayment, CancelPayment},
CompletePayment: set(),
CancelPayment: set(),
RejectPayment: set(),
},
roles={
# Messages each role implements handlers for (inbound)
"seller": {CommitPayment, RejectPayment},
"buyer": {RequestPayment, CancelPayment, CompletePayment},
},
)
  • Interactions: valid next messages per message type
  • Roles: which inbound message types each role must handle (not a list of messages the role is limited to send)

Verify the installed protocol version:

python -c "from uagents_core.contrib.protocols.payment import payment_protocol_spec; print(payment_protocol_spec.name, payment_protocol_spec.version)"

Expected: AgentPaymentProtocol 0.1.0.

3. Basic Payment Flow

  1. Seller sends RequestPayment to Buyer
  2. Buyer responds with either CommitPayment or RejectPayment
  3. If committed, Seller sends CompletePayment (or CancelPayment if aborting after commit)

Using the Payment Protocol

Prerequisites

Agentverse-hosted

  • Packages are provided by the platform; no local pip install required.

Local

  • Python 3.10+
  • pip install "uagents>=0.22.3" (pulls compatible uagents_core), or pip install uagents uagents-core if needed
  • Payment protocol version 0.1.0 (see verify command above)

Import the models and specification from uagents_core.contrib.protocols.payment:

from uagents_core.contrib.protocols.payment import (
Funds,
RequestPayment,
RejectPayment,
CommitPayment,
CancelPayment,
CompletePayment,
payment_protocol_spec,
)
Locked-spec errors

Because the specification defines roles, instantiate the protocol with role="buyer" or role="seller" so it matches the inbound handlers you implement. A locked-spec error usually means you registered a handler (or interaction) that your role= does not include — for example, a role="seller" agent must handle CommitPayment / RejectPayment, while a role="buyer" agent must handle RequestPayment / CompletePayment / CancelPayment.

Example Agents

Below are two minimal agents demonstrating the Buyer and Seller roles. These are local-runnable once you replace the buyer address placeholder.

Seller Agent

seller.py
import os
from uuid import uuid4
from uagents import Agent, Protocol, Context

from uagents_core.contrib.protocols.payment import (
Funds,
RequestPayment,
CommitPayment,
RejectPayment,
CancelPayment,
CompletePayment,
payment_protocol_spec,
)

SELLER_PORT = int(os.getenv("SELLER_PORT", "8091"))
# REQUIRED: paste Buyer address from its startup logs before starting Seller
BUYER_ADDRESS = "PASTE_BUYER_ADDRESS_HERE"

seller = Agent(
name="demo_seller",
port=SELLER_PORT,
endpoint=[f"http://localhost:{SELLER_PORT}/submit"],
seed="demo_seller_seed",
)

payment_proto = Protocol(spec=payment_protocol_spec, role="seller")


@seller.on_event("startup")
async def startup(ctx: Context):
ctx.logger.info(f"Seller address: {ctx.agent.address}")

if BUYER_ADDRESS.startswith("PASTE_") or not BUYER_ADDRESS.startswith("agent"):
ctx.logger.error("Set BUYER_ADDRESS to the Buyer's real address before running Seller")
return

# Protocol-only demo: no Stripe/Skyfire metadata. Real rails verify before CompletePayment.
# Optional Stripe-shaped metadata (commented):
# metadata={"stripe": {"ui_mode": "embedded", "checkout_session_id": "cs_test_..."}}
req = RequestPayment(
accepted_funds=[Funds(currency="USDC", amount="0.001", payment_method="skyfire")],
recipient=ctx.agent.address,
deadline_seconds=300,
reference=str(uuid4()),
description="demo payment",
metadata={},
)
ctx.logger.info(f"Seller sending RequestPayment to {BUYER_ADDRESS}: {req}")
await ctx.send(BUYER_ADDRESS, req)


@payment_proto.on_message(CommitPayment)
async def on_commit(ctx: Context, sender: str, msg: CommitPayment):
ctx.logger.info(f"Seller received CommitPayment: {msg}")
# Demo only — real integrations must verify Stripe/Skyfire/on-chain payment first.
# To abort instead: await ctx.send(sender, CancelPayment(transaction_id=msg.transaction_id, reason="demo cancel"))
await ctx.send(sender, CompletePayment(transaction_id=msg.transaction_id))
ctx.logger.info("Seller sent CompletePayment")


@payment_proto.on_message(RejectPayment)
async def on_reject(ctx: Context, sender: str, msg: RejectPayment):
ctx.logger.info(f"Buyer rejected: {msg.reason}")


seller.include(payment_proto, publish_manifest=True)

if __name__ == "__main__":
seller.run()

Buyer Agent

buyer.py
import os
from uagents import Agent, Protocol, Context

from uagents_core.contrib.protocols.payment import (
Funds,
RequestPayment,
CommitPayment,
RejectPayment,
CompletePayment,
CancelPayment,
payment_protocol_spec,
)

BUYER_PORT = int(os.getenv("BUYER_PORT", "8092"))
BUYER_MODE = os.getenv("BUYER_MODE", "commit").lower() # commit | reject

buyer = Agent(
name="demo_buyer",
port=BUYER_PORT,
endpoint=[f"http://localhost:{BUYER_PORT}/submit"],
seed="demo_buyer_seed",
)

payment_proto = Protocol(spec=payment_protocol_spec, role="buyer")


@buyer.on_event("startup")
async def startup(ctx: Context):
ctx.logger.info(f"Buyer address: {ctx.agent.address}")


@payment_proto.on_message(RequestPayment)
async def on_request(ctx: Context, sender: str, msg: RequestPayment):
ctx.logger.info(f"Buyer received RequestPayment: {msg}")

if not msg.accepted_funds:
await ctx.send(sender, RejectPayment(reason="no accepted funds provided"))
return

selected = msg.accepted_funds[0]

if BUYER_MODE == "reject":
await ctx.send(sender, RejectPayment(reason="demo reject"))
return

# Demo only: hardcoded transaction_id — no real Stripe/Skyfire/on-chain verification occurs.
commit = CommitPayment(
funds=Funds(
currency=selected.currency,
amount=selected.amount,
payment_method=selected.payment_method,
),
recipient=msg.recipient,
transaction_id="demo-txn-001",
reference=msg.reference,
description=msg.description,
metadata=msg.metadata or {},
)
await ctx.send(sender, commit)
ctx.logger.info("Buyer sent CommitPayment")


@payment_proto.on_message(CompletePayment)
async def on_complete(ctx: Context, sender: str, msg: CompletePayment):
ctx.logger.info(f"Buyer received CompletePayment: {msg}")


@payment_proto.on_message(CancelPayment)
async def on_cancel(ctx: Context, sender: str, msg: CancelPayment):
# Seller may send CancelPayment instead of CompletePayment after a commit
ctx.logger.info(f"Buyer received CancelPayment: {msg}")


buyer.include(payment_proto, publish_manifest=True)

if __name__ == "__main__":
buyer.run()

Running the Agents

Hosted (Agentverse)

  1. Create two hosted agents (Buyer and Seller). See Hosted Agents.
  2. Paste each script into the matching editor. For hosted agents, omit the if __name__ == "__main__": … .run() block and you can simplify Agent(...) to Agent() (Agentverse provides networking).
  3. Start Buyer first, copy its address from logs into Seller’s BUYER_ADDRESS.
  4. Start Seller. Seller sends RequestPayment on startup, so Buyer must already be running.

Local

Defaults: Seller port 8091, Buyer port 8092 (override with SELLER_PORT / BUYER_PORT).

# Seller — already shown in seller.py
seller = Agent(
name="demo_seller",
port=SELLER_PORT,
endpoint=[f"http://localhost:{SELLER_PORT}/submit"],
seed="demo_seller_seed",
)

# Buyer — already shown in buyer.py
buyer = Agent(
name="demo_buyer",
port=BUYER_PORT,
endpoint=[f"http://localhost:{BUYER_PORT}/submit"],
seed="demo_buyer_seed",
)

Seller will not work until BUYER_ADDRESS is replaced with the Buyer’s real address.

  1. Start the Buyer first and copy its address from logs (Buyer address: agent1q...).
  2. Paste that address into BUYER_ADDRESS in seller.py.
  3. Start the Seller.

You should see the Seller send a RequestPayment, the Buyer respond with a CommitPayment, and the Seller send a CompletePayment.

Prefer importing from uagents_core.contrib.protocols.payment as shown above. Vendoring (copying) the models/spec into your repo is only needed if you must pin or customize the protocol offline; otherwise depend on uagents_core so you stay aligned with published version 0.1.0.

Expected Output

When both agents are running, you should see logs similar to the following.

Buyer logs

INFO:     [demo_buyer]: Starting agent with address: agent1q...
INFO: [demo_buyer]: Agent inspector available at https://agentverse.ai/inspect/?uri=http%3A//127.0.0.1%3A8092&address=agent1q...
INFO: [demo_buyer]: Starting server on http://0.0.0.0:8092 (Press CTRL+C to quit)
INFO: [demo_buyer]: Buyer address: agent1q...
INFO: [demo_buyer]: Buyer received RequestPayment: accepted_funds=[Funds(amount='0.001', currency='USDC', payment_method='skyfire')] recipient='agent1q...' deadline_seconds=300 reference='...' description='demo payment' metadata={}
INFO: [demo_buyer]: Buyer sent CommitPayment
INFO: [demo_buyer]: Buyer received CompletePayment: transaction_id='demo-txn-001'

Seller logs

INFO:     [demo_seller]: Starting agent with address: agent1q...
INFO: [demo_seller]: Seller address: agent1q...
INFO: [demo_seller]: Seller sending RequestPayment to agent1q...: accepted_funds=[Funds(amount='0.001', currency='USDC', payment_method='skyfire')] recipient='agent1q...' deadline_seconds=300 reference='...' description='demo payment' metadata={}
INFO: [demo_seller]: Agent inspector available at https://agentverse.ai/inspect/?uri=http%3A//127.0.0.1%3A8091&address=agent1q...
INFO: [demo_seller]: Starting server on http://0.0.0.0:8091 (Press CTRL+C to quit)
INFO: [demo_seller]: Seller received CommitPayment: funds=Funds(amount='0.001', currency='USDC', payment_method='skyfire') recipient='agent1q...' transaction_id='demo-txn-001' reference='...' description='demo payment' metadata={}
INFO: [demo_seller]: Seller sent CompletePayment

Success checklist

  • Buyer received RequestPayment
  • Buyer sent CommitPayment
  • Seller sent CompletePayment
  • Buyer received CompletePayment

Troubleshooting

  • Seller errors about buyer address / no reply → Buyer is not running, or BUYER_ADDRESS still starts with PASTE_.
  • NameError: seller / buyer → Ensure each script constructs seller = Agent(...) / buyer = Agent(...) before decorators.
  • Locked-spec errors → Set role="seller" or role="buyer" to match the inbound handlers you implement (see note above).
  • Missing Agent() / wrong ports → Wire SELLER_PORT / BUYER_PORT into Agent(port=..., endpoint=[...]) as in the samples.
  • Import / version failures → Install prerequisites and verify payment_protocol_spec.version is 0.1.0.

Conclusion

The Payment Protocol provides a clear, role-driven workflow for negotiating and finalizing payments between agents. By defining message types, valid interactions, and explicit roles, it helps you implement predictable flows: the seller initiates with RequestPayment, the buyer either sends CommitPayment or RejectPayment, and the seller completes or cancels as appropriate.

  • Always instantiate your protocol with the correct role so inbound handlers match the role’s required messages.
  • Use the examples to bootstrap local testing, then adapt handlers to perform real transfers, persistence, retries, and idempotency.
  • For complementary patterns, see the Agent Chat Protocol and the agent setup notes in Agent Creation.

With this foundation, you can extend validation, add signatures or receipts, and integrate with on-chain or off-chain payment rails as needed.