Skip to content

Quick start

No Rust knowledge required. You need Docker (with compose) and curl. Every command and every output below was executed against the real code — what you see is what you get.

Terminal window
git clone https://github.com/joaoabuenosi/fednow-oss.git
cd fednow-oss
docker compose up --build

Two services come up:

  • fednow-sim on :8080 — plays the FedNow Service, with MQ-style queue-pair semantics (fire-and-forget sends, advices on a receive queue).
  • fednow-gateway on :8090 — your sending institution: idempotent REST API, event-sourced state machine, outbox, background reconciler.

Prefer raw binaries? cargo run -p fednow-sim and FEDNOW_GW_SOUTHBOUND=mq cargo run -p fednow-gateway do the same.

Terminal window
curl -s -X POST http://localhost:8090/payments \
-H "content-type: application/json" \
-H "Idempotency-Key: quickstart-1" \
-d '{
"reference": "QS0001",
"amount_cents": 125000,
"debtor_name": "Jane Example", "debtor_account": "123456789012",
"creditor_name": "John Example", "creditor_account": "987654321000",
"creditor_agent_routing_number": "992000008",
"category_purpose": "CONS"
}'
{"idempotency_key":"quickstart-1","state":"ACK_PENDING","message_identification":"20260703991000009QS0001","end_to_end_identification":"QS0001","uetr":null,"queries_sent":0,"rejection_reason":null,"events":4}

Note the state: ACK_PENDING, not settled. A FedNow-profile pacs.008 went out over the (simulated) MQ connection, and MQ sends are fire-and-forget — the answer arrives asynchronously on a receive queue. That is how the real service behaves, and the gateway is built around it.

Ask again a couple of seconds later:

Terminal window
curl -s http://localhost:8090/payments/quickstart-1
{"idempotency_key":"quickstart-1","state":"SETTLED","message_identification":"20260703991000009QS0001","end_to_end_identification":"QS0001","uetr":null,"queries_sent":0,"rejection_reason":null,"events":5}

The pacs.002 advice (ACSC) arrived on the queue, the background pump applied it, the state machine settled. The money moved.

Two rules you just used without noticing:

  • The Idempotency-Key header is mandatory. Repeat the exact same POST and you get the settled payment back — nothing touches the wire twice.
  • Amounts are integer cents. No floats anywhere near money.

Amounts steer the simulator (Stripe-sandbox style). Anything ending in .11 is refused by the receiving bank:

Terminal window
curl -s -X POST http://localhost:8090/payments \
-H "content-type: application/json" \
-H "Idempotency-Key: quickstart-2" \
-d '{ "reference": "QS0002", "amount_cents": 125011,
"debtor_name": "Jane Example", "debtor_account": "123456789012",
"creditor_name": "John Example", "creditor_account": "987654321000",
"creditor_agent_routing_number": "992000008", "category_purpose": "CONS" }'

Moments later:

{"idempotency_key":"quickstart-2","state":"REJECTED", ... ,"rejection_reason":"AC04","events":5}

AC04 is the ISO reason code (account closed) — carried through from the pacs.002 exactly as a real receiving bank would send it.

Amounts ending in .33 make the simulator go silent — the payment settles internally, but no advice is ever pushed. This is the case that loses money in production when handled wrong (resend = double pay).

Terminal window
curl -s -X POST http://localhost:8090/payments \
-H "content-type: application/json" \
-H "Idempotency-Key: quickstart-3" \
-d '{ "reference": "QS0003", "amount_cents": 125033,
"debtor_name": "Jane Example", "debtor_account": "123456789012",
"creditor_name": "John Example", "creditor_account": "987654321000",
"creditor_agent_routing_number": "992000008", "category_purpose": "CONS" }'

Now just watch:

Terminal window
watch -n 2 'curl -s http://localhost:8090/payments/quickstart-3'

The payment sits in ACK_PENDING, crosses the presumed timeout into TIMEOUT_UNRESOLVED, and then the background reconciler sends a pacs.028 payment status request — never a resend. The withheld advice comes back on the queue, and about half a minute after submission:

{"idempotency_key":"quickstart-3","state":"SETTLED", ... ,"queries_sent":1,"rejection_reason":null,"events":7}

queries_sent: 1 and 7 events tell the whole story: submitted → published → timeout declared → pacs.028 sent → advice received → settled. It had settled all along; a blind retry would have paid twice. This flow is the reason this project exists — the full write-up is the timeout reconciliation chapter.

The gateway validates against the real FedNow Release 1 profile before anything reaches the wire:

Terminal window
# category_purpose must be CONS or BIZZ
curl -s -X POST http://localhost:8090/payments \
-H "content-type: application/json" -H "Idempotency-Key: quickstart-4" \
-d '{ "reference": "QS0004", "amount_cents": 125000, "category_purpose": "WRONG",
"debtor_name": "Jane Example", "debtor_account": "123456789012",
"creditor_name": "John Example", "creditor_account": "987654321000",
"creditor_agent_routing_number": "992000008" }'
{"codes":["fednow.ctgypurp.known"],"error":"fednow_profile_violation"}

HTTP 422 with stable rule codes — every violation at once, not just the first. The same validator (and the same codes) is available as a library (fednow-core), a CLI (fednow-conformance), and a language-agnostic vector corpus your own implementation can run against.

Amount ends in Scenario
anything else settled (ACSC)
.11 rejected by the receiving bank (RJCT/AC04)
.22 accepted without posting (ACWP)
.33 timeout — no advice until a pacs.028 asks
.44 settled after a 2-second delay
.55 rejected by the service itself (RJCT/E990)
.66 ACWP now, receiver’s ACCC pushed moments later

Per-routing-number scenarios via a TOML file: see the simulator README.

The same flow, without hand-writing HTTP — both SDKs are integration-tested against this exact stack in CI:

Python (sdk/python/, zero dependencies):

from fednow_client import GatewayClient
gw = GatewayClient("http://localhost:8090")
gw.submit("order-1", reference="ORDER0001", amount_cents=125_000,
debtor_name="Jane", debtor_account="123456789012",
creditor_name="John", creditor_account="987654321000",
creditor_agent_routing_number="992000008")
print(gw.wait_final("order-1").state) # SETTLED

Java 17 (sdk/java/):

var gw = new GatewayClient("http://localhost:8090");
gw.submit("order-1", SubmitPaymentRequest.builder()
.reference("ORDER0001").amountCents(125_000)
.debtorName("Jane").debtorAccount("123456789012")
.creditorName("John").creditorAccount("987654321000")
.creditorAgentRoutingNumber("992000008").build());
System.out.println(gw.waitFinal("order-1").state()); // SETTLED

GET /ops/summary is the operator’s glance — counts by state, outbox depth, and the age of the oldest unresolved payment (the number to page on). Full endpoint and environment reference: gateway README.