Payment rails & processors
The Lending Core names no processor. It has three provider-neutral connections, exactly one way to send money out, and treats every settlement truth as a signed event coming back in.
Search the Lending Core for the name of a payment processor and you will not find one. Money movement is expressed as three provider-neutral connections, and a processor integration is one client bound to those three connections. Moov is the reference implementation for the ACH and card rails; the contract it satisfies is the same contract any other processor satisfies, which is why replacing a processor changes that client and nothing else.
The shape of that contract matters more than the vendor. There is exactly one outbound verb. Everything a processor later has to say arrives inbound as a normalized event. Submission acknowledgement is a transport fact; settlement truth arrives afterwards, separately, and is the only thing allowed to move a balance.
The three ports
ProviderMoneyMovementPort
- Answers with the processor’s own code
- Submits one request, returns one acknowledgement
- The entire outbound surface
ProviderSignatureVerifier
- Verifies an inbound event, keyed by provider code
- A processor signs on its own side
- The platform only verifies
ProviderEventSigner
- Signs an event for a rail that is both ends of its own wire
- A processor that signs externally never binds it
The one outbound verb
submit carries only what a processor needs to move money and nothing that would let it infer the rest of the portfolio:
| Field | Meaning |
|---|---|
intentKind |
PAYMENT or FUNDING. |
intentRef |
The platform’s own identifier for the intent being submitted. |
rail |
ACH, CARD, or EXTERNAL. |
direction |
DEBIT or CREDIT. |
amount, currencyCode |
The submitted figure, in one currency. |
effectiveDate |
The date the money belongs to, which is not the date it settles. |
providerTokenRef |
An opaque instrument token. The platform stores the token and the last four digits; it never holds an account or card number. |
idempotencyKey |
Makes a repeated submission a no-op at the processor. |
The acknowledgement returns a providerRef and an acceptedState. Neither posts anything. A 201 from a processor means a request was accepted for processing — it is not money, and the Lending Core never treats it as money.
What arrives inbound
Inbound events are normalized into the platform’s own vocabulary. Four concern money coming in and the instruments that carry it:
| Event | Effect |
|---|---|
payment.settlement.updated |
Advances the payment’s rail state; may open the posting gate. |
payment.returned |
Carries a return code and moves the intent to RETURNED. |
instrument.verification.completed |
Confirms an instrument by a named method. |
instrument.validation.completed |
Records a validation outcome against a stored token. |
Money leaving the lender travels its own path and has its own pair of events, described under money going out.
Intake runs in three transactions
The order is the design, and it is not the obvious one:
- Record. The raw event is written to its own committed row before anything about it is trusted. A unique
(provider, external_event_id, event_type)withON CONFLICT DO NOTHINGmeans a redelivered event returnsDEDUPLICATEDand goes no further. - Verify. The signature is checked against the provider’s verifier. An event that fails verification is already recorded, and is marked failed rather than discarded — a forged or misconfigured event leaves evidence instead of silence.
- Normalize. Interpretation happens in a separate transaction, so a poison event marks its own row failed without rolling back the intake commit.
Writing the row first is what makes the unique constraint a usable deduplication anchor at all: an event cannot be recognized as a repeat unless the first copy was safely stored before anyone tried to understand it. Redelivery is the normal behaviour of every payment network, so it is handled by a constraint rather than by careful code.
The posting gate
Rail state, not elapsed time, decides when money is real. Each rail carries its own state column, and exactly one state per rail opens the gate:
| Rail | Posting state | Everything else |
|---|---|---|
| ACH | FUNDS_AVAILABLE |
Moves the intent to PROCESSING. |
| Card | COMPLETED |
Moves the intent to PROCESSING. |
Posting fires exactly once per intent. In the same database transaction it records funds_available_at, recalculates delinquency, and re-reads the credit balance — so there is no window in which a payment has posted but the loan does not yet know it. Nothing in the platform asserts how long a rail takes; a settlement duration is a property of the network, not a promise of the product.
Payment intent states
Alongside status, each intent records how it came to exist. capture_mode is one of PROVIDER_INITIATED, AUTOPAY_GENERATED, EXTERNAL_RECORD, or IMPORT; source is one of BORROWER, STAFF, AUTOPAY, SERVICING, EXTERNAL, or IMPORT. The pair answers two different audit questions — which mechanism moved the money, and on whose authority — that a single field would blur.
Returns and chargebacks
A return is an inbound fact, not an operator action, and there is no public return route. When payment.returned arrives, one operation applies the whole consequence: the intent moves to RETURNED, the return code is stored, the ACH rail state is set to RETURNED, the original ledger transaction is reversed, and autopay takes the action its policy binds to that specific code.
An unrecognized return code fails closed. It does not default to “retry” and it is never ignored: it produces a recorded disposition that blocks secondary automation until a human resolves it. Retries are always new intents linked back to the original; a returned payment is never resubmitted. See Payments & repayments for the retry chain and Autopay for the per-rail return-code table.
Money going out
Disbursements, payouts, and credit-balance refunds are not payments in reverse. They live in their own intent table with their own normalizer and their own two events — funding.settlement.updated advances a payout, funding.failed ends one with its provider reason — and a refund begins at APPROVAL_REQUIRED with a second approver who is not the requester. The asymmetry is deliberate: pulling money the borrower authorized and pushing money out of the lender’s account carry different risks and deserve different controls. See Funding & disbursement.
Instrument confirmation
Instruments are confirmed by a named method rather than a boolean. The platform recognizes exactly MICRO_DEPOSIT, AGGREGATOR_AUTH, PRENOTE, EXISTENCE_CHECK, MIGRATED, and STAFF_ASSERTED; which one applies is the processor’s answer, not the platform’s assumption — a bank account proven through an aggregator reports AGGREGATOR_AUTH, a card checked for existence reports EXISTENCE_CHECK.
The distinction is load-bearing rather than decorative. VERIFIED means ownership was proven, and an existence-only check must never set it — so the method travels with the instrument and a later dispute is argued from what was actually checked instead of from a boolean that has forgotten. Instruments enter through POST /v1/customers/{customerId}/payment-instruments.
Idempotency, twice
Duplicate protection is applied independently on both sides of the boundary, because the two sides fail differently:
- Outbound.
Idempotency-Keyis bound to a payload fingerprint at payment creation, so a replay returns the original result rather than a second debit. The full contract — replay, key reuse, and in-flight collision — is in Payments & repayments. - Inbound. The
(provider, external_event_id, event_type)constraint absorbs redelivery. Each normalized answer commits on its own, so one failed event cannot un-settle its neighbours.
Every event names where it came from
The provider-event record carries the provider code that produced it, so the origin of a settlement is a stored fact rather than an inference from timing or reference format. A processor’s own reference — whatever shape that processor happens to use — is stored beside it, unparsed. The platform never tries to read meaning out of a vendor’s identifier string.
This matters most where two rails coexist. A portfolio migrated from one processor to another keeps settlements attributable to the rail that actually reported them, years later, without anyone having to remember which reference format belonged to whom.
Configuration
Rail configuration lives under lendeasy.providers.moov:
| Property | Purpose |
|---|---|
mode |
Selects which client is bound to the ports. |
secret |
Shared secret for event signature verification. |
Nothing in the platform settles a payment on its own initiative. Settlement is always something a processor reports, and the pipeline that receives that report is the same pipeline in every environment — lower environments exercise the production path rather than a shortcut around it. Mode selection is fail-closed. A mode with no client bound to it throws at configuration time with an instruction naming what is missing, rather than starting and silently moving nothing. A deployment cannot believe it is connected when it is not — the failure happens at startup, in front of an engineer, instead of at the first payment, in front of a borrower.
What a processor integration supplies
Because the ports are neutral, onboarding a processor is a bounded list:
- A client answering as its own provider code against
ProviderMoneyMovementPort. - A mapping from the platform’s instrument token to the processor’s stored-instrument concept.
- Signature verification for that processor’s event format.
- A translation from the processor’s own event and status vocabulary into the six normalized events and the rail states that open the posting gate.
Everything downstream — the state machine, the posting gate, deduplication, the ledger, delinquency recalculation, reconciliation, receipts, every table and every route — is shared and unchanged.