Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2192c58
feat(python): add payment-identifier extension types and constants
apmcdermott Feb 6, 2026
5800089
feat(python): add payment-identifier utility functions
apmcdermott Feb 6, 2026
5abab71
feat(python): add payment-identifier JSON Schema definition
apmcdermott Feb 6, 2026
ca173d4
feat(python): add payment-identifier validation and extraction functions
apmcdermott Feb 6, 2026
c422e3b
feat(python): add payment-identifier client utilities
apmcdermott Feb 6, 2026
eb0f3c5
feat(python): add payment-identifier resource server utilities
apmcdermott Feb 6, 2026
7a5d976
feat(python): add payment-identifier extension main module
apmcdermott Feb 6, 2026
d28cfd2
feat(python): export payment-identifier extension from extensions module
apmcdermott Feb 6, 2026
cb5bcc6
fix(python): resolve ValidationResult type mismatch in extensions module
apmcdermott Feb 6, 2026
30607c5
refactor(python): rename payment_identifier ValidationResult to Payme…
apmcdermott Feb 6, 2026
006e154
test(python): add comprehensive test suite for payment_identifier ext…
apmcdermott Feb 6, 2026
4b2b2d6
fix(python): fix payment_identifier test helper and assertions
apmcdermott Feb 6, 2026
4a286a8
fix(python): add ValidationResult alias for backward compatibility
apmcdermott Feb 6, 2026
bb15165
test(python): add test to verify __all__ exports are importable
apmcdermott Feb 6, 2026
d724da7
fix(python): make jsonschema import lazy in payment_identifier valida…
apmcdermott Feb 6, 2026
0254580
fix(python): fix linting errors
apmcdermott Feb 6, 2026
10e7c66
Lint
apmcdermott Feb 6, 2026
be35184
docs(python): add payment-identifier extension examples
apmcdermott Feb 6, 2026
42990fd
Merge branch 'main' into feat/payment-identifier-python
apmcdermott Feb 9, 2026
4276c6c
Lint
apmcdermott Feb 9, 2026
e2ed678
Format
apmcdermott Feb 9, 2026
73cf02f
Changelog
apmcdermott Feb 11, 2026
ffb3e63
Merge branch 'main' into feat/payment-identifier-python
apmcdermott Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions e2e/clients/httpx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
exit(1)

if not evm_private_key and not svm_private_key:
error_result = {"success": False, "error": "At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY must be set"}
error_result = {
"success": False,
"error": "At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY must be set",
}
print(json.dumps(error_result))
exit(1)

Expand Down Expand Up @@ -76,7 +79,9 @@ async def main():
}

# Check for payment response header (V2: PAYMENT-RESPONSE, V1: X-PAYMENT-RESPONSE)
payment_header = response.headers.get("PAYMENT-RESPONSE") or response.headers.get("X-PAYMENT-RESPONSE")
payment_header = response.headers.get(
"PAYMENT-RESPONSE"
) or response.headers.get("X-PAYMENT-RESPONSE")
if payment_header:
payment_response = decode_payment_response_header(payment_header)
result["payment_response"] = payment_response.model_dump()
Expand Down
10 changes: 7 additions & 3 deletions e2e/clients/requests/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from x402.mechanisms.evm.exact import register_exact_evm_client
from x402.mechanisms.svm import KeypairSigner
from x402.mechanisms.svm.exact import register_exact_svm_client
import requests

# Load environment variables
load_dotenv()
Expand All @@ -30,7 +29,10 @@
exit(1)

if not evm_private_key and not svm_private_key:
error_result = {"success": False, "error": "At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY must be set"}
error_result = {
"success": False,
"error": "At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY must be set",
}
print(json.dumps(error_result))
exit(1)

Expand Down Expand Up @@ -70,7 +72,9 @@ def main():
}

# Check for payment response header (V2: PAYMENT-RESPONSE, V1: X-PAYMENT-RESPONSE)
payment_header = response.headers.get("PAYMENT-RESPONSE") or response.headers.get("X-PAYMENT-RESPONSE")
payment_header = response.headers.get(
"PAYMENT-RESPONSE"
) or response.headers.get("X-PAYMENT-RESPONSE")
if payment_header:
payment_response = decode_payment_response_header(payment_header)
result["payment_response"] = payment_response.model_dump()
Expand Down
8 changes: 2 additions & 6 deletions e2e/facilitators/python/bazaar.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ def to_dict(self) -> dict[str, Any]:


class BazaarCatalog:
"""Catalog for storing discovered x402 resources.
"""
"""Catalog for storing discovered x402 resources."""

def __init__(self) -> None:
self._resources: dict[str, DiscoveredResource] = {}
Expand Down Expand Up @@ -79,9 +78,7 @@ def catalog_resource(
metadata={},
)

def get_resources(
self, limit: int = 100, offset: int = 0
) -> dict[str, Any]:
def get_resources(self, limit: int = 100, offset: int = 0) -> dict[str, Any]:
"""Get paginated list of discovered resources.

Args:
Expand All @@ -108,4 +105,3 @@ def get_resources(
def get_count(self) -> int:
"""Get total count of discovered resources."""
return len(self._resources)

3 changes: 1 addition & 2 deletions e2e/facilitators/python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

import os
import sys
from datetime import datetime
from typing import Any

from dotenv import load_dotenv
Expand All @@ -23,7 +22,7 @@
from solders.keypair import Keypair

from x402 import x402Facilitator
from x402.extensions.bazaar import DiscoveredResource, extract_discovery_info
from x402.extensions.bazaar import extract_discovery_info
from x402.mechanisms.evm import FacilitatorWeb3Signer
from x402.mechanisms.evm.exact import register_exact_evm_facilitator
from x402.mechanisms.svm import FacilitatorKeypairSigner
Expand Down
3 changes: 1 addition & 2 deletions e2e/legacy/servers/fastapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
import signal
import sys
import asyncio
from typing import Any, Dict, Optional
from typing import Any, Dict

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from x402.fastapi.middleware import require_payment
from x402.types import EIP712Domain, TokenAmount, TokenAsset
from x402.chains import (
Expand Down
2 changes: 1 addition & 1 deletion e2e/legacy/servers/flask/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import signal
import sys
import logging
from flask import Flask, jsonify, request
from flask import Flask, jsonify
from dotenv import load_dotenv
from x402.flask.middleware import PaymentMiddleware

Expand Down
2 changes: 0 additions & 2 deletions e2e/servers/fastapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,12 @@

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse

# Import from new x402 package
from x402 import x402ResourceServer
from x402.http import FacilitatorConfig, HTTPFacilitatorClient
from x402.http.middleware.fastapi import payment_middleware
from x402.mechanisms.evm.exact import (
ExactEvmServerScheme,
register_exact_evm_server,
)
from x402.mechanisms.svm.exact import register_exact_svm_server
Expand Down
3 changes: 3 additions & 0 deletions examples/python/clients/payment-identifier/.env-local
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
EVM_PRIVATE_KEY=
RESOURCE_SERVER_URL=http://localhost:4022
ENDPOINT_PATH=/weather
126 changes: 126 additions & 0 deletions examples/python/clients/payment-identifier/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Payment-Identifier Extension Client Example

Example client demonstrating how to use the `payment-identifier` extension to enable **idempotency** when making payments.

## How It Works

1. Client generates a unique payment ID using `generate_payment_id()`
2. Client includes the payment ID in the `PaymentPayload` using `append_payment_identifier_to_extensions()`
3. Server caches responses keyed by payment ID
4. Retry requests with the same payment ID return cached responses without re-processing payment

```python
from x402 import x402Client
from x402.extensions.payment_identifier import (
append_payment_identifier_to_extensions,
generate_payment_id,
)
from x402.http.clients import x402HttpxClient

client = x402Client()
# ... register schemes ...

# Generate a unique payment ID for this logical request
payment_id = generate_payment_id()

# Hook into payment flow to add the payment ID before payload creation
async def before_payment_creation(context):
extensions = context.payment_required.extensions
if extensions is not None:
append_payment_identifier_to_extensions(extensions, payment_id)

client.on_before_payment_creation(before_payment_creation)

async with x402HttpxClient(client) as http:
# First request - payment is processed
response1 = await http.get(url)

# Retry with same payment ID - cached response returned (no payment)
response2 = await http.get(url)
```

## Prerequisites

- Python 3.10+
- uv (install via [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/))
- A running payment-identifier server (see [payment-identifier server example](../../servers/payment-identifier))
- Valid EVM private key for making payments (Base Sepolia with USDC)

## Setup

1. Install dependencies:

```bash
uv sync
```

2. Copy `.env-local` to `.env` and add your private key:

```bash
cp .env-local .env
```

Required environment variable:

- `EVM_PRIVATE_KEY` - Ethereum private key for EVM payments

3. Start the payment-identifier server (in another terminal):

```bash
cd ../../servers/payment-identifier
uv run python main.py
```

4. Run the client:

```bash
uv run python main.py
```

## Expected Output

```
Generated Payment ID: pay_7d5d747be160e280504c099d984bcfe0

====================================================
First Request (with payment ID: pay_7d5d747be160e280504c099d984bcfe0)
====================================================
Making request to: http://localhost:4022/weather

Response (1523ms): {"report": {"weather": "sunny", "temperature": 70, "cached": false}}

Payment settled on eip155:84532

====================================================
Second Request (SAME payment ID: pay_7d5d747be160e280504c099d984bcfe0)
====================================================
Making request to: http://localhost:4022/weather

Expected: Server returns cached response without payment processing

Response (45ms): {"report": {"weather": "sunny", "temperature": 70, "cached": true}}

No payment processed - response served from cache!

====================================================
Summary
====================================================
Payment ID: pay_7d5d747be160e280504c099d984bcfe0
First request: 1523ms (payment processed)
Second request: 45ms (cached)
Cached response was 97% faster!
```

## Use Cases

- **Network failures**: Safely retry failed requests without duplicate payments
- **Client crashes**: Resume requests after restart using persisted payment IDs
- **Load balancing**: Same request can hit different servers with shared cache
- **Testing**: Replay requests during development without spending funds

## Best Practices

1. **Generate payment IDs at the logical request level**, not per retry
2. **Persist payment IDs** for long-running operations so they survive restarts
3. **Use descriptive prefixes** (e.g., `order_`, `sub_`) to identify payment types
4. **Don't reuse payment IDs** across different logical requests
131 changes: 131 additions & 0 deletions examples/python/clients/payment-identifier/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Payment-Identifier Extension Client Example.

Demonstrates how to use the payment-identifier extension to enable idempotency
when making payments. This allows safe retries without duplicate payments.

This example:
1. Makes a request with a unique payment ID
2. Makes a second request with the SAME payment ID
3. The second request returns from cache without payment processing

Required environment variables:
- EVM_PRIVATE_KEY: The private key of the EVM signer
"""

import asyncio
import os
import sys
import time

from dotenv import load_dotenv
from eth_account import Account

from x402 import x402Client
from x402.extensions.payment_identifier import (
append_payment_identifier_to_extensions,
generate_payment_id,
)
from x402.http import x402HTTPClient
from x402.http.clients import x402HttpxClient
from x402.mechanisms.evm import EthAccountSigner
from x402.mechanisms.evm.exact.register import register_exact_evm_client
from x402.schemas import PaymentCreationContext

load_dotenv()


async def main() -> None:
"""Main entry point demonstrating payment-identifier extension for idempotency."""
# Validate environment
private_key = os.getenv("EVM_PRIVATE_KEY")
if not private_key:
print("Error: EVM_PRIVATE_KEY environment variable is required")
sys.exit(1)

base_url = os.getenv("RESOURCE_SERVER_URL", "http://localhost:4022")
endpoint_path = os.getenv("ENDPOINT_PATH", "/weather")
url = f"{base_url}{endpoint_path}"

# Create x402 client
account = Account.from_key(private_key)
client = x402Client()
register_exact_evm_client(client, EthAccountSigner(account))

# Generate a unique payment ID for this request
payment_id = generate_payment_id()
print(f"\nGenerated Payment ID: {payment_id}")

# Hook into the payment flow to add payment identifier BEFORE payload creation
# We modify paymentRequired.extensions to include our payment ID
async def before_payment_creation(context: PaymentCreationContext) -> None:
extensions = context.payment_required.extensions
if extensions is not None:
# Append our payment ID to the extensions (only if server declared the extension)
append_payment_identifier_to_extensions(extensions, payment_id)

client.on_before_payment_creation(before_payment_creation)

# Create HTTP client helper for payment response extraction
http_client = x402HTTPClient(client)

# First request - will process payment
print("\n" + "=" * 52)
print(f"First Request (with payment ID: {payment_id})")
print("=" * 52)
print(f"Making request to: {url}\n")

async with x402HttpxClient(client) as http:
start_time1 = time.time()
response1 = await http.get(url)
await response1.aread()
duration1 = int((time.time() - start_time1) * 1000)

print(f"Response ({duration1}ms): {response1.text}")

if response1.is_success:
try:
settle_response = http_client.get_payment_settle_response(
lambda name: response1.headers.get(name)
)
print(f"\nPayment settled on {settle_response.network}")
except ValueError:
pass

# Second request - same payment ID, should return from cache
print("\n" + "=" * 52)
print(f"Second Request (SAME payment ID: {payment_id})")
print("=" * 52)
print(f"Making request to: {url}\n")
print("Expected: Server returns cached response without payment processing\n")

start_time2 = time.time()
response2 = await http.get(url)
await response2.aread()
duration2 = int((time.time() - start_time2) * 1000)

print(f"Response ({duration2}ms): {response2.text}")

if response2.is_success:
try:
settle_response = http_client.get_payment_settle_response(
lambda name: response2.headers.get(name)
)
print("\nPayment settled (unexpected - should have been cached)")
except ValueError:
print("\nNo payment processed - response served from cache!")

# Summary
print("\n" + "=" * 52)
print("Summary")
print("=" * 52)
print(f" Payment ID: {payment_id}")
print(f" First request: {duration1}ms (payment processed)")
print(f" Second request: {duration2}ms (cached)")
if duration2 < duration1 and duration1 > 0:
speedup = round((1 - duration2 / duration1) * 100)
print(f" Cached response was {speedup}% faster!")
print()


if __name__ == "__main__":
asyncio.run(main())
Loading