Payment Flow
End-to-end lifecycle of a payment — from API submission through provider routing, DynamoDB persistence, and final settlement. See Payment Syncing for how ACH statuses are resolved after initial submission.
Payment Types
The SubmitPayment endpoint routes each request to one of five paths based on the combination of request fields and provider:
| Payment Type | Direction | Provider | Method | Initial Status |
|---|---|---|---|---|
Subscription ACH |
Debit (collection) |
JPM (default) or Usio (override) |
ACH bank transfer |
|
Float / loan ACH credit |
Credit (disbursement) |
Usio |
ACH bank transfer |
|
Float / loan ACH debit |
Debit (collection) |
Usio |
ACH bank transfer |
|
Pinless credit |
Credit (disbursement) |
Usio |
Tokenized card |
|
Pinless debit |
Debit (collection) |
Usio |
Tokenized card |
|
RTP credit |
Credit (disbursement) |
Usio |
Real-Time Payments |
|
RTP is attempted when the caller sets rtp_mode on a credit request. If the routing number is not RTP-eligible and rtp_mode = fallback, the payment falls back to ACH automatically. If rtp_mode = only, a failure is returned.
|
Submission Flow
ACH Payments
Caller
│ POST /{user_id}/payments
▼
Payments API (Lambda)
│ decode request body
│ route: debit or credit?
│
├─ credit + ach_information ──► check rtp_mode?
│ ├─ RTP mode? ──► check RTP eligibility (Usio)
│ │ ├─ eligible ──► submit RTP credit (Usio)
│ │ └─ not eligible (if fallback) ──► fall through to ACH
│ └─ no RTP ──► submit ACH credit (Usio)
│
└─ debit + ach_information ──► subscription?
├─ yes + no provider override ──► submit ACH debit (JPM)
└─ yes + provider override ──► submit ACH debit (Usio)
float/loan debit ──► submit ACH debit (Usio)
│
▼
Provider ACH API (JPM or Usio)
│ accepts payment, returns confirmation ID
▼
Payments API
│ build payment record (status = ACHSENT)
│ save to DynamoDB (prod-payments)
│ return confirmation_id + status to caller
The payment record’s masked_account_number (and the response’s account_last_four) is the official account mask from the bank account record, sourced from the transaction service — not the last 4 digits of the auth account number, which for some institutions is a tokenized account number (TAN) with different digits.
Pinless Payments
Caller
│ POST /{user_id}/payments
▼
Payments API (Lambda)
│ decode request body
│ route: debit or credit?
│
├─ credit (no ach_information) ──► fetch credit token from DynamoDB
└─ debit (no ach_information) ──► fetch debit token from DynamoDB
│
▼
Usio Pinless API (tokenized)
│ request sets ReturnConfirmationIDOnDecline = true
│ success → confirmation ID
│ failure → error message + confirmation ID (on decline)
▼
Payments API
│ backfill usio_card_id onto the debit card if empty
│ (from PaymentAccountID in the response, success or decline)
│ on success: build payment record (status = COMPLETED)
│ save to DynamoDB (prod-payments)
│ on failure: build payment record (status = FAILED, return_code set)
│ save to DynamoDB if a confirmation ID was returned
│ return confirmation_id + status to caller
Pinless requests set ReturnConfirmationIDOnDecline so Usio returns a confirmation ID even on a decline, letting the declined attempt be persisted as a FAILED payment (return_code = cleaned Usio message) for analytics and return-code handling. If Usio returns no confirmation ID, the attempt cannot be persisted (confirmation_id is the partition key) and is only returned to the caller. ACH failures still return no confirmation ID and are not persisted at submission time.
|
Every pinless attempt (success or decline) also backfills Usio’s unique card ID onto the debit card record: the response’s PaymentAccountID is saved as usio_card_id when the card doesn’t have one yet. Existing values are never overwritten, and a save failure never fails the payment.
|
Pinless subscription debits (regular and prorated) pick their merchant account per user: the GrowthBook flag payments.pinless.subscription_mid_2 (default off, percentage rollout) redirects them to the second subscription account (28411821, MCC 5968) instead of the original subscription account. See Usio Integration for the merchant account list.
|
Payment Status Transitions
ACH payments are asynchronous. The initial status after submission is ACHSENT, and the final status is determined later through webhook callbacks or daily polling.
┌─────────────┐
│ SUBMITTED │ (API submits to provider)
└──────┬──────┘
│
┌────────▼────────┐
│ ACHSENT │ saved to DynamoDB immediately after submission
└────────┬────────┘
│
┌──────────────┴──────────────────┐
│ │
┌────▼────┐ ┌──────────▼─────────────────┐
│COMPLETED│ │ FAILED (return_code=XYZ) │
└─────────┘ └────────────────────────────┘
(JPM: cleared, (JPM: rejected; Usio failed;
Usio: settled) bank returned with NACHA or
JPM return code — return_code
and return_info are set)
Pinless / RTP:
SUBMITTED → COMPLETED (immediate, no ACHSENT step)
SUBMITTED → FAILED (immediate; pinless and RTP declines persisted when a
confirmation ID is returned, otherwise not written)
Returned ACH payments transition to FAILED with return_code (and return_info) populated — there is no distinct RETURNED payment_status. The return_code distinguishes a rejection (e.g. AC01) from a bank return (e.g. R02 / AC04); the event_type on the record (SUBSCRIPTION_RETURNED, FLOAT_DEBIT_RETURNED, etc.) tells downstream Kinesis consumers which class of return occurred.
Status is written by one of three mechanisms:
-
JPM webhook:
jpm-webhook-processorupdatespayment_statuswhen JPM sends a callback. -
Check ACH polling:
check-ach-workerpolls both JPM and Usio daily for outstandingACHSENTrecords. -
Usio sync:
usio-syncerfetches Usio transaction data every 30 minutes and reconciles payment records.
See Payment Syncing for details on all three mechanisms.
Pinless vs ACH
| Attribute | Pinless / RTP | ACH |
|---|---|---|
Settlement timing |
Instant (synchronous response) |
Several business days |
Initial status |
|
|
Final status reconciliation |
Not required — status is final at submission |
Required — via webhook, polling, or Usio sync |
Confirmation ID on failure |
Returned — requests set |
Not returned by Usio ( |
Blocklist trigger |
Not applicable |
Return codes R02/R03/R04/R16 (or JPM equivalents) |
Kinesis Event
After a successful payment is saved to DynamoDB, the kinesis-feeder Lambda reads the DynamoDB Streams record and publishes an fmsdk.Event to the prod-payments Kinesis stream. The event type is taken from the payment’s event_type field.
Payments submitted through the API have an empty event_type at write time. The event type is set later when the payment status is updated (e.g., to SUBSCRIPTION_RETURNED or FLOAT_DEBIT_RETURNED). Only records with a non-empty event_type are forwarded to Kinesis.
See Event Flows for the full list of event types and their consumers.
Error Handling
| Scenario | Behavior |
|---|---|
Provider API error (network / 5xx) |
|
Provider rejects payment (validation failure) |
|
DynamoDB write failure after successful provider submission |
|
Pinless token not found ( |
Returned as a failure response. Token may have been created under the old master account and not migrated. See Usio Integration. |
Invalid debit card (non-allowlisted failure code) |
Debit card is automatically marked |
Related Pages
-
Payment Syncing — How ACH payment statuses are resolved after submission
-
JPM Integration — JPM ACH details, webhook flow, and return codes
-
Usio Integration — Usio ACH, pinless, and RTP details
-
Blocklist — How returned payments trigger automatic blocklisting
-
Event Flows — Kinesis events published for each payment lifecycle transition