> ## Documentation Index
> Fetch the complete documentation index at: https://docs.finosu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create Loans (Batch)

> Create or update (upsert) many loans asynchronously. Large batches are split server-side into parallel slices of 25 rows. Returns a job ID immediately; poll GET /loans/batch/{jobId} for progress and for every row that failed. Each row has the same semantics as POST /loans, including the upsert on (customerId, id), so resubmitting a batch updates rather than duplicates.

## Overview

Create or update (upsert) many loans in a single request. Use this for portfolio boarding
and for daily bulk syncs from your loan management system.

The endpoint processes loans **asynchronously** — it returns a job ID immediately
(HTTP 202 Accepted) and works the batch in the background. Poll
`GET /loans/batch/{jobId}` for progress and for every row that failed.

Each row has exactly the same semantics as [Create Loan](./create.mdx) — same fields,
same validation, same upsert behavior, same schedule creation. Nothing about a loan
behaves differently because it arrived in a batch.

<Note>
  Customers must exist before their loans are pushed. Board customers first via
  [`POST /customers/batch`](/api-reference/endpoint/customers/batch), then send this
  request. A row whose `customerId` is unknown fails individually and does not affect the
  rest of the batch.
</Note>

## Batch Size

There is no fixed limit on rows per request. Large batches are split server-side into
slices of 25 rows that are processed in parallel, so a boarding file does not have to be
chunked by hand.

That said, the whole request is buffered and validated before anything is queued, so
prefer requests in the low thousands over one enormous submission — it keeps validation
feedback fast and makes a network failure cheaper to retry.

Every `(customerId, id)` pair must be unique **within a single request**. Rows are worked
in parallel slices, so two rows naming the same loan would race — which one wins depends
on worker timing, and if they disagree on `amountDue` the loser is reported as a per-row
failure for a loan you did intend to send. We reject the request up front instead.

This is a different case from sending the same loan again in a **later** request, which
updates it rather than duplicating it — see [Idempotency](#idempotency).

## Request Body Format

An array of loan records wrapped in a `loans` field:

```json theme={null}
{
  "loans": [
    {
      "id": "LOAN-12345",
      "customerId": "CUST-001",
      "type": "CASH_ADVANCE",
      "applicationStatus": "FUNDED",
      "originalFundedAmount": 500.00,
      "originalTotalOwed": 650.00,
      "originationDate": "2026-04-01",
      "totalBalance": 425.00,
      "amountDue": 125.00,
      "dueDate": "2026-05-01",
      "servicingStatus": "ACTIVE",
      "chargeoffDate": "2026-06-15",
      "chargeoffAmount": 425.00,
      "portfolioId": "3607837d-2daf-4772-90db-638a2fa008df",
      "taskType": "Collections"
    },
    {
      "id": "LOAN-12346",
      "customerId": "CUST-002",
      "originationDate": "2026-03-10",
      "totalBalance": 890.50,
      "amountDue": 210.00,
      "dueDate": "2026-05-05",
      "servicingStatus": "ACTIVE"
    }
  ]
}
```

### Individual Loan Schema

Each object in the `loans` array follows the same schema as the
[single loan endpoint](./create.mdx), including all required fields (`id`, `customerId`,
`amountDue`, `dueDate`, `servicingStatus`, `totalBalance`, `originationDate`) and every
validation rule — non-negative balances, chargeoff field pairing, settlement percentage
pairing, and `servicingStatus: ACTIVE` on create.

## Response Format

```json theme={null}
{
  "jobId": "550e8400-e29b-41d4-a716-446655440000",
  "totalLoans": 400,
  "message": "Batch job created with 16 parallel batches. Use GET /loans/batch/550e8400-e29b-41d4-a716-446655440000 to check status."
}
```

### Response Fields

* **jobId** — Unique identifier for the batch job. Use it with `GET /loans/batch/{jobId}`
* **totalLoans** — Number of loans queued for processing
* **message** — How many parallel slices the job was split into, and how to poll it

## Checking Job Status

Use `GET /loans/batch/{jobId}` to check progress and read every row that failed:

```json theme={null}
{
  "jobId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "totalLoans": 400,
  "processed": 398,
  "failed": 2,
  "progressPercentage": 100.0,
  "createdAt": "2026-08-17T10:30:00+00:00",
  "processingStartedAt": "2026-08-17T10:30:04+00:00",
  "processingCompletedAt": "2026-08-17T10:31:42+00:00",
  "errorMessage": null,
  "failures": [
    {
      "index": 7,
      "id": "LOAN-12352",
      "customerId": "CUST-999",
      "message": "404: Customer with ID CUST-999 not found"
    },
    {
      "index": 261,
      "id": "LOAN-12606",
      "customerId": "CUST-014",
      "message": "400: amountDue is immutable. Existing value is 425.00, but request sent 500.00."
    }
  ]
}
```

### Status Values

* **pending** — Queued, not yet started
* **processing** — Slices are being worked
* **completed** — Finished. **Individual rows may still have failed** — check `failures`
* **failed** — Every row failed, or the job hit a fatal error. See `errorMessage`

<Warning>
  `status: completed` does **not** mean every loan succeeded — it means the job finished.
  Always reconcile against `failed` and `failures` rather than treating `completed` as
  success.
</Warning>

### Failures

`failures` lists **every** row that could not be written — never truncated, however many
there are. Successful rows are not enumerated; they are the set you submitted minus these.

| Field | Description |
| - | - |
| `index` | 0-based position of the row in the `loans` array you submitted |
| `id` | External loan reference ID, exactly as submitted |
| `customerId` | External customer reference ID, exactly as submitted |
| `message` | Why the row was rejected, prefixed with the status code the equivalent single `POST /loans` call would have returned |

`index` is the position in **your** array, not within a server-side slice, so you can walk
failures straight against your source file.

Common failure messages:

* `404: Customer with ID {customerId} not found` — board the customer first via
  [`POST /customers`](/api-reference/endpoint/customers/create) or
  [`POST /customers/batch`](/api-reference/endpoint/customers/batch)
* `400: amountDue is immutable...` — the row changed `amountDue` on a loan that already
  exists
* `400: Invalid type '...'. Valid values: [...]` — an unrecognised enum value, checked when
  the row is written rather than during request validation
* `400: Customer timezone is required for schedule creation...` — the customer has no valid
  IANA timezone, so no collections schedule can be built
* `500: Internal error processing this loan` — a fault on our side. The row was not
  written; resubmit it

### Polling

Slices publish their failures when they finish, so `failures` fills in in bursts and is
**partial** while `status` is `processing`. Poll until `status` is `completed` or `failed`
before treating the list as the final record. The array is always sorted by `index`
regardless of which slice finished first.

`progressPercentage` is `(processed + failed) / totalLoans * 100`, so it reaches `100` even
when some rows failed.

Status errors: `400` if `jobId` isn't a valid UUID, `404` if no loan batch job with that ID
exists for the authenticated company.

## Partial Success Handling

* **Individual failures don't stop processing** — a bad row is recorded and skipped; the
  remaining loans are still processed, including the rest of its own slice
* **Transaction isolation** — each loan is committed in its own transaction, so one
  failure can never roll back a loan that already succeeded
* **Nothing is truncated** — every failed row is reported, however many there are

Note the split between the two kinds of error. Anything the request **schema** can catch
— a negative balance, an unpaired `chargeoffDate`, a malformed `portfolioId`, an empty
`loans` array, a duplicate `(customerId, id)` — is rejected as `422` up front and
**nothing is queued**. Everything checked while the row is written — an unknown
`customerId`, a mismatched immutable `amountDue`, a customer with no valid timezone — is
reported per row in `failures`.

Enum-valued fields fall in that **second** group, which is easy to guess wrong. `type`,
`applicationStatus`, `servicingStatus`, and the `no*Reason` fields are plain strings on
the wire, so an unrecognised value passes request validation and is only rejected when
that row is written — a per-row `400`, not an up-front `422`. This matches
[Create Loan](./create.mdx), which returns `400` for the same mistake.

## Idempotency

Batch loan creation is **idempotent on `(customerId, id)`** across requests. Every row
upserts, so resubmitting a batch after a timeout or a partial failure updates the loans
that already landed rather than duplicating them. No schedule or scheduled payment is
created a second time.

Repeating a pair *within one request* is the separate case rejected up front — see
[Batch Size](#batch-size).

Retrying a batch verbatim is therefore always safe, and resubmitting the whole file is a
reasonable way to recover from a partial failure. Note that `amountDue` is immutable after
creation, so an unchanged resubmission is fine, but changing `amountDue` on a resubmitted
row fails that row.

## What the Endpoint Does

1. **Validates the whole request** — every row against the loan schema, plus the
   duplicate check. Any failure rejects the request with `422` and queues nothing
2. **Queues the job** — stores the submitted payload, splits it into slices of 25 rows,
   and returns HTTP 202 with a job ID
3. **Processes slices in parallel** — for each loan, in its own transaction:
   * Resolves the customer by `customerId`
   * Creates the loan, or updates it if an active loan with that `id` already exists
   * Creates the collections schedule for the resolved `taskType` (new loans only)
   * Creates the opening scheduled payment (new loans only)
   * Records the row on the job if it failed

## When to Use Batch vs Single

**Use the batch endpoint when:**

* Boarding an initial portfolio
* Running a daily or weekly bulk sync from your loan management system
* You have more than 5–10 loans to push

**Use the [single endpoint](./create.mdx) when:**

* Pushing a loan in real time as it boards
* You need the created loan echoed back synchronously — the batch response contains only
  a job ID, not loan bodies
* Testing or debugging one loan

## Error Handling

The endpoint returns `202 Accepted` whenever the request is well-formed. Request-level
failures:

| Status Code | Description |
| - | - |
| 401 | Missing, empty, invalid, inactive, or unknown `X-API-Key` |
| 422 | Any row fails schema validation, empty `loans` array, or a duplicate `(customerId, id)` |
| 500 | Could not store or queue the batch. Nothing was processed — safe to retry |

Per-row failures are **not** HTTP errors. They surface in the `failures` array of the
status response with the status code the equivalent single call
would have returned — `404` for an unknown `customerId`, `400` for a changed immutable
`amountDue`, an unrecognised enum value, a missing customer timezone, or a `taskType` that
isn't valid for your company.

## Authentication

Authentication via **X-API-Key header**:

* API key is company-scoped
* Must be obtained from Finosu
* Same authentication as the single loan endpoint
* A `jobId` is only visible to the company that created it

## Example Usage

This endpoint is typically used for:

* **Portfolio boarding** — push a placement's loans as they transfer
* **Daily reconciliation** — resend open accounts so balances and statuses stay in sync
* **Bulk LMS sync** — mirror loan state from your loan management system


## OpenAPI

````yaml POST /loans/batch
openapi: 3.1.0
info:
  title: Finosu API
  description: Finosu API
  license:
    name: MIT
  version: 1.0.0
servers:
  - url: https://api.finosu.com
security:
  - apiKeyAuth: []
paths:
  /loans/batch:
    post:
      summary: Create Loans (Batch)
      description: >-
        Create or update (upsert) many loans asynchronously. Large batches are
        split server-side into parallel slices of 25 rows. Returns a job ID
        immediately; poll GET /loans/batch/{jobId} for progress and for every
        row that failed. Each row has the same semantics as POST /loans,
        including the upsert on (customerId, id), so resubmitting a batch
        updates rather than duplicates.
      requestBody:
        description: Batch of loan records
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - loans
              properties:
                loans:
                  type: array
                  minItems: 1
                  description: >-
                    Array of loan records to create. Every (customerId, id) pair
                    must be unique within the request.
                  items:
                    $ref: '#/components/schemas/NewLoan'
      responses:
        '202':
          description: >-
            Batch job accepted for async processing. Use GET
            /loans/batch/{jobId} to check status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoanBatchResponse'
        '422':
          description: >-
            A row failed schema validation, empty loans array, or a duplicate
            (customerId, id) pair. Nothing was queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Could not store or queue the batch. Nothing was processed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    NewLoan:
      required:
        - id
        - customerId
        - amountDue
        - dueDate
        - servicingStatus
        - totalBalance
        - originationDate
      type: object
      properties:
        id:
          description: Your stable loan reference ID
          type: string
          example: LOAN-12345
        customerId:
          description: External reference of a previously-pushed customer
          type: string
          example: CUST-001
        type:
          type: string
          enum:
            - CASH_ADVANCE
            - PERSONAL_LOAN
            - INSTALLMENT_LOAN
        applicationStatus:
          type: string
          enum:
            - PENDING
            - APPROVED
            - FUNDED
            - WITHDRAWN
            - REJECTED
        originalFundedAmount:
          type: number
          minimum: 0
          example: 500
        originalTotalOwed:
          type: number
          minimum: 0
          example: 650
        interestRate:
          description: Non-negative decimal where 1.0 = 100% (e.g. 6.49 = 649%)
          type: number
          minimum: 0
          example: 0.2999
        apr:
          description: Non-negative decimal where 1.0 = 100%
          type: number
          minimum: 0
          example: 0.355
        originationDate:
          description: Required. Date the loan was originated.
          type: string
          format: date
          example: '2026-04-01'
        startDate:
          type: string
          format: date
        totalBalance:
          description: Required. Current outstanding balance.
          type: number
          minimum: 0
          example: 425
        payoffAmount:
          type: number
          minimum: 0
        totalPayoffAmount:
          type: number
          minimum: 0
        balanceAtTransfer:
          type: number
          minimum: 0
        preTransferPayments:
          type: number
          minimum: 0
        amountDue:
          description: >-
            Next scheduled installment amount. Charged-off loans with no
            scheduled installment may send 0 when chargeoffDate and
            chargeoffAmount are provided. Must be non-negative and is immutable
            after creation. A zero amount does not create an initial
            scheduled-payment obligation.
          type: number
          minimum: 0
          example: 125
        dueDate:
          description: >-
            Next scheduled installment date. For a charged-off loan with
            amountDue 0, send the last missed contractual due date, or
            chargeoffDate when that date is unavailable. Do not send the
            borrower's next payroll date.
          type: string
          format: date
          example: '2026-05-01'
        servicingStatus:
          description: Must be ACTIVE on create. Use cancel-servicing endpoint to stop.
          type: string
          enum:
            - ACTIVE
        isSettled:
          type: boolean
        chargeoffDate:
          description: Must be paired with chargeoffAmount
          type: string
          format: date
        chargeoffAmount:
          description: Must be paired with chargeoffDate
          type: number
          minimum: 0
        autopayEnabled:
          type: boolean
          example: true
        noCallReason:
          type: string
          enum:
            - CUSTOMER_OPT_OUT
            - LEGAL_HOLD
            - BANKRUPT
            - INCARCERATED
            - DECEASED
            - MILITARY
            - FRAUD
            - AGENCY_REQUEST
            - COMPLAINT
            - WRONG_NUMBER
            - OTHER
        noTextReason:
          type: string
          enum:
            - CUSTOMER_OPT_OUT
            - LEGAL_HOLD
            - BANKRUPT
            - INCARCERATED
            - DECEASED
            - MILITARY
            - FRAUD
            - AGENCY_REQUEST
            - COMPLAINT
            - WRONG_NUMBER
            - OTHER
        noEmailReason:
          type: string
          enum:
            - CUSTOMER_OPT_OUT
            - LEGAL_HOLD
            - BANKRUPT
            - INCARCERATED
            - DECEASED
            - MILITARY
            - FRAUD
            - AGENCY_REQUEST
            - COMPLAINT
            - WRONG_NUMBER
            - OTHER
        isVisibleInBorrowerPortal:
          type: boolean
          example: true
        daysPastDueAtBoarding:
          type: integer
          minimum: 0
        daysSinceOriginationAtBoarding:
          type: integer
          minimum: 0
        externalLmsMetadata:
          type: object
        portfolioId:
          description: Portfolio UUID for payment processor routing
          type: string
          format: uuid
        paymentPlanMinPercentage:
          description: >-
            Min settlement percentage (0-1). Min and max must be supplied
            together. When both are omitted, the loan is pay-in-full unless an
            approved lender-specific creation policy derives a band.
          type: number
          minimum: 0
          maximum: 1
        paymentPlanMaxPercentage:
          description: >-
            Max settlement percentage (0-1). Min and max must be supplied
            together. When both are omitted, the loan is pay-in-full unless an
            approved lender-specific creation policy derives a band.
          type: number
          minimum: 0
          maximum: 1
        taskType:
          description: >-
            Schedule task type (e.g. Collections). Defaults to Collections for
            JustLoans.
          type: string
          example: Collections
    LoanBatchResponse:
      type: object
      properties:
        jobId:
          type: string
          format: uuid
          description: Job ID. Use with GET /loans/batch/{jobId}
          example: 550e8400-e29b-41d4-a716-446655440000
        totalLoans:
          type: integer
          description: Number of loans queued for processing
          example: 400
        message:
          type: string
          description: How many parallel slices the job was split into, and how to poll it
          example: >-
            Batch job created with 16 parallel batches. Use GET
            /loans/batch/550e8400-e29b-41d4-a716-446655440000 to check status.
    Error:
      required:
        - error
        - message
      type: object
      properties:
        error:
          type: integer
          format: int32
        message:
          type: string
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

````