Skip to main content

Summary

Payment-plan retrieval is proposed and is not yet available. We are tightening the required-field contract on POST /customers and POST /customers/batch so that every customer we board arrives with the data needed to contact them lawfully and verify their identity. The redundant loanId field is being removed at the same time. The proposed required-field and loanId changes remain unenforced. We will notify partners before introducing those changes. Endpoints affected by the Create a Customer required-field changes:
  • POST /customers
  • POST /customers/batch customer-create rows (same per-customer payload); scheduleMode: "enroll" rows use origination enrollment and are not affected by the required-field changes
PUT /customers/{id} and loan, call, text, and payment endpoints are not affected by the proposed required-field changes. Customer updates do participate in the validation-status migration described below.

What is changing

id, taskType, streetAddress1, and city are unchanged. streetAddress2, doNotCall, externalMetadata, and loanIds remain optional.

Why each change

At least one of email or phoneNumber. Every customer needs at least one channel we can reach them on — a record with neither cannot be worked. Today phoneNumber is unconditionally required, which turns away email-only accounts. Relaxing that while requiring one of the pair is both stricter in effect and more flexible in practice. At least one of timezone or zipCode. We may only place calls during hours permitted by the borrower’s local time, so TCPA compliance depends on knowing where they are. Under the proposed contract, supplying either will be sufficient: when timezone is omitted we will derive it from zipCode. If the ZIP cannot be resolved, the proposed behavior is to reject the request with 422. This stricter ZIP behavior is not live. Today, with sync: true, customer creation defaults to America/New_York when a supplied or fetched ZIP cannot be resolved; if both timezone and ZIP are absent, it returns 422 for missing timezone. Replacement schedule sync defaults to America/New_York even when the ZIP is absent. state always required. State determines which lending and collections rules apply to the account, and several states are restricted per-portfolio. We need it regardless of how the rest of the record was populated. birthday and ssnLastFour required. Both are used to verify a borrower’s identity before discussing account details on a call. Without them, an agent cannot complete verification and the conversation cannot proceed. loanId removed. On POST /customers this field is accepted and then ignored — it has never had any effect. loanIds is the field that actually controls which loans are imported, so a single loan ID belongs there as a one-element array.

Migrating your payload

A request that is valid today:
The same request under the new contract — birthday and ssnLastFour added, loanId moved into loanIds:
Note that loanIds is an array of integers, not strings. After the proposed field changes take effect, email/phoneNumber and timezone/zipCode will be at-least-one pairs. The following minimal payload is not valid today:

What you can do now vs. what to wait for

Read this section carefully if you plan to migrate early — some of the new fields are not yet wired up. Safe to do now:
  • Start sending birthday, ssnLastFour, and state on every request. All three are already accepted and stored today. Birthday and SSN remain optional; supplied nonempty birthdays are validated, and state is already required unless sync: true.
  • Move a single loanId value into loanIds. loanIds is already honored; loanId is already ignored on this endpoint, so there is no behavior to lose.
Only after the change takes effect:
  • Dropping phoneNumber (relying on email alone) or dropping timezone (relying on zipCode alone) on requests without sync: true. These requests return 422 today. LMS auto-fetch retains its existing enrichment and timezone fallback behavior.

Error behavior

Missing required fields return 422 Unprocessable Content with the offending fields named. For example, today a request missing both first name and phone after any LMS enrichment returns:
After the proposed field changes take effect, errors for the at-least-one pairs will name the pair. Rejecting an unresolvable ZIP with 422 is also proposed; the current sync: true fallback described above still applies.

Customer validation errors moved from 400 to 422

Customer creation, updates, replacement schedule sync, servicing cancellation and batch-job UUID validation now return 422 for invalid input. Enrollment retains its structured detail object and other validation errors use strings or framework field-error arrays. Invalid birthday, phone or timezone inputs are rejected before writes, including cases previously reported as 500. The customer upsert webhook validates phone, birthday and supplied timezone before persistence. Empty or whitespace-only webhook birthdays now return 422. Endpoint-generated string validation messages omit raw phone, birthday and timezone values. Framework schema-error arrays can include submitted values in input; this change does not redact those arrays. These changes affect clients matching status codes or text. LMS auto-fetch failures return 502; malformed optional provider birthdays are skipped without clearing an existing birthday. Unexpected internal failures remain 500. Accepted customer batches retain HTTP 202. Customer-create row validation failures use 422: message prefixes, known provider failures use 502:, and unexpected internal failures use 500:. Enrollment rows retain their structured error codes. Do not confuse those prefixes with the batch HTTP status. During rollout, accept both 400 and 422 for migrated validation and update prefix matching. Company/workflow configuration and replacement-mode Do Not Call refusals retain 400. Enrollment Do Not Call conflicts retain 409; authentication and missing-resource statuses remain unchanged. Do not retry invalid input unchanged; follow each operation’s idempotency behavior before retrying writes after server failures.

Proposed payment-plan retrieval

Proposal only — not implemented or available for integration. No release date has been announced. All fields, examples, calculations, status meanings, error responses, and polling behavior below describe the proposed contract and remain subject to implementation and review. The curl examples are illustrative and cannot be used to retrieve a payment plan today.

Overview

Two proposed read-only endpoints, both authenticated with X-API-Key and scoped to the company associated with that key: The intended use is to board a payment plan into your servicing system and reconcile payments collected against it. The sections below describe the proposed integration behavior, not an available endpoint.
To board or reconcile a plan, make one call: GET /loans/{id}/payment-plan?includeScheduledPayments=true&includePayments=true. paidToDate and remainingAmount are computed from the scheduled payments and payments in the same response, so they always agree with them. Use the scheduled-payments endpoint when you already hold a planId, for example one stored from an earlier poll.

Proposed request: current plan

GET /loans/{id}/payment-plan This endpoint always returns the loan’s current plan. A plan that was replaced by a newer one is not returned here; fetch its scheduled payments by planId instead.

Proposed request: scheduled payments of a plan

GET /loans/{id}/payment-plans/{planId}/scheduled-payments The response is an ScheduledPaymentList. A 404 means the loan does not exist for your company, or the planId does not belong to that loan.
The example shows one scheduled payment for brevity; the response returns every scheduled payment of the plan, ordered by due date.

Reading across two calls

planVersion changes whenever anything in the plan changes: a payment posts, credit is applied, or the schedule is rebuilt. Both endpoints return it. If you fetch the plan and its scheduled payments in separate calls, first check that both responses have the same planId. A different planId means the plan you asked about has been replaced, and the current-plan endpoint is describing its successor. With the same planId, the identities below hold only when both responses also have the same planVersion. If the planVersion values differ, something changed in between; fetch again, or use the single-call form above.

Which scheduled payments to board

scheduledPayments[].status is the single field that answers this. Board a scheduled payment only when its status is one of: Every other status means the scheduled payment is not a live obligation and must not be boarded:
OVERDUE and MISSED both describe non-payment, and the difference matters. With OVERDUE the money is still owed on that scheduled payment, so keep collecting against it. With MISSED the amount has already been carried onto a later scheduled payment, so collecting against the MISSED one would count the same money twice.

Reading the money

remainingAmount is a sum over scheduled payments

totals.remainingAmount is the sum of amountRemaining across scheduled payments whose status is PENDING, PARTIALLY_PAID or OVERDUE.
remainingAmount is the balance, already computed from the scheduled payments.creditsApplied is money that paidToDate has already counted. It comes from overpayments, where the excess on one scheduled payment is applied to another, so subtracting it from discountedTotal alongside paidToDate would deduct the same money twice.remainingAmount is summed from the scheduled payments, so it stays correct however credit arises.

amountRemaining is only meaningful on live scheduled payments

Every scheduled payment reports max(0, scheduledAmount - amountPaid - creditsApplied), including ones that are no longer obligations. A MISSED or POSTPONED scheduled payment therefore shows a non-zero amountRemaining even though that amount has moved elsewhere. Sum amountRemaining only across scheduled payments whose status is PENDING, PARTIALLY_PAID or OVERDUE, or use totals.remainingAmount, which does this for you.

offPlatformPaymentsTotal is a portion of paidToDate

It reports how much of what paidToDate already counts was collected outside Finosu (payments with source: "EXTERNAL").
This is a portion of paidToDate, not a separate amount to add to it. The off-platform payments it reports are already counted there, so adding the two together would count each of them twice.

Totals you can verify against the scheduled payments

The proposed contract requires these identities for a response from the current-plan endpoint with includeScheduledPayments=true, includePayments=true, and includeRetired=true. The first identity also holds across two calls when they report the same planId and planVersion: paidToDate from the current-plan endpoint, and the rows from the scheduled-payments endpoint with includeRetired=true.

Credits

creditsApplied is credit applied to a scheduled payment from the loan’s credit balance. That balance comes from overpayments. When a payment covers more than the scheduled payment it was applied to, the excess is held as credit and later applied to another scheduled payment. Credit is reported separately from amountPaid because the money was already counted once, as a payment against the scheduled payment the overpayment landed on. Counting it again would double it.

Dates and timing

  • scheduledPayments[].dueDate is authoritative for when a scheduled payment is actually due. It carries a UTC offset and reflects any rescheduling.
  • cadence describes the rule the plan was created under, not the current scheduled payments. Its anchor days derive from the plan’s original start date, which does not change when a schedule is rebuilt — so after a reschedule cadence can disagree with the actual due dates. Use it to understand the intended pattern; use dueDate for real dates.
  • transactedAt and finalizedAt are Finosu-side timestamps, not bank confirmations. transactedAt is when the payment was submitted or took effect; finalizedAt is when Finosu considers it settled. For ACH, neither is a guarantee of when funds moved at your bank. Apply your own settlement rules.

Identifiers

  • planId identifies the plan and is stable for the life of the plan. It stays valid after the plan is replaced, so you can still fetch that plan’s scheduled payments.
  • planVersion identifies the plan’s state, not the plan itself. It changes whenever the plan does, so do not store it as an identifier.
  • scheduledPaymentId and paymentId are stable Finosu identifiers. Store them if you want to correlate across polls.
  • reference on a payment echoes the external reference you supplied when recording it, so you can join back to your own ledger.

When there is no plan

A 200 response with paymentPlan: null means the loan exists but has no active plan. This is normal — the borrower may never have enrolled, or the plan may have completed or been cancelled.
Do not treat paymentPlan: null as evidence that a plan was broken. A completed plan and a cancelled plan both read as null here. If your system holds an active plan for this loan, stop expecting drafts and flag it for review rather than assuming default.
A 404 is different: it means no loan with that reference exists for your company. Treat it as an integration error, not a business state.

Polling efficiently

Both endpoints return an ETag. Send it back as If-None-Match and an unchanged plan responds 304 Not Modified with no body, which keeps a nightly sweep across a portfolio cheap.
Send the same query parameters on every poll. An ETag describes one response, so an ETag taken from a call without includeScheduledPayments does not match a call with it.

Example

The plan originally had four installments of $206.67, due May 15, May 29, June 12, and June 26, for a total of $826.68. When the May 29 installment was missed, a new replacement installment for the same $206.67 was added for July 10. It was not added to the amount of an existing installment. The response retains the missed row for history, so it shows five rows but only four obligations across the life of the plan. The response below is from GET /loans/L-88213/payment-plan?includeScheduledPayments=true&includePayments=true. Without the two parameters, the same call returns everything except the scheduledPayments and payments arrays. A biweekly plan viewed in late June, showing five of the eight possible statuses: PAID (by two payments, the second of which overpaid it), MISSED, OVERDUE, PARTIALLY_PAID (from the resulting credit), and PENDING. SKIPPED, POSTPONED and CANCELLED are not shown here — see the status tables above for all eight.
scheduledAmount values may not sum to discountedTotal. scheduledPayments includes scheduled payments that are no longer obligations, whose amounts were moved onto others or forgiven, so they can total more than the plan itself. In this example the five come to 1033.35 against a discountedTotal of 826.68. The difference is the historical MISSED row from May 29. Its obligation is represented by the new July 10 PENDING row, not by an increase to the June rows.discountedTotal is the authorized total, and remainingAmount the outstanding balance.
Reading the example: The first scheduled payment shows amountPaid: 300.00 against a scheduledAmount of 206.67. It reports only the outcome (PAID) — that it took two payments, and that the borrower was five days late completing it, is visible only in payments[]. Note the first of those payments has source: "EXTERNAL", which is why offPlatformPaymentsTotal is 100.00: money the lender collected and uploaded, already counted inside paidToDate. The 93.33 excess (300.00 - 206.67) appears as creditsApplied on the fourth scheduled payment, which is why it is PARTIALLY_PAID with amountRemaining: 113.34 despite amountPaid: 0.00. The second scheduled payment is MISSED. Its FAILED payment is in payments[]. The fifth row, due July 10 with status PENDING, is its replacement and carries the same $206.67 obligation. The June 12 and June 26 amounts are unchanged. Do not board the historical May 29 row as well, or you would collect the same obligation twice. Checking the balance: remainingAmount (526.68) is the sum of amountRemaining across the three boardable scheduled payments (206.67 + 113.34 + 206.67), and equals discountedTotal - paidToDate (826.68 - 300.00). It is not discountedTotal - paidToDate - creditsApplied (433.35). That would deduct the 93.33 twice: once as the payment that created it, and again as the credit it became.

Error Responses

Proposed response field reference

These draft fields supplement the worked example above; they are not a published runtime schema.

PaymentPlanEnvelope

PaymentPlan

ScheduledPaymentList

Returned by GET /loans/{id}/payment-plans/{planId}/scheduled-payments.

Cadence

PlanTotals

ScheduledPayment

Payment

The final contract and availability will be published in the API reference when released. No integration changes are required now.

Questions

If any of these fields are not available in your system, or you need more time to adjust, contact your Finosu account manager so we can plan around it.