Menu

Explorer & Settings

Tempo Explorer Submit Project
Back to all projects

X402-Payment

by cherry-ff · Updated 4 months ago

In the AI payments ecosystem

X402-Payment is an early-stage Go project in the AI payments / x402 ecosystem. It currently has 1 GitHub stars and 0 forks.

README.md View on GitHub →

X402-Payment

Phase 1 skeleton for a three-party x402 payment platform:

  • buyer/ keeps the Python demo client and approval boundary
  • cmd/platformd hosts the Go platform entrypoint
  • internal/control serves /platform/*
  • internal/gateway serves /relay/*
  • internal/x402 is the only package that should talk to the official Go x402 SDK

Current state

This repository now contains:

  • Go package boundaries for control, gateway, registry, provider, ledger, audit, and x402
  • a first MySQL schema migration using the agreed Phase 1 constraints
  • an embedded migration runner for MySQL startup bootstrap
  • a Python buyer demo with explicit 402 -> approve -> sign -> retry flow
  • signed-request result recovery via payment_id + request_digest polling
  • support for read_only, idempotent_write, and effectful_write with the approved execution-mode matrix
  • real x402 challenge / verify / settle wiring
  • synchronous relay audit + ledger hooks
  • a dev-mode in-memory audit + ledger path when MYSQL_DSN is unset
  • provider-facing control-plane read APIs for provider profile, manifests, published routes, audit, and ledger
  • manifest publish / unpublish lifecycle for relay route activation and withdrawal
  • provider API key create/list/revoke/rotate management with read / write scopes
  • single-payment query and filtered ledger endpoints for reconciliation
  • a local reconcile CLI for unknown settlement inspection and manual recovery
  • a separate admin-token protected provider lifecycle path for suspend / disable / reactivate
  • optional provider registration token protection for POST /platform/providers
  • /healthz, /readyz, /readyz/control, and /readyz/relay system routes
  • validator and config unit tests

What is still intentionally stubbed:

  • richer control-plane query APIs beyond manifests, published routes, audit, and provider ledger

Useful control-plane endpoints

  • POST /platform/providers
  • GET /platform/providers/:providerID
  • POST /platform/providers/:providerID/api-key/rotate
  • GET /platform/providers/:providerID/api-keys
  • POST /platform/providers/:providerID/api-keys
  • DELETE /platform/providers/:providerID/api-keys/:keyID?reason=...
  • POST /platform/providers/:providerID/manifests
  • POST /platform/providers/:providerID/manifests/:interfaceID/publish
  • POST /platform/providers/:providerID/manifests/:interfaceID/unpublish
  • GET /platform/providers/:providerID/routes
  • GET /platform/providers/:providerID/ledger
  • GET /platform/providers/:providerID/ledger?settlement_status=unknown
  • GET /platform/providers/:providerID/ledger?payment_id=pay_123&interface_id=weather.v1.current&limit=50
  • GET /platform/providers/:providerID/ledger?requested_after=2026-03-15T00:00:00Z&requested_before=2026-03-16T00:00:00Z
  • GET /platform/providers/:providerID/payments/:paymentID
  • GET /platform/providers/:providerID/audit
  • GET /platform/providers/:providerID/audit?event_type=manifest_published&limit=50
  • GET /platform/providers/:providerID/audit?created_after=2026-03-15T00:00:00Z&created_before=2026-03-16T00:00:00Z
  • GET /platform/admin/providers/:providerID
  • GET /platform/admin/providers?status=suspended
  • GET /platform/admin/providers?status=active&limit=50
  • GET /platform/admin/providers?created_after=2026-03-15T00:00:00Z
  • PUT /platform/admin/providers/:providerID/status
  • GET /readyz/control
  • GET /readyz/relay

Useful relay recovery endpoint

  • GET /relay/payments/:paymentID/result?request_digest=...
  • if the payment is already settled and the cached response exists, the relay replays the original provider result
  • if the payment is still pending, the endpoint returns 202
  • if the payment is unknown, the endpoint returns 503
  • if the same payment_id is queried with a different request_digest, the endpoint returns 409
  • result cache retention now differs by interface type:
    • read_only -> ephemeral, TTL-controlled
    • idempotent_write -> ephemeral, longer TTL-controlled
    • effectful_write -> durable, no automatic expiry
  • once an ephemeral result is garbage-collected, the relay no longer rehydrates it; buyers should treat that as an expired paid result and start a fresh payment flow

Local commands

go test ./...
go run ./cmd/platformd
go run ./cmd/fakefacilitator
go run ./cmd/demo-upstream
go run ./cmd/reconcile list-unknown
go run ./cmd/reconcile list-expired-pending
go run ./cmd/reconcile expire-pending
go run ./cmd/resultgc -before 2026-03-15T00:00:00Z -limit 500
python -m unittest discover -s buyer/tests -p 'test_*.py'
python -m buyer GET http://localhost:8080/relay/providers/weather/v1/current
make test

Local demo flow

  1. Start the fake facilitator:
go run ./cmd/fakefacilitator
  1. Start the demo upstream:
go run ./cmd/demo-upstream
  1. Start the platform in local-dev mode so /relay/* can reach localhost:
DEV_ALLOW_PRIVATE_UPSTREAMS=true \
X402_FACILITATOR_URL=http://localhost:8081 \
go run ./cmd/platformd
  1. Register a demo provider, submit the sample manifest, and publish it:
python scripts/demo_setup.py

To try a real paid write flow instead of the weather read path:

python scripts/demo_setup.py --manifest examples/checkout_manifest.json
python -m buyer POST http://localhost:8080/relay/providers/checkout/v1/create --body '{"sku":"book_1","quantity":1}'

To try the first provider-managed effectful flow:

python scripts/demo_setup.py --manifest examples/charge_manifest.json
python -m buyer POST http://localhost:8080/relay/providers/checkout/v1/charge --body '{"order_id":"ord_demo_1","amount":"1000"}'

To simulate "provider completed work but the first response was lost", add:

python -m buyer POST http://localhost:8080/relay/providers/checkout/v1/charge --header X-Demo-Lose-Response=once --body '{"order_id":"ord_demo_1","amount":"1000"}'

For effectful_write + provider_managed, the manifest must declare extensions.provider_managed.status_target, the target must contain {payment_id}, and status_method must be GET. The provider status endpoint should return one of these envelopes:

{"state":"pending","operation_id":"op_pay_123"}
{
  "state": "succeeded",
  "operation_id": "op_pay_123",
  "result_http_status": 200,
  "result_headers": {"Content-Type": "application/json"},
  "result_body": {"ok": true, "charge_id": "chg_123"}
}
{"state":"failed","operation_id":"op_pay_123","error_detail":"provider rejected the charge"}

Malformed envelopes are rejected during recovery. In particular, operation_id is required for every state, failed requires error_detail, and succeeded requires both a 2xx result_http_status and result_body.

  1. Use the printed buyer_command, or run the buyer directly:
python -m buyer GET http://localhost:8080/relay/providers/weather/v1/current

If the signed request times out after payment submission, rerun the buyer with the same command only if it reuses the same signed payment, or query:

curl -H "X-X402-Recovery-Key: <sha256(PAYMENT-SIGNATURE)>" \
  "http://localhost:8080/relay/payments/<payment_id>/result?request_digest=<digest>"

The buyer now persists payment operations to ~/.x402-payment/buyer_operations.json by default. Successful paid requests print both operation_id and payment_id, and an interrupted agent can resume with:

python -m buyer --recover-operation <operation_id>

Use --state-file /path/to/ops.json if you want a different local state file. The buyer computes and sends the recovery key automatically during operation recovery.

For effectful_write + provider_managed, duplicate signed relay requests no longer re-drive the provider after settlement. Recovery flows through GET /relay/payments/<payment_id>/result and the provider status_target contract instead. In other words, provider-managed recovery is a status lookup contract for an already-submitted operation, not a platform-side re-execution path. The provider-managed recovery contract is now stricter: status_target must contain {payment_id}, status_method must be GET, and every valid status envelope must include operation_id. Recovered or failed settlements also persist provider_operation_id, so provider-facing control queries can surface the upstream operation handle during incident review.

Result retention is configured with:

RESULT_TTL_READ_ONLY_SECONDS=604800
RESULT_TTL_IDEMPOTENT_WRITE_SECONDS=2592000
PROVIDER_MANAGED_RECOVERY_INTERVAL_SECONDS=30
PROVIDER_MANAGED_RECOVERY_BATCH_SIZE=25
PENDING_RESERVATION_CLEANUP_INTERVAL_SECONDS=60
PENDING_RESERVATION_CLEANUP_BATCH_SIZE=100

When MySQL is enabled, platformd also runs a background provider-managed recovery pass. It periodically scans settled + fulfillment_status=unknown records, polls the provider status_target, and closes them to succeeded, failed, or keeps them pending without waiting for a buyer poll. Set PROVIDER_MANAGED_RECOVERY_INTERVAL_SECONDS=0 to disable the loop. platformd also runs a pending-reservation cleanup pass. It periodically marks stale settlement_status=pending reservations older than the lease window as not_started + failed with error_detail=pending_reservation_expired, so abandoned reservations do not remain in the backlog forever. Set PENDING_RESERVATION_CLEANUP_INTERVAL_SECONDS=0 to disable that loop.

If PLATFORM_ADMIN_TOKEN is configured, operators can inspect the most recent recovery pass via:

curl -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" \
  http://localhost:8080/platform/admin/recovery/provider-managed

The response includes whether the loop is enabled, interval/batch settings, last-run timestamps, last-run scan counts, and cumulative success/failure/pending/error totals.

Operators can also fetch a compact platform snapshot via:

curl -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" \
  http://localhost:8080/platform/admin/metrics

The admin metrics response includes provider counts by lifecycle status, settlement backlog counts (pending, unknown, and settled + fulfillment_status=unknown), and the current provider-managed recovery snapshot when that loop is enabled.

If you want a scrape-friendly text format instead of JSON:

curl -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" \
  http://localhost:8080/platform/admin/metrics/prometheus

This returns plain-text gauges for provider counts, settlement backlog, and provider-managed recovery loop state.

You can also use the shortcut targets in Makefile:

make run-fakefacilitator
make run-demo-upstream
make run-platform
make run-reconcile
make run-resultgc
make demo-setup

Phase 1 constraints already reflected in code

  • Platform runtime: Go
  • HTTP framework: Gin
  • Buyer demo: Python
  • supported interface types in the current runtime:
    • read_only + proxy_after_verify
    • idempotent_write + proxy_after_verify
    • effectful_write + provider_managed
  • Database target: MySQL 8.0+ with InnoDB, utf8mb4, and READ COMMITTED
  • MySQL runtime: configurable pool sizing and connection lifetime via env
  • uint256-like atomic amounts: VARCHAR(78)
  • all timestamps: UTC DATETIME(6)
  • JSON-like manifest fields: TEXT

Control-plane API key scopes

  • write: can submit/publish manifests, manage pay-to whitelist, and manage provider API keys
  • read: can query provider profile, manifests, routes, audit, ledger, and single-payment state
  • POST /platform/providers returns the initial write key
  • set PROVIDER_REGISTRATION_TOKEN to require X-Provider-Registration-Token on POST /platform/providers
  • POST /platform/providers/:providerID/api-key/rotate revokes the current caller key and issues a fresh write key with the same label/scope set
  • query endpoints use limit with a default of 100 and a maximum of 500
  • /platform/admin/providers, /platform/providers/:providerID/audit, and /platform/providers/:providerID/ledger support cursor pagination via cursor and return next_cursor
  • ledger pagination is settlement-driven: the cursor advances on settlement order, and invocations/results are trimmed to the same page of payment IDs
  • paginated query responses now also include a stable meta object with limit and next_cursor, while keeping the legacy top-level fields for compatibility
  • time filters use RFC3339 UTC timestamps
  • /platform/admin/metrics returns provider and settlement backlog metrics for operator use
  • /platform/admin/metrics/prometheus returns the same metrics as Prometheus-style text gauges

Platform admin lifecycle control

  • set PLATFORM_ADMIN_TOKEN to enable /platform/admin/*
  • /readyz/control checks process + DB readiness for control-plane use
  • /readyz/relay checks process + DB + x402/facilitator readiness for relay traffic
  • PUT /platform/admin/providers/:providerID/status accepts active, suspended, or disabled
  • switching a provider to suspended or disabled removes all currently published routes immediately
  • while a provider is not active, manifest submit/publish is rejected; after reactivation the provider must republish explicitly

Unknown settlement operations

  • list unresolved settlements:
MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/x402_payment?parseTime=true' \
go run ./cmd/reconcile list-unknown
  • filter by provider:
MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/x402_payment?parseTime=true' \
go run ./cmd/reconcile list-unknown -provider-id prov_123
  • list expired pending reservations that are older than the lease window:
MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/x402_payment?parseTime=true' \
go run ./cmd/reconcile list-expired-pending -before 2026-03-15T00:00:00Z -limit 100
  • mark expired pending reservations as failed/not_started:
MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/x402_payment?parseTime=true' \
go run ./cmd/reconcile expire-pending -before 2026-03-15T00:00:00Z -limit 100
  • resolve to settled after external reconciliation:
MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/x402_payment?parseTime=true' \
go run ./cmd/reconcile resolve -payment-id pay_123 -status settled -tx-hash 0xabc
  • resolve to failed when external settlement is confirmed absent:
MYSQL_DSN='user:pass@tcp(127.0.0.1:3306)/x402_payment?parseTime=true' \
go run ./cmd/reconcile resolve -payment-id pay_123 -status failed -error-detail "manual investigation confirmed failure"
All Go libraries →