Skip to content

Quick start

No Rust knowledge required. You need Docker (with compose), curl and openssl (to generate an API key). 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
export FEDNOW_GW_API_KEY="$(openssl rand -hex 32)"
docker compose up --build

The gateway’s REST API is authenticated, and the gateway refuses to start without a key; there is no unauthenticated mode. The key never goes in a file. You generate a random one, compose passes it to the gateway, and every request below sends it as Authorization: Bearer $FEDNOW_GW_API_KEY. Forget the export and compose stops with required variable FEDNOW_GW_API_KEY is missing a value. Use the same shell for the steps below, or export the same key again in the new one.

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_API_KEYS="$FEDNOW_GW_API_KEY" FEDNOW_GW_SOUTHBOUND=mq cargo run -p fednow-gateway do the same.

Terminal window
curl -s -X POST http://localhost:8090/payments \
-H "Authorization: Bearer $FEDNOW_GW_API_KEY" \
-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 -H "Authorization: Bearer $FEDNOW_GW_API_KEY" \
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.

Three rules you just used without noticing:

  • Every call needs the API key. Without it you get 401. Only GET /healthz is open, for probes.
  • 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 "Authorization: Bearer $FEDNOW_GW_API_KEY" \
-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 "Authorization: Bearer $FEDNOW_GW_API_KEY" \
-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 -H 'Authorization: Bearer $FEDNOW_GW_API_KEY' 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 "Authorization: Bearer $FEDNOW_GW_API_KEY" \
-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.

The gateway can ask a risk provider about each payment after validation and before anything reaches the outbox. That is the last moment a payment can still be stopped, because FedNow settlement is final. The check is off in steps 1–5 and stays off unless you turn it on, so the outputs above do not change.

For the demo, the provider is the simulator’s demo endpoint. Its request and response formats are this project’s own. It is not the Federal Reserve’s Network Intelligence API. This project has no client for that API: its specification is not public (#95).

Stop the stack (Ctrl-C) and start it again with the check on. This time also create an operator key. Only an operator key can release or cancel a payment the check holds, and your FEDNOW_GW_API_KEY cannot. The application that submits payments must not be able to wave its own holds through.

Terminal window
export FEDNOW_GW_RISK_PROVIDER=sim
export FEDNOW_GW_OPERATOR_KEY="$(openssl rand -hex 32)"
export FEDNOW_GW_OPERATOR_API_KEYS="ops-demo:$FEDNOW_GW_OPERATOR_KEY"
docker compose up --build

ops-demo is the key’s label. The audit trail records it as operator:ops-demo on every release or cancel made with that key.

Raw binaries: add FEDNOW_GW_RISK_PROVIDER=sim and FEDNOW_GW_OPERATOR_API_KEYS="$FEDNOW_GW_OPERATOR_API_KEYS" to the gateway command.

The gateway logs risk check: on (provider sim, timeout 1000 ms, max in flight 32, on unavailable: hold) and held payments: releasable for 14400 s by an operator key, then cancelled (hold_expired). Amounts ending in .77 are held by the demo provider:

Terminal window
curl -s -X POST http://localhost:8090/payments \
-H "Authorization: Bearer $FEDNOW_GW_API_KEY" \
-H "content-type: application/json" \
-H "Idempotency-Key: quickstart-5" \
-d '{ "reference": "QS0005", "amount_cents": 125077,
"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-5","state":"HELD","message_identification":"20260922991000009QS0005","end_to_end_identification":"QS0005","uetr":null,"queries_sent":0,"rejection_reason":null,"events":4,"risk":{"outcome":"hold","reason":"sim.hold","source":"provider"},"hold":{"held_at_unix":1790110339,"releasable_until_unix":1790124739,"resolution":null}}

HELD, 4 events: created, validated, risk-checked, parked. Nothing was sent. The built message is parked outside the outbox, and the history keeps only its SHA-256. The same POST with 125088 (and a new key) comes back REFUSED with "reason":"sim.refuse".

Now the case that decides the policy: the provider does not answer in time. 125099 makes the demo provider take 5 seconds, and the gateway waits 1:

{"idempotency_key":"quickstart-7","state":"HELD", ... ,"events":4,"risk":{"outcome":"hold","reason":"risk_check_timeout","source":"timeout"}, ... }

The provider never decided; the failure policy did. The default is hold: fail closed, but in a state a person can review. That is safer than waving the payment through during an outage. It is also less final than refusing it. FEDNOW_GW_RISK_ON_UNAVAILABLE=allow fails open instead, and the audit trail still records that the check did not happen. Any other amount is allowed and settles as before, now with "risk":{"outcome":"allow","reason":null,"source":"provider"} in the view.

Resolve the holds. Someone reviewed quickstart-5 and it is fine. Your submitting key cannot release it:

Terminal window
curl -s -X POST http://localhost:8090/payments/quickstart-5/release \
-H "Authorization: Bearer $FEDNOW_GW_API_KEY" \
-H "content-type: application/json" -d '{"reason": "reviewed_ok"}'
{"detail":"this route needs an operator key (FEDNOW_GW_OPERATOR_API_KEYS); full-access and read-only keys cannot resolve a held payment","error":"forbidden"}

The operator key can:

Terminal window
curl -s -X POST http://localhost:8090/payments/quickstart-5/release \
-H "Authorization: Bearer $FEDNOW_GW_OPERATOR_KEY" \
-H "content-type: application/json" -d '{"reason": "reviewed_ok"}'
{"idempotency_key":"quickstart-5","state":"ACK_PENDING","message_identification":"20260922991000009QS0005","end_to_end_identification":"QS0005","uetr":null,"queries_sent":0,"rejection_reason":null,"events":6,"risk":{"outcome":"hold","reason":"sim.hold","source":"provider"},"hold":{"held_at_unix":1790110339,"resolution":{"action":"released","actor":"operator:ops-demo","reason":"reviewed_ok","at_unix":1790110339}}}

The parked message, byte for byte the one that was checked, went to the outbox and onto the wire. A couple of seconds later GET /payments/quickstart-5 says SETTLED, with 7 events. The risk verdict stays hold in the view, because the history is not rewritten. Releasing again gets 409 {"error":"not_held","state":"SETTLED"}, and nothing is sent twice.

The timed-out one you cancel instead:

Terminal window
curl -s -X POST http://localhost:8090/payments/quickstart-7/cancel \
-H "Authorization: Bearer $FEDNOW_GW_OPERATOR_KEY" \
-H "content-type: application/json" -d '{"reason": "confirmed_fraud"}'

It ends CANCELLED, never sent, with "resolution":{"action":"cancelled","actor":"operator:ops-demo","reason":"confirmed_fraud",...}. The reason must be a short code: {"reason": "customer called"} gets 400 invalid_reason, because free-text notes are where account numbers and names end up. A hold nobody resolves within FEDNOW_GW_HOLD_MAX_AGE_SECS (4 hours by default) is cancelled by the gateway with "actor":"gateway","reason":"hold_expired".

/ops/summary shows the outcomes by state ("by_state":{"CANCELLED":1,"REFUSED":1,...}). The full reference is in the gateway README: timeouts, the concurrency budget, what the audit events record and never record, and why a release sends the parked message instead of a rebuilt one. See Pre-send risk check and Resolving a held payment.

Amount ends in Demo risk provider (only with FEDNOW_GW_RISK_PROVIDER=sim)
.77 hold → HELD (then release → SUBMITTED → …, or cancel → CANCELLED)
.88 refuse → REFUSED
.98 provider error (HTTP 503) → the failure policy decides (HELD by default)
.99 answers after 5 s → timeout → the failure policy decides (HELD by default)
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):

import os
from fednow_client import GatewayClient
gw = GatewayClient("http://localhost:8090", api_key=os.environ["FEDNOW_GW_API_KEY"])
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", System.getenv("FEDNOW_GW_API_KEY"));
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). For monitoring, give it a read-only key (FEDNOW_GW_READ_API_KEYS), which can read but gets 403 on submit and reconcile. Held payments are released or cancelled with an operator key (FEDNOW_GW_OPERATOR_API_KEYS, step 6). Full endpoint, authentication and environment reference: gateway README.