Payment Flow
POST /payments routes each request to a provider and merchant account, submits it, persists the result in the payments table (schema), and returns confirmation_id and status. ACH payments return ACHSENT and are resolved later (Payment Syncing); pinless and RTP payments return COMPLETED or FAILED synchronously.
Routing
The request carries credit_information or debit_information. ach_information selects ACH; its absence selects the user’s primary debit card. Exactly one of float_id, subscription_id, prorated_subscription_id, installment_loan_id names the payment class.
| Request | Provider | Method | Credential | Initial status |
|---|---|---|---|---|
credit + |
Usio |
RTP credit |
|
|
credit + |
Usio |
ACH credit |
|
|
credit, no |
Usio |
Pinless credit (card credit token) |
|
|
debit + |
JPM |
ACH debit |
JPM ACH account |
|
debit + |
Usio |
ACH debit |
|
|
debit + |
Usio |
ACH debit |
|
|
debit, no |
Usio |
Pinless debit (card debit token, or the request’s |
|
|
An RTP credit first calls Usio’s eligibility check for the routing number (no cache). Not eligible with rtp_mode = fallback falls through to ACH credit; with rtp_mode = only the response is FAILED with error_code RTP_NOT_ELIGIBLE. A credit with neither float_id nor installment_loan_id fails with LOAN_ID_REQUIRED.
Source: pkg/api/payments.go (SubmitPayment, resolveUsioAuthDomain)
Usio Merchant Accounts
Credentials are Secrets Manager secrets (Infrastructure); each secret holds the login, password, and merchant ID.
| Credential | MID | Secret | Used for |
|---|---|---|---|
|
|
|
Card tokenization (mobile app), token refresh, tokenization transaction sync |
|
|
|
Float pinless and ACH debits; float prenotes |
|
|
|
Float pinless, RTP, and ACH credits; refund ACH credits; balance report |
|
|
|
Subscription pinless debits and |
|
|
|
Subscription pinless debits for users in |
|
see Secrets Manager |
|
Installment loan pinless and ACH debits |
|
see Secrets Manager |
|
Installment loan pinless, RTP, and ACH credits; balance report |
Source: pkg/usio/usio.go (Auth* constants), pkg/api/payments.go (resolveUsioAuthDomain, subscriptionDebitAuthType)
Pinless Debit Gates
Before a pinless debit reaches Usio, the API loads the user’s primary card and applies two gates. A blocked submit returns status = FAILED with block_reason (and error_message set to the same value), no confirmation ID, no payment record, and no Kinesis event.
| Gate | Blocks when | block_reason |
|---|---|---|
Unusable card |
The stored card has |
|
The card’s decline history exceeds a cap |
|
Both gates are skipped when bypass_retry_limit is set or the debit’s rollout flag is off for the user: payments.debit_retry_limit.subscription.rollout for subscription and prorated subscription debits, payments.debit_retry_limit.rollout for everything else (floats, installment loans) (GrowthBook Flags); the unusable-card gate is also skipped for card_override submits, which carry no stored card. RETRY_LIMIT_EXCEEDED clears once the window passes.
Source: pkg/api/payments.go (blockedBeforeSubmit), pkg/retrylimit/flags.go (Request.RolloutFlag), pkg/api/server.gen.go (PaymentBlockReason)
Retry limit
The CARDATTEMPT# ledger (schema) holds one row per Usio network response. Declines are counted since the most recent approval in the same bucket; an approval resets the count.
| Window | Visa (and unknown issuer) | Mastercard | Scope |
|---|---|---|---|
30 days |
15 declines |
10 declines |
Per card, class, and exact amount |
24 hours |
— |
4 declines |
Per card and class, across all amounts |
A ledger read error fails open. bypass_retry_limit skips the check but the attempt is still recorded. GET /admin/{user_id}/payments/retry-limit returns the ledger grouped by card, class, and amount, with each bucket’s blocked verdict from the same evaluation the gate runs.
Source: pkg/retrylimit/retrylimit.go
After Submission
| Outcome | Persistence and side effects |
|---|---|
Pinless or RTP approved |
Record saved |
Pinless or RTP declined with a confirmation ID |
Record saved |
Pinless declined without a confirmation ID |
Nothing saved; response |
Usio ACH accepted |
Record saved |
Usio ACH rejected |
Nothing saved (Usio returns no confirmation ID); response |
JPM ACH accepted |
Record saved |
JPM ACH rejected |
Nothing saved; response |
Provider or network error |
|
DynamoDB save fails after provider acceptance |
|
Pinless requests set ReturnConfirmationIDOnDecline, so Usio returns a confirmation ID on network declines. POST /payments returns 200 with status = FAILED for every provider decline; only infrastructure errors return 500.
A 500 after provider acceptance means money moved with no payment record. Reconcile through Account Code Search or the Usio staging tables (dynamo.adoc#usio-staging-tables).
|
Source: pkg/api/payments.go
Status Transitions
Statuses are defined on DynamoDB Tables.
ACH: ACHSENT ──► COMPLETED ──► REFUNDED
│
├──► FAILED (return_code set)
└──► CHARGED_BACK
Pinless / RTP: submit ──► COMPLETED ──► REFUNDED
└──► FAILED
-
ACHSENT→COMPLETED: check-ach-worker. →FAILED: JPM webhook or the Usio ACH-returns sync. →CHARGED_BACK: the Usio chargebacks sync (Payment Syncing). -
COMPLETED→REFUNDED: Refunds. -
FAILEDis terminal; a re-presentment is a new payment.
RTP Eligibility
GET /{user_id}/payment-methods returns rtp_eligible:
-
Load the user’s bank account; none →
rtp_eligible: false. -
Read the
RTP#<routing_number>cache item (schema); a hit answers directly. -
Otherwise call Usio
RTPAccountCheck, cache the result with a 30-day TTL, and return it; a failed check returnsfalse. RTP credit submissions call Usio directly and do not read this cache.
Source: pkg/api/payment_methods.go, pkg/usio/rtp.go
Account Code Search
SubmitPayment accepts an optional account_code on debit_information / credit_information: an opaque caller reference such as <subscription_id>-<unix_millis>. Usio-routed submissions encode it into Usio’s reporting fields: AccountCode1 = the credential name (subscription, float-debit, …), AccountCode2 = a truncated SHA-256 of the reference. AccountCode3 is reserved for Safe Retry.
POST /payments/usio/search with the same account_code and class discriminator live-queries Usio GetTransactionList on those two fields (default window: the last 24 hours) and returns matching transactions, so a caller that received an ambiguous error can learn whether the payment reached Usio before retrying. fmsdk’s payments client wraps the pair as SubmitWithFailureRecovery. No caller sets account_code yet; for float and subscription debits, Safe Retry does the same job inside the service.
Source: pkg/usio/accountcode.go, pkg/api/usio_search.go
Safe Retry
A Usio debit submit can error (5xx, timeout) after Usio took the charge, and a blind retry by the caller charges the user twice. Safe retry covers debits with a float_id or subscription_id; prorated-subscription, installment-loan, and credit payments are not covered. Gated by payments.usio.safe_retry (GrowthBook Flags); off, only step 1 runs and no breadcrumbs are read or saved.
-
Every Usio debit submit (pinless, float or installment-loan ACH,
provider_overridesubscription ACH) sends a fresh random 20-characterAccountCode3. On a submit error the value is logged for a manual Usio search. -
If the submit errors and the debit has a
float_idorsubscription_id, a breadcrumb is saved (best-effort) with theAccountCode3value, the credential, the payment method, and the data for the payment record. -
The next debit for the same
float_idorsubscription_idreads the breadcrumb before routing, so the check also applies when the retry switches method or provider (pinless → ACH, Usio → JPM). -
GetTransactionListon the breadcrumb’s credential with filtercustom|accountcode3|<value>, from 1 minute before the failed submit:Search result Next step A landed transaction
Save the missing payment record, delete the breadcrumb, return success; no charge
No landed transaction, or failure
5030(no match)Delete the breadcrumb; submit as usual
Any other failure or error
Keep the breadcrumb;
500; no chargeA breadcrumb read error also returns
500with no charge.
A transaction landed when AccountType = debit and FinalStatus = success, or AccountType = ACH and FinalStatus is 000 (accepted) or Cxx (notice of change on a posted entry). Return and chargeback rows (DBRETURN, DBCHBACK) also show success, so AccountType must match; Rxx and reject codes did not land.
The recovered response carries the success status of the requested method — COMPLETED for pinless, ACHSENT for ACH — even when the other method landed, since callers accept only that status. When a pinless charge landed for an ACH request, the record is saved with event_type FLOAT_DEBIT_COMPLETED or SUBSCRIPTION_COMPLETED, which moves the float or subscription from ACHSENT to COMPLETED. 500 still means "result unknown, retry later".
Not covered:
-
Subscription reactivation creates a new
subscription_idper call, so a retry finds no breadcrumb. -
An error after a successful submit (e.g. the payment record save fails) saves no breadcrumb.
-
Two concurrent requests for the same id can both submit; only Usio’s duplicate check stops them.
Metric: payments.usio.safe_retry, tag outcome = saved, save_failed, recovered, not_landed, or unconfirmed.
Source: pkg/api/usio_safe_retry.go
Refunds
POST /refunds writes refund records to usio-debit-refunds (schema).
| Request | Behavior |
|---|---|
|
Usio ACH credit to the user for |
|
JPM payment: Usio ACH credit for the payment amount. Usio payment: pinless or ACH refund of the staged Usio transaction by its |
|
|
|
Refund of the Usio transaction as above; no payment update |
|
|
|
|
|
|
Refund idempotency
Before any provider call, a request with a confirmation_id reserves a refund attempt for it: a conditional put to the single table, keyed on the confirmation ID. The put is strongly consistent, so exactly one request per confirmation ID gets past it, whether the others arrive at the same time — a double-clicked refund button, two agents — or later. A request that finds a COMPLETED attempt returns 200 with no provider call; one that finds a PENDING attempt returns 409. A refund record already in usio-debit-refunds for a confirmation ID with no attempt also returns 200, and completes the attempt from that record. A record with outcome = FAILED — the refunds syncer’s failed auto-refund, identified by its support name and description on records from before outcome existed — doesn’t count: it marks an attempt, not money sent, so the refund goes ahead. Payment status alone can’t prevent a second payout: the COMPLETED check and the REFUNDED save are separate reads and writes with the provider call in between, and a staged Usio transaction with no payment record has no status at all.
Once the provider accepts the refund, the attempt is completed with the provider’s refund confirmation, even if writing the refund record or saving the payment then fails. Those cases return 500 and the record or payment is fixed by hand, but a retry returns 200 instead of paying out again. When no money moved — 400, 404, a lookup error, a failure before the provider call, or a Usio response with Status: failure — the attempt is deleted so the refund can be retried. A provider call that errors (transport error, timeout, non-2xx, or unreadable response), or a Usio response with neither a confirmation nor Status: failure, leaves the outcome unknown, so the attempt stays PENDING rather than risk a second payout. The same rule decides the fallback: the ACH credit is only sent after a reversal Usio explicitly rejected. An attempt left PENDING — an unknown provider outcome, the Lambda died mid-refund, or completing or deleting the attempt afterwards failed (logged as Refund attempt left PENDING) — makes retries return 409 until someone checks Usio for the refund and completes or deletes the item.
A successful refund, one step per row. The attempt is PENDING from the first step to the last, and almost all of that is the Usio call:
The same steps for every outcome, one row per way a refund request ends. Bars run left to right through reserve refund attempt, check refunds table, look up payment (and Usio transaction), Usio refund call, write refund record, save payment, complete or delete the attempt. Purple is a refund attempt write, gray a read, teal money moved or written, coral a request stopped early by an existing attempt or record, and red the failed step. The thin bar under each row is how long the attempt is PENDING. Timings are illustrative; only the Usio call is slow.
Arbitrary refunds (is_arbitrary with no confirmation_id) have no key to reserve, so they are not deduplicated.
GET /{user_id}/refunds lists a user’s refund records.
Source: pkg/api/refunds.go
Early Advance
PATCH /payments/{confirmation_id} records early-advance data on an ACHSENT payment:
-
early_advance_float_id: set by Float Service when the advancing float is created.early_advance_dayis optional on the request — omitted, it defaults to the number of business days since the payment’ssubmit_date. -
early_advance_eligible_at: set by underwriting-api when it first approves the user for an early advance; an unparseable value returns400.
Each field is written only while the payment is ACHSENT and the field is not already set; otherwise that part of the request is a no-op. The write clears event_type, so the update is not republished to Kinesis. The response is the updated payment.
Source: pkg/api/payments.go (UpdatePayment)