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 + ach_information + rtp_mode

Usio

RTP credit

float-credit / loan-credit

COMPLETED or FAILED

credit + ach_information

Usio

ACH credit

float-credit / loan-credit

ACHSENT

credit, no ach_information

Usio

Pinless credit (card credit token)

float-credit / loan-credit

COMPLETED or FAILED

debit + ach_information + subscription_id, no provider_override

JPM

ACH debit

JPM ACH account

ACHSENT

debit + ach_information + subscription_id + provider_override

Usio

ACH debit

subscription

ACHSENT

debit + ach_information + float_id / installment_loan_id

Usio

ACH debit

float-debit / loan-debit

ACHSENT

debit, no ach_information

Usio

Pinless debit (card debit token, or the request’s card_override token)

float-debit / subscription or subscription-2 / loan-debit

COMPLETED or FAILED

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

main

10790868

site/usio/main

Card tokenization (mobile app), token refresh, tokenization transaction sync

float-debit

29063402

site/usio/float_debit

Float pinless and ACH debits; float prenotes

float-credit

29063403

site/usio/float_credit

Float pinless, RTP, and ACH credits; refund ACH credits; balance report

subscription

29111679

site/usio/subscription

Subscription pinless debits and provider_override ACH debits; subscription prenotes

subscription-2

28411821

site/usio/subscription_2

Subscription pinless debits for users in payments.pinless.subscription_mid_2 (MCC 5968)

loan-debit

see Secrets Manager

site/usio/loan_debit

Installment loan pinless and ACH debits

loan-credit

see Secrets Manager

site/usio/loan_credit

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 suspended_on set, or is_valid = false (suspension wins)

CARD_SUSPENDED, CARD_INVALID

Retry limit

The card’s decline history exceeds a cap

RETRY_LIMIT_EXCEEDED

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 COMPLETED; usio_card_id backfilled onto the card from the response’s PaymentAccountID when the card has none; ledger row approved

Pinless or RTP declined with a confirmation ID

Record saved FAILED with return_code (cleaned Usio code), return_info (Usio message), and available_funds_cents on a code-51 partial-auth decline; usio_card_id backfilled; ledger row declined; the decline is handed to the return-code engine

Pinless declined without a confirmation ID

Nothing saved; response FAILED

Usio ACH accepted

Record saved ACHSENT

Usio ACH rejected

Nothing saved (Usio returns no confirmation ID); response FAILED

JPM ACH accepted

Record saved ACHSENT with confirmation_id = endToEndId

JPM ACH rejected

Nothing saved; response FAILED with error_message = JPM statusCode

Provider or network error

500; nothing saved. A Usio debit for a float or subscription also saves a breadcrumb for Safe Retry

DynamoDB save fails after provider acceptance

500; the provider holds the payment and this service has no record

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.

  • FAILED is terminal; a re-presentment is a new payment.

RTP Eligibility

GET /{user_id}/payment-methods returns rtp_eligible:

  1. Load the user’s bank account; none → rtp_eligible: false.

  2. Read the RTP#<routing_number> cache item (schema); a hit answers directly.

  3. Otherwise call Usio RTPAccountCheck, cache the result with a 30-day TTL, and return it; a failed check returns false. RTP credit submissions call Usio directly and do not read this cache.

Source: pkg/api/payment_methods.go, pkg/usio/rtp.go

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.

  1. Every Usio debit submit (pinless, float or installment-loan ACH, provider_override subscription ACH) sends a fresh random 20-character AccountCode3. On a submit error the value is logged for a manual Usio search.

  2. If the submit errors and the debit has a float_id or subscription_id, a breadcrumb is saved (best-effort) with the AccountCode3 value, the credential, the payment method, and the data for the payment record.

  3. The next debit for the same float_id or subscription_id reads the breadcrumb before routing, so the check also applies when the retry switches method or provider (pinless → ACH, Usio → JPM).

  4. GetTransactionList on the breadcrumb’s credential with filter custom|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 charge

    A breadcrumb read error also returns 500 with 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_id per 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

is_arbitrary with no confirmation_id

Usio ACH credit to the user for amount; refund record with no payment_id

confirmation_id of a COMPLETED payment

JPM payment: Usio ACH credit for the payment amount. Usio payment: pinless or ACH refund of the staged Usio transaction by its AccountType, falling back to a Usio ACH credit when Usio rejects the refund; a credit Usio doesn’t confirm returns 500. Then payment_status = REFUNDED, return_date set, event_type = FLOAT_REFUNDED, SUBSCRIPTION_REFUNDED, or LOAN_PAYMENT_REFUNDED

confirmation_id of a payment that is not COMPLETED

400 PAYMENT_NOT_REFUNDABLE

confirmation_id with no payment record but a staged Usio transaction

Refund of the Usio transaction as above; no payment update

confirmation_id matching nothing

404

confirmation_id already refunded

200; no provider call

confirmation_id with a PENDING refund attempt

409 REFUND_IN_PROGRESS; no provider call

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:

Successful refund timeline

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.

Refund timeline by outcome

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_day is optional on the request — omitted, it defaults to the number of business days since the payment’s submit_date.

  • early_advance_eligible_at: set by underwriting-api when it first approves the user for an early advance; an unparseable value returns 400.

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)