Refund an overpayment
curl --request POST \
--url https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"amount": 123,
"idempotencyKey": "<string>",
"transactionDate": "<string>"
}
'import requests
url = "https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds"
payload = {
"amount": 123,
"idempotencyKey": "<string>",
"transactionDate": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({amount: 123, idempotencyKey: '<string>', transactionDate: '<string>'})
};
fetch('https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds', 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-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds",
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([
'amount' => 123,
'idempotencyKey' => '<string>',
'transactionDate' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$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-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds"
payload := strings.NewReader("{\n \"amount\": 123,\n \"idempotencyKey\": \"<string>\",\n \"transactionDate\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
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-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"amount\": 123,\n \"idempotencyKey\": \"<string>\",\n \"transactionDate\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"amount\": 123,\n \"idempotencyKey\": \"<string>\",\n \"transactionDate\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"loanStatus": "<string>",
"amount": 123
}Repayments
Refund an overpayment
Return an overpayment credit to the customer and close the loan.
POST
/
v1
/
loans
/
external
/
{loan_external_id}
/
refunds
Refund an overpayment
curl --request POST \
--url https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"amount": 123,
"idempotencyKey": "<string>",
"transactionDate": "<string>"
}
'import requests
url = "https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds"
payload = {
"amount": 123,
"idempotencyKey": "<string>",
"transactionDate": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({amount: 123, idempotencyKey: '<string>', transactionDate: '<string>'})
};
fetch('https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds', 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-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds",
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([
'amount' => 123,
'idempotencyKey' => '<string>',
'transactionDate' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$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-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds"
payload := strings.NewReader("{\n \"amount\": 123,\n \"idempotencyKey\": \"<string>\",\n \"transactionDate\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
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-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"amount\": 123,\n \"idempotencyKey\": \"<string>\",\n \"transactionDate\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/refunds")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"amount\": 123,\n \"idempotencyKey\": \"<string>\",\n \"transactionDate\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"loanStatus": "<string>",
"amount": 123
}When a repayment exceeds what the loan still owes, the excess stays on the
loan as a credit balance and the loan moves to
Retrying a refund that already succeeded is not an error: with the same
Overpaid. While it is in
that state no further repayment can be posted to it — the loan is
settled, and the customer is owed money back.
This endpoint records that you have returned that money. It does not move
money itself: the wallet credit is yours to make. Once recorded, the credit
is cleared and the loan closes.
Two equivalent forms — prefer the externalId form for partner integrations.
Credit the wallet first, then call this. There is no way to un-refund. If
you record the refund with us and your wallet credit then fails, our ledger
says the customer was repaid when they were not. The safe order is: move the
money, then call this endpoint, retrying with the same
idempotencyKey until
it succeeds.Path parameters
string
required
The loan’s externalId. On the
/v1/loans/{loan_id}/refunds form, this is
the numeric LMS id instead.Request body
number
required
The overpayment you are refunding. It must equal the loan’s current
overpayment exactly; a disagreement is rejected rather than corrected,
because it means you are working from a figure that has since changed. You
already hold this number, since you credited the wallet with it before
calling, and stating it is what ties the two sides to the same figure.
string
required
Your own reference for the wallet credit that returned the money (max 100
characters). If a refund carrying this key has already been recorded on the
loan, the original transaction is returned unchanged instead of a second
refund being made. This is what makes a retry safe, and it keeps working
after the loan has closed.
string
Optional value date in ISO
yyyy-MM-dd, defaulting to today. Unlike a
repayment there is no transactionAt: nothing about a refund depends on the
exact instant.Examples
# Read the credit, return it in the wallet, then record it here
curl -sf "$BASE/v1/loans/external/loan-ext-12345" \
-H "Authorization: Bearer $TOKEN" | jq '{status, totalOverpaid}'
# { "status": "Overpaid", "totalOverpaid": 900 }
curl -sf -X POST "$BASE/v1/loans/external/loan-ext-12345/refunds" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "amount": 900, "idempotencyKey": "wallet-refund-abc123" }'
Response
200 OK returns the refund transaction plus the loan’s position afterwards.
The loan is now closed and carries nothing outstanding.
{
"id": "739",
"type": "Credit Balance Refund",
"date": "2026-09-22",
"amount": 900,
"overpayment": 900,
"idempotencyKey": "wallet-refund-abc123",
"status": "posted",
"loanStatus": "Closed (obligations met)",
"totalOutstanding": 0,
"availableCreditLimit": 0
}
string
Closed (obligations met) after a successful refund. The overpayment is
cleared and totalOverpaid on the loan returns to 0.number
The credit that was returned.
Errors
| Code | Sub-code | When |
|---|---|---|
failed_precondition | loan_not_overpaid | The loan carries no overpayment. Either it was never overpaid, or the credit has already been refunded. The message includes the loan’s current status |
invalid_argument | validation_error | amount or idempotencyKey is missing; amount disagrees with the loan’s overpayment; transactionDate is not ISO yyyy-MM-dd or is in the future; idempotencyKey is longer than 100 characters |
not_found | loan_not_found | No loan with that id or externalId |
idempotencyKey you get the original transaction back with 200, for as long
as the loan exists.
Avoiding overpayment in the first place
A refund is a recovery path, not a routine one. To stay out of it, readtotalOutstanding immediately before each debit and debit exactly that
figure — never a cached amount, never rounded up. See
Record a repayment.
