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.
1. Start the stack
Section titled “1. Start the stack”git clone https://github.com/joaoabuenosi/fednow-oss.gitcd fednow-ossexport FEDNOW_GW_API_KEY="$(openssl rand -hex 32)"docker compose up --buildThe 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-simandFEDNOW_GW_API_KEYS="$FEDNOW_GW_API_KEY" FEDNOW_GW_SOUTHBOUND=mq cargo run -p fednow-gatewaydo the same.
2. Send a payment that settles
Section titled “2. Send a payment that settles”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:
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. OnlyGET /healthzis open, for probes. - The
Idempotency-Keyheader 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.
3. Send a payment that gets rejected
Section titled “3. Send a payment that gets rejected”Amounts steer the simulator (Stripe-sandbox style). Anything ending in
.11 is refused by the receiving bank:
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.
4. The hard case: no answer at all
Section titled “4. The hard case: no answer at all”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).
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:
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.
5. Send something invalid
Section titled “5. Send something invalid”The gateway validates against the real FedNow Release 1 profile before anything reaches the wire:
# category_purpose must be CONS or BIZZcurl -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.
6. Optional: a pre-send risk check
Section titled “6. Optional: a pre-send risk check”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.
export FEDNOW_GW_RISK_PROVIDER=simexport FEDNOW_GW_OPERATOR_KEY="$(openssl rand -hex 32)"export FEDNOW_GW_OPERATOR_API_KEYS="ops-demo:$FEDNOW_GW_OPERATOR_KEY"docker compose up --buildops-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=simandFEDNOW_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:
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:
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:
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:
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 triggers cheat sheet
Section titled “Amount triggers cheat sheet”| 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.
Use it from your language
Section titled “Use it from your language”The same flow, without hand-writing HTTP — both SDKs are integration-tested against this exact stack in CI:
Python (sdk/python/, zero dependencies):
import osfrom 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) # SETTLEDJava 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()); // SETTLEDOperate it
Section titled “Operate it”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.
Where to go next
Section titled “Where to go next”- Understand the flow you just ran: handbook chapter 1 — the credit transfer flow.
- Point your own code at the sim: HTTP dev mode (
POST /fednow/messages, synchronous) or MQ mode (/mq/participants/{rtn}/send+/receive) — simulator README. - Prepare for certification: from zero to the Customer Testing
Program, and
cargo run -p fednow-conformance -- scenariosruns the CTP arcs live. - Use the library:
fednow-coreparses, validates and builds all seven Release 1 credit-transfer-family message types.
Pacsmith is an independent open-source project. Not affiliated with, endorsed by or sponsored by the Federal Reserve. FedNow is a service mark of the Federal Reserve Banks.