RelayPay is a synthetic, INR-only payment-orchestration sandbox. Its core is a payment API that stays correct when a provider commits a financial effect and the response never arrives: the mutation is sent once, recovery uses a status lookup instead of a retry, and local state is finalized exactly once with a balanced, immutable ledger entry, a merchant event, and a webhook delivery.
Around that core the repository contains a merchant balance and payout ledger, statement reconciliation, versioned connectors, dispute responses, subscription recovery, settlement questions, merchant risk review, an operator console, and the supporting simulators (provider, bank, commerce, dispute network, and others) needed to run all of it locally.
Synthetic data only. RelayPay is not a payment processor. Do not send it real card, bank, identity, or customer data. All counterparties are local simulators, and the "model" roles in the settlement, risk, and dispute workflows are deterministic code, not external LLM calls (see limitations).
Current version: 1.0.1 (release notes). Licensed under the MIT License. Nothing in this repository deploys or hosts the sandbox for you.
main also contains ongoing v2 work: persisted payment states, partial capture, void and
expiry, a PSP evidence inbox, and fixtures-first gateway adapters. It is not a completed v2
release. Four core decision modules (invariant checker, payout locking, fee calculator, PSP
ordering) were implemented by Claude at the maintainer's request; PROVENANCE.md
states exactly who wrote what.
For offline seller investigations, relaypay-reconcile
streams settlement and order CSVs through local SQLite and emits deterministic exception reports.
It uses only the Python standard library, opens no network connections, and never changes the
payment database. Try the sample exports after uv sync --frozen:
uv run relaypay-reconcile --settlement examples/reconcile/settlement.csv \
--orders examples/reconcile/orders.csv --out-dir output/reconcileThe sample deliberately returns exit code 1 with one finding in each of nine exception classes. Clean inputs return 0; malformed inputs return 2.
Latest measured evidence and recovery recording cover the implemented completion pass, its verification results and the remaining v2 boundaries.
A capture request reaches the provider, the provider commits the capture, and the response is lost. A naive client retries and captures twice; a cautious one fails the payment while the money moved. RelayPay handles it with these rules:
- The provider operation and a
SENTattempt are committed to PostgreSQL before any HTTP call. A logical command has one stable provider key. - After a lost or ambiguous response the mutation is never resent. A worker issues a status-only lookup for the same stable key; the provider's signed reply is validated (signature, schema, account, kind, amount, currency, reference) before it is trusted.
- One shared finalizer, in one transaction, writes the resource state, the ledger journal, the immutable merchant event, and the terminal response bytes for every idempotency key attached to the operation. Two capture keys therefore replay byte-identical responses.
- A leased delivery worker sends the event as HMAC-signed bytes to an allowlisted receiver, which deduplicates by event id and digest.
The console's Scenario Lab and make demo run this journey deterministically and assert: one
provider effect, one capture, one balanced journal, one event, one acknowledged delivery, and
identical response bytes for both capture keys. Details: architecture,
ADR-002.
| Area | Capability |
|---|---|
| Payments | Customers, payment intents, authorize, capture (single; partial in v2), void and expiry in v2, refunds against available amount; route-aware idempotency; cursor pagination |
| Ledger | Double-entry INR journals, DB-enforced balance and immutability, derived merchant balances (pending, available, receivable) |
| Events | Immutable merchant events, endpoint versions, leased HMAC-signed delivery, bounded retry, dead letter, operator replay |
| Settlement and payouts | Idempotent settlement runs, reserved payouts, numbered bank attempts with lookup-only recovery |
| Reconciliation | Immutable statement imports (CSV/JSON), leased versioned runs, matches, mismatch workflow |
| Connectors | Versioned connector config with encrypted credentials, health verification, authenticated inbound webhooks, synthetic commerce sync |
| Identity | Organisations with TEST and LIVE_LIKE environments, memberships and roles, scoped and rotatable API keys, session + CSRF admin access |
| Disputes | Draft versions with citations, digest-bound frozen packages, maker-checker approval, send-before-network submission |
| Subscription recovery | Bounded recovery cases, consent-aware actions, opt-out suppression |
| Settlement questions and risk review | Fixed tenant-scoped queries with citations; deterministic risk scoring over synthetic site snapshots, escalation, disposition |
| Analytics and evaluations | Portfolio aggregates computed in PostgreSQL; a versioned deterministic evaluation suite run in CI |
| Console | Next.js operator UI: Scenario Lab, payment evidence, operations, settlement, risk, recovery, disputes, agent runs |
| Edge (optional) | Cloudflare Worker source with local/CI proof only; not deployed by this repository |
Editable source: architecture.excalidraw (open it at excalidraw.com).
| Component | Role |
|---|---|
apps/api |
FastAPI service. Merchant API (/api/v1), admin API (/api/admin/v1, /api/session), signed inbound webhooks (/api/inbound/v1) |
apps/worker/poller.py |
The only background process. Time-gated batches for recovery, delivery, reconciliation, payouts, subscription recovery, inbound webhooks, forecasts, and the outbox drain |
apps/console |
Next.js 16 operator console that talks to the API through server components and a /backend/* proxy |
apps/provider, bank, commerce, dispute_network, communication_network, merchant_site |
Deterministic counterparty simulators, each its own container |
apps/receiver |
Reference webhook consumer with deduplication in an isolated schema |
apps/edge |
Optional Cloudflare Worker (rate and replay admission, R2 archive, Queue, signed origin requests) |
packages/relaypay |
Domain code, one package per area (payments, ledger, provider_operations, event_delivery, and so on) |
packages/relaypay_sdk |
Python SDK: hand-written facade over a client generated from contracts/openapi |
migrations/ |
One Alembic tree per database: relaypay, provider, bank, commerce, receiver |
PostgreSQL is the only correctness authority. There is no task queue: the poller claims work with
FOR UPDATE SKIP LOCKED and expiring lease tokens, so a crashed poller restarts and reclaims its
work. Redpanda receives business-event outbox rows for downstream consumers, but nothing in this
repository consumes them and no financial outcome depends on it.
More detail: architecture, database, HTTP API, threat model.
| Component | Choice | Why |
|---|---|---|
| API | Python 3.12, FastAPI, Pydantic v2 (strict models) | Typed request and response contracts; OpenAPI is generated and checked for drift in CI |
| Persistence | PostgreSQL 17, SQLAlchemy 2, psycopg 3, Alembic | SKIP LOCKED leases, advisory locks, deferred constraint triggers for ledger balance, partial unique indexes |
| Event stream | Redpanda (Kafka API) | Target for the transactional outbox; derived and at-least-once |
| Console | Next.js 16 (App Router), React 19, TypeScript | Server-side session handling; Playwright and axe for end-to-end and accessibility checks |
| Edge (optional) | Cloudflare Workers, Durable Objects, R2, Queues | Admission control and immutable ingress archive, proven locally with Wrangler dry runs |
| Ingress | Caddy | Automatic TLS and HTTP/3 for the single-host deployment |
| Observability (optional) | OpenTelemetry, Prometheus, Grafana | Bounded-cardinality metrics and traces behind a compose profile |
| Tooling | uv, ruff, mypy (strict), pytest, Docker Compose | Reproducible installs and a single release gate |
Requirements: Docker with Compose v2, Python 3.12, uv, Node.js 24.
Docker alone is enough to run the stack; Python and Node are needed for the make targets and the
test suites.
cp .env.example .env
docker compose up --buildCompose starts PostgreSQL and Redpanda, runs every Alembic tree, seeds two demo organisations,
then starts the API, simulators, receiver, poller, and console. Open
http://localhost:3000/login and sign in with a seeded administrator (these are public
development credentials from scripts/seed.py; never reuse them):
admin@northstar.test / RelayPay-Northstar-2026!
admin@juniper.test / RelayPay-Juniper-2026!
Choose Run lost-response scenario, then Inspect payment evidence.
The seed step prints each organisation's TEST API key once, in the migrate service log:
docker compose logs migrate | grep "API key"Call the merchant API on port 8000 with that key:
KEY=rpk_test_... # from the log above
curl -s localhost:8000/api/v1/customers \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"merchant_customer_reference":"cust-001","display_name":"Asha"}'
curl -s localhost:8000/api/v1/payment_intents \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: pi-001' \
-d '{"customer_id":"cus_<id from above>","merchant_reference":"order-001","amount":50000}'amount is an integer in paise. Authorize, capture, and refund are
POST /api/v1/payment_intents/{id}/authorize|capture|refunds with their own Idempotency-Key; they
return 200 when finalized inline or 202 while unresolved. See the HTTP API reference.
Use uv0.12.19 (enforced by the project), Node24.12 or later, and npm11.19.1. Dependency installation scripts are disabled by the tracked npm policy and the commands below. Run PostgreSQL and Redpanda from Compose and the Python tooling from the host:
uv sync --frozen
npm install --global npm@11.19.1 --ignore-scripts
npm ci --ignore-scripts --prefix apps/console
npm ci --ignore-scripts --prefix apps/edge # only for the edge Worker checks
docker compose up -d postgres redpanda
make migrate && make seed
make testPostgreSQL is published on localhost:55432 and Redpanda on localhost:19092; .env.example
already points host-run code at them. Integration tests need PostgreSQL, and the agent-runtime
outbox test also needs Redpanda. make migrate does not read .env: Alembic uses the
development URLs hard-coded in each alembic.ini unless you export the matching
*_MIGRATION_DATABASE_URL variables. make seed and the demos do read .env.
| Command | Purpose |
|---|---|
make lint, make format-check, make typecheck |
ruff, ruff format, strict mypy over packages apps scripts |
make unit, make test |
pytest: unit only, or the full suite |
make api-contract, make sdk-drift, make sdk-example |
OpenAPI regeneration and compatibility check; SDK regeneration check; offline webhook-verification example |
make evaluations |
Deterministic evaluation suite (dispute, recovery, settlement, risk, adversarial fixtures) |
make demo |
Lost-response proof, run in-process against the seeded database |
make merchant-balance-demo, make connector-demo, make portfolio-demo |
Settlement, connector/inbound/commerce, and cross-workflow journeys against the database |
RELAYPAY_DEMO_PASSWORD=... make reconciliation-demo |
Export a provider statement, import it, run reconciliation (needs the full stack) |
make console-check, make console-e2e |
Console lint, typecheck, build, npm audit; Playwright journeys (needs the full stack) |
make edge-check, make compose-check |
Worker lint, types, tests, dry-run build; docker compose config validation |
make m7-evidence |
Query plans and bounded HTTP performance measurements |
make load, make load-check |
Synthetic payment-path k6 measurements and offline workload checks; load kit |
make reconcile-check, make reconcile-proof |
Offline CLI correctness tests; separate two-million-order memory proof |
make check |
Lint, format, types, contract and SDK checks, pytest, console and edge checks, compose validation (needs PostgreSQL and Redpanda up; not the Playwright journeys) |
make infra-up, make infra-down |
Start PostgreSQL only; docker compose down (keeps the data volume) |
The reset truncates application data in all four databases and the receiver schema, keeps schemas and Alembic history, and reseeds both organisations. Stop everything that writes first:
docker compose stop api poller console provider receiver bank commerce \
dispute-network recovery-network merchant-site
SANDBOX_RESET_CONFIRM=RESET_SYNTHETIC_RELAYPAY make reset
docker compose up -dIn APP_ENV=production it additionally requires ALLOW_SANDBOX_RESET=true.
CI (.github/workflows/ci.yml) separates release checks, property/chaos proofs, and the
maintainer's handwritten specifications. The release job starts empty PostgreSQL and Redpanda, replays each
milestone's upgrade proof, applies all migrations, runs alembic check on all five trees, then
runs lint, format, strict typing, the OpenAPI and SDK drift checks, the pytest suite (unit,
integration, concurrency), the evaluation suite, the console build, the edge checks, compose
validation, the Playwright journeys with axe, and measured performance evidence. Test files live
in tests/unit and tests/integration; console journeys in apps/console/e2e; Worker tests in
apps/edge/test. Recorded release runs are in docs/test-evidence.md and
docs/evidence/.
The chaos harness evaluates the fifteen money invariants on every seed and reports violations. A clean run covers the schedules and seeds it ran, not every interleaving.
All settings are environment variables validated at startup by packages/relaypay/config.py; the
reference is docs/configuration.md. Production mode rejects insecure
defaults for eight secrets and requires an HTTPS PUBLIC_BASE_URL.
docs/deployment.md covers the optional single-host Ubuntu deployment
(compose.production.yaml plus Caddy), backups across all four databases, and reset. It is a
disposable-sandbox layout, not a regulated production architecture.
- PostgreSQL over a queue. Recovery, delivery, reconciliation, and payouts are rows with leases, so every claim is observable and recoverable with SQL. The cost is polling latency (about one second per tick) and throughput bounded by one scheduler. Running a second poller is safe, but it is not a tested scale-out path.
- Send-before-HTTP, lookup-only after. A mutation can reach a provider at most once per stable
key. After five lookups that stay indeterminate the operation becomes
REQUIRES_REVIEW; a human decides, and the system never guesses. The one exception is the provider's own signed "not found" received after a grace period, which proves the request never arrived. The operation then fails instead of waiting for a human (ADR-011). - Immutability by trigger. Ledger, events, statements, and other evidence tables reject
UPDATEandDELETEthrough database triggers. Tables added in migrations 0012 to 0017 (agent runtime, disputes, recovery, settlement intelligence, risk review) are append-only by application convention only. See database. - No row-level security. Tenant isolation comes from organisation and environment predicates
in queries plus composite foreign keys. Foreign IDs return
404. - Separate databases for each counterparty. The provider, bank, and commerce simulators own their data so RelayPay code cannot read it. Compose still passes every database URL to every container; the separation is enforced by database roles, not container wiring.
The full list is in docs/limitations.md. The ones most likely to matter:
- Synthetic INR only; one full capture per payment; the model roles are deterministic stand-ins.
- The compose stack seeds fixed, public demo credentials on every
up, including with the production overlay. - The login rate limiter is per process; API-key routes have no rate limit unless the optional edge Worker is placed in front.
/metricsand the poller metrics port are unauthenticated and must stay network-internal.- Redpanda runs in
dev-containermode without a data volume, and no consumer reads it.
apps/ api, worker (poller), console, edge, simulators, receiver
packages/ relaypay (domain code), relaypay_sdk
migrations/ Alembic trees: relaypay, provider, bank, commerce, receiver
contracts/ Frozen OpenAPI documents and API fixtures
infra/ Caddy, Postgres bootstrap, OpenTelemetry, Prometheus, Grafana
scripts/ Seed, reset, demos, evaluation runner, contract and evidence checks
examples/python/ SDK and webhook examples
tests/ unit, integration, fixtures
docs/ Documentation (index: docs/README.md)
Start with the documentation index. Direct links: architecture · database · HTTP API · error catalog · Python SDK · configuration · deployment · threat model · limitations · ADRs · domain glossary · contributing