curl --request POST \
--url https://api.finosu.com/loans/batch \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"loans": [
{
"id": "LOAN-12345",
"customerId": "CUST-001",
"originationDate": "2026-04-01",
"totalBalance": 425,
"amountDue": 125,
"dueDate": "2026-05-01",
"servicingStatus": "ACTIVE",
"originalFundedAmount": 500,
"originalTotalOwed": 650,
"interestRate": 0.2999,
"apr": 0.355,
"startDate": "2023-12-25",
"payoffAmount": 1,
"totalPayoffAmount": 1,
"balanceAtTransfer": 1,
"preTransferPayments": 1,
"isSettled": true,
"chargeoffDate": "2023-12-25",
"chargeoffAmount": 1,
"autopayEnabled": true,
"isVisibleInBorrowerPortal": true,
"daysPastDueAtBoarding": 1,
"daysSinceOriginationAtBoarding": 1,
"externalLmsMetadata": {},
"portfolioId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"paymentPlanMinPercentage": 0.5,
"paymentPlanMaxPercentage": 0.5,
"taskType": "Collections"
}
]
}
'import requests
url = "https://api.finosu.com/loans/batch"
payload = { "loans": [
{
"id": "LOAN-12345",
"customerId": "CUST-001",
"originationDate": "2026-04-01",
"totalBalance": 425,
"amountDue": 125,
"dueDate": "2026-05-01",
"servicingStatus": "ACTIVE",
"originalFundedAmount": 500,
"originalTotalOwed": 650,
"interestRate": 0.2999,
"apr": 0.355,
"startDate": "2023-12-25",
"payoffAmount": 1,
"totalPayoffAmount": 1,
"balanceAtTransfer": 1,
"preTransferPayments": 1,
"isSettled": True,
"chargeoffDate": "2023-12-25",
"chargeoffAmount": 1,
"autopayEnabled": True,
"isVisibleInBorrowerPortal": True,
"daysPastDueAtBoarding": 1,
"daysSinceOriginationAtBoarding": 1,
"externalLmsMetadata": {},
"portfolioId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"paymentPlanMinPercentage": 0.5,
"paymentPlanMaxPercentage": 0.5,
"taskType": "Collections"
}
] }
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
loans: [
{
id: 'LOAN-12345',
customerId: 'CUST-001',
originationDate: '2026-04-01',
totalBalance: 425,
amountDue: 125,
dueDate: '2026-05-01',
servicingStatus: 'ACTIVE',
originalFundedAmount: 500,
originalTotalOwed: 650,
interestRate: 0.2999,
apr: 0.355,
startDate: '2023-12-25',
payoffAmount: 1,
totalPayoffAmount: 1,
balanceAtTransfer: 1,
preTransferPayments: 1,
isSettled: true,
chargeoffDate: '2023-12-25',
chargeoffAmount: 1,
autopayEnabled: true,
isVisibleInBorrowerPortal: true,
daysPastDueAtBoarding: 1,
daysSinceOriginationAtBoarding: 1,
externalLmsMetadata: {},
portfolioId: '3c90c3cc-0d44-4b50-8888-8dd25736052a',
paymentPlanMinPercentage: 0.5,
paymentPlanMaxPercentage: 0.5,
taskType: 'Collections'
}
]
})
};
fetch('https://api.finosu.com/loans/batch', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.finosu.com/loans/batch",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'loans' => [
[
'id' => 'LOAN-12345',
'customerId' => 'CUST-001',
'originationDate' => '2026-04-01',
'totalBalance' => 425,
'amountDue' => 125,
'dueDate' => '2026-05-01',
'servicingStatus' => 'ACTIVE',
'originalFundedAmount' => 500,
'originalTotalOwed' => 650,
'interestRate' => 0.2999,
'apr' => 0.355,
'startDate' => '2023-12-25',
'payoffAmount' => 1,
'totalPayoffAmount' => 1,
'balanceAtTransfer' => 1,
'preTransferPayments' => 1,
'isSettled' => true,
'chargeoffDate' => '2023-12-25',
'chargeoffAmount' => 1,
'autopayEnabled' => true,
'isVisibleInBorrowerPortal' => true,
'daysPastDueAtBoarding' => 1,
'daysSinceOriginationAtBoarding' => 1,
'externalLmsMetadata' => [
],
'portfolioId' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'paymentPlanMinPercentage' => 0.5,
'paymentPlanMaxPercentage' => 0.5,
'taskType' => 'Collections'
]
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.finosu.com/loans/batch"
payload := strings.NewReader("{\n \"loans\": [\n {\n \"id\": \"LOAN-12345\",\n \"customerId\": \"CUST-001\",\n \"originationDate\": \"2026-04-01\",\n \"totalBalance\": 425,\n \"amountDue\": 125,\n \"dueDate\": \"2026-05-01\",\n \"servicingStatus\": \"ACTIVE\",\n \"originalFundedAmount\": 500,\n \"originalTotalOwed\": 650,\n \"interestRate\": 0.2999,\n \"apr\": 0.355,\n \"startDate\": \"2023-12-25\",\n \"payoffAmount\": 1,\n \"totalPayoffAmount\": 1,\n \"balanceAtTransfer\": 1,\n \"preTransferPayments\": 1,\n \"isSettled\": true,\n \"chargeoffDate\": \"2023-12-25\",\n \"chargeoffAmount\": 1,\n \"autopayEnabled\": true,\n \"isVisibleInBorrowerPortal\": true,\n \"daysPastDueAtBoarding\": 1,\n \"daysSinceOriginationAtBoarding\": 1,\n \"externalLmsMetadata\": {},\n \"portfolioId\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"paymentPlanMinPercentage\": 0.5,\n \"paymentPlanMaxPercentage\": 0.5,\n \"taskType\": \"Collections\"\n }\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.finosu.com/loans/batch")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"loans\": [\n {\n \"id\": \"LOAN-12345\",\n \"customerId\": \"CUST-001\",\n \"originationDate\": \"2026-04-01\",\n \"totalBalance\": 425,\n \"amountDue\": 125,\n \"dueDate\": \"2026-05-01\",\n \"servicingStatus\": \"ACTIVE\",\n \"originalFundedAmount\": 500,\n \"originalTotalOwed\": 650,\n \"interestRate\": 0.2999,\n \"apr\": 0.355,\n \"startDate\": \"2023-12-25\",\n \"payoffAmount\": 1,\n \"totalPayoffAmount\": 1,\n \"balanceAtTransfer\": 1,\n \"preTransferPayments\": 1,\n \"isSettled\": true,\n \"chargeoffDate\": \"2023-12-25\",\n \"chargeoffAmount\": 1,\n \"autopayEnabled\": true,\n \"isVisibleInBorrowerPortal\": true,\n \"daysPastDueAtBoarding\": 1,\n \"daysSinceOriginationAtBoarding\": 1,\n \"externalLmsMetadata\": {},\n \"portfolioId\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"paymentPlanMinPercentage\": 0.5,\n \"paymentPlanMaxPercentage\": 0.5,\n \"taskType\": \"Collections\"\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.finosu.com/loans/batch")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"loans\": [\n {\n \"id\": \"LOAN-12345\",\n \"customerId\": \"CUST-001\",\n \"originationDate\": \"2026-04-01\",\n \"totalBalance\": 425,\n \"amountDue\": 125,\n \"dueDate\": \"2026-05-01\",\n \"servicingStatus\": \"ACTIVE\",\n \"originalFundedAmount\": 500,\n \"originalTotalOwed\": 650,\n \"interestRate\": 0.2999,\n \"apr\": 0.355,\n \"startDate\": \"2023-12-25\",\n \"payoffAmount\": 1,\n \"totalPayoffAmount\": 1,\n \"balanceAtTransfer\": 1,\n \"preTransferPayments\": 1,\n \"isSettled\": true,\n \"chargeoffDate\": \"2023-12-25\",\n \"chargeoffAmount\": 1,\n \"autopayEnabled\": true,\n \"isVisibleInBorrowerPortal\": true,\n \"daysPastDueAtBoarding\": 1,\n \"daysSinceOriginationAtBoarding\": 1,\n \"externalLmsMetadata\": {},\n \"portfolioId\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"paymentPlanMinPercentage\": 0.5,\n \"paymentPlanMaxPercentage\": 0.5,\n \"taskType\": \"Collections\"\n }\n ]\n}"
response = http.request(request)
puts response.read_body{
"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."
}{
"error": 123,
"message": "<string>"
}{
"error": 123,
"message": "<string>"
}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/ 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.
curl --request POST \
--url https://api.finosu.com/loans/batch \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"loans": [
{
"id": "LOAN-12345",
"customerId": "CUST-001",
"originationDate": "2026-04-01",
"totalBalance": 425,
"amountDue": 125,
"dueDate": "2026-05-01",
"servicingStatus": "ACTIVE",
"originalFundedAmount": 500,
"originalTotalOwed": 650,
"interestRate": 0.2999,
"apr": 0.355,
"startDate": "2023-12-25",
"payoffAmount": 1,
"totalPayoffAmount": 1,
"balanceAtTransfer": 1,
"preTransferPayments": 1,
"isSettled": true,
"chargeoffDate": "2023-12-25",
"chargeoffAmount": 1,
"autopayEnabled": true,
"isVisibleInBorrowerPortal": true,
"daysPastDueAtBoarding": 1,
"daysSinceOriginationAtBoarding": 1,
"externalLmsMetadata": {},
"portfolioId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"paymentPlanMinPercentage": 0.5,
"paymentPlanMaxPercentage": 0.5,
"taskType": "Collections"
}
]
}
'import requests
url = "https://api.finosu.com/loans/batch"
payload = { "loans": [
{
"id": "LOAN-12345",
"customerId": "CUST-001",
"originationDate": "2026-04-01",
"totalBalance": 425,
"amountDue": 125,
"dueDate": "2026-05-01",
"servicingStatus": "ACTIVE",
"originalFundedAmount": 500,
"originalTotalOwed": 650,
"interestRate": 0.2999,
"apr": 0.355,
"startDate": "2023-12-25",
"payoffAmount": 1,
"totalPayoffAmount": 1,
"balanceAtTransfer": 1,
"preTransferPayments": 1,
"isSettled": True,
"chargeoffDate": "2023-12-25",
"chargeoffAmount": 1,
"autopayEnabled": True,
"isVisibleInBorrowerPortal": True,
"daysPastDueAtBoarding": 1,
"daysSinceOriginationAtBoarding": 1,
"externalLmsMetadata": {},
"portfolioId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"paymentPlanMinPercentage": 0.5,
"paymentPlanMaxPercentage": 0.5,
"taskType": "Collections"
}
] }
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
loans: [
{
id: 'LOAN-12345',
customerId: 'CUST-001',
originationDate: '2026-04-01',
totalBalance: 425,
amountDue: 125,
dueDate: '2026-05-01',
servicingStatus: 'ACTIVE',
originalFundedAmount: 500,
originalTotalOwed: 650,
interestRate: 0.2999,
apr: 0.355,
startDate: '2023-12-25',
payoffAmount: 1,
totalPayoffAmount: 1,
balanceAtTransfer: 1,
preTransferPayments: 1,
isSettled: true,
chargeoffDate: '2023-12-25',
chargeoffAmount: 1,
autopayEnabled: true,
isVisibleInBorrowerPortal: true,
daysPastDueAtBoarding: 1,
daysSinceOriginationAtBoarding: 1,
externalLmsMetadata: {},
portfolioId: '3c90c3cc-0d44-4b50-8888-8dd25736052a',
paymentPlanMinPercentage: 0.5,
paymentPlanMaxPercentage: 0.5,
taskType: 'Collections'
}
]
})
};
fetch('https://api.finosu.com/loans/batch', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.finosu.com/loans/batch",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'loans' => [
[
'id' => 'LOAN-12345',
'customerId' => 'CUST-001',
'originationDate' => '2026-04-01',
'totalBalance' => 425,
'amountDue' => 125,
'dueDate' => '2026-05-01',
'servicingStatus' => 'ACTIVE',
'originalFundedAmount' => 500,
'originalTotalOwed' => 650,
'interestRate' => 0.2999,
'apr' => 0.355,
'startDate' => '2023-12-25',
'payoffAmount' => 1,
'totalPayoffAmount' => 1,
'balanceAtTransfer' => 1,
'preTransferPayments' => 1,
'isSettled' => true,
'chargeoffDate' => '2023-12-25',
'chargeoffAmount' => 1,
'autopayEnabled' => true,
'isVisibleInBorrowerPortal' => true,
'daysPastDueAtBoarding' => 1,
'daysSinceOriginationAtBoarding' => 1,
'externalLmsMetadata' => [
],
'portfolioId' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'paymentPlanMinPercentage' => 0.5,
'paymentPlanMaxPercentage' => 0.5,
'taskType' => 'Collections'
]
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.finosu.com/loans/batch"
payload := strings.NewReader("{\n \"loans\": [\n {\n \"id\": \"LOAN-12345\",\n \"customerId\": \"CUST-001\",\n \"originationDate\": \"2026-04-01\",\n \"totalBalance\": 425,\n \"amountDue\": 125,\n \"dueDate\": \"2026-05-01\",\n \"servicingStatus\": \"ACTIVE\",\n \"originalFundedAmount\": 500,\n \"originalTotalOwed\": 650,\n \"interestRate\": 0.2999,\n \"apr\": 0.355,\n \"startDate\": \"2023-12-25\",\n \"payoffAmount\": 1,\n \"totalPayoffAmount\": 1,\n \"balanceAtTransfer\": 1,\n \"preTransferPayments\": 1,\n \"isSettled\": true,\n \"chargeoffDate\": \"2023-12-25\",\n \"chargeoffAmount\": 1,\n \"autopayEnabled\": true,\n \"isVisibleInBorrowerPortal\": true,\n \"daysPastDueAtBoarding\": 1,\n \"daysSinceOriginationAtBoarding\": 1,\n \"externalLmsMetadata\": {},\n \"portfolioId\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"paymentPlanMinPercentage\": 0.5,\n \"paymentPlanMaxPercentage\": 0.5,\n \"taskType\": \"Collections\"\n }\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.finosu.com/loans/batch")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"loans\": [\n {\n \"id\": \"LOAN-12345\",\n \"customerId\": \"CUST-001\",\n \"originationDate\": \"2026-04-01\",\n \"totalBalance\": 425,\n \"amountDue\": 125,\n \"dueDate\": \"2026-05-01\",\n \"servicingStatus\": \"ACTIVE\",\n \"originalFundedAmount\": 500,\n \"originalTotalOwed\": 650,\n \"interestRate\": 0.2999,\n \"apr\": 0.355,\n \"startDate\": \"2023-12-25\",\n \"payoffAmount\": 1,\n \"totalPayoffAmount\": 1,\n \"balanceAtTransfer\": 1,\n \"preTransferPayments\": 1,\n \"isSettled\": true,\n \"chargeoffDate\": \"2023-12-25\",\n \"chargeoffAmount\": 1,\n \"autopayEnabled\": true,\n \"isVisibleInBorrowerPortal\": true,\n \"daysPastDueAtBoarding\": 1,\n \"daysSinceOriginationAtBoarding\": 1,\n \"externalLmsMetadata\": {},\n \"portfolioId\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"paymentPlanMinPercentage\": 0.5,\n \"paymentPlanMaxPercentage\": 0.5,\n \"taskType\": \"Collections\"\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.finosu.com/loans/batch")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"loans\": [\n {\n \"id\": \"LOAN-12345\",\n \"customerId\": \"CUST-001\",\n \"originationDate\": \"2026-04-01\",\n \"totalBalance\": 425,\n \"amountDue\": 125,\n \"dueDate\": \"2026-05-01\",\n \"servicingStatus\": \"ACTIVE\",\n \"originalFundedAmount\": 500,\n \"originalTotalOwed\": 650,\n \"interestRate\": 0.2999,\n \"apr\": 0.355,\n \"startDate\": \"2023-12-25\",\n \"payoffAmount\": 1,\n \"totalPayoffAmount\": 1,\n \"balanceAtTransfer\": 1,\n \"preTransferPayments\": 1,\n \"isSettled\": true,\n \"chargeoffDate\": \"2023-12-25\",\n \"chargeoffAmount\": 1,\n \"autopayEnabled\": true,\n \"isVisibleInBorrowerPortal\": true,\n \"daysPastDueAtBoarding\": 1,\n \"daysSinceOriginationAtBoarding\": 1,\n \"externalLmsMetadata\": {},\n \"portfolioId\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"paymentPlanMinPercentage\": 0.5,\n \"paymentPlanMaxPercentage\": 0.5,\n \"taskType\": \"Collections\"\n }\n ]\n}"
response = http.request(request)
puts response.read_body{
"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."
}{
"error": 123,
"message": "<string>"
}{
"error": 123,
"message": "<string>"
}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. PollGET /loans/batch/{jobId} for progress and for every row that failed.
Each row has exactly the same semantics as Create Loan — same fields,
same validation, same upsert behavior, same schedule creation. Nothing about a loan
behaves differently because it arrived in a batch.
POST /customers/batch, then send this
request. A row whose customerId is unknown fails individually and does not affect the
rest of the batch.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.
Request Body Format
An array of loan records wrapped in aloans field:
{
"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 theloans array follows the same schema as the
single loan endpoint, 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
{
"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
UseGET /loans/batch/{jobId} to check progress and read every row that failed:
{
"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
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.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 viaPOST /customersorPOST /customers/batch400: amountDue is immutable...— the row changedamountDueon a loan that already exists400: Invalid type '...'. Valid values: [...]— an unrecognised enum value, checked when the row is written rather than during request validation400: Customer timezone is required for schedule creation...— the customer has no valid IANA timezone, so no collections schedule can be built500: 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, sofailures 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
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, 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.
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
- Validates the whole request — every row against the loan schema, plus the
duplicate check. Any failure rejects the request with
422and queues nothing - Queues the job — stores the submitted payload, splits it into slices of 25 rows, and returns HTTP 202 with a job ID
- 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
idalready 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
- Resolves the customer by
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
- 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 returns202 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 |
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
jobIdis 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
Authorizations
Body
Batch of loan records
Array of loan records to create. Every (customerId, id) pair must be unique within the request.
1Show child attributes
Show child attributes
Response
Batch job accepted for async processing. Use GET /loans/batch/{jobId} to check status.
Job ID. Use with GET /loans/batch/{jobId}
"550e8400-e29b-41d4-a716-446655440000"
Number of loans queued for processing
400
How many parallel slices the job was split into, and how to poll it
"Batch job created with 16 parallel batches. Use GET /loans/batch/550e8400-e29b-41d4-a716-446655440000 to check status."