Refund a paid penalty
curl --request POST \
--url https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"idempotencyKey": "<string>",
"note": "<string>"
}
'import requests
url = "https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund"
payload = {
"idempotencyKey": "<string>",
"note": "<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({idempotencyKey: '<string>', note: '<string>'})
};
fetch('https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund', 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}/penalties/refund",
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([
'idempotencyKey' => '<string>',
'note' => '<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}/penalties/refund"
payload := strings.NewReader("{\n \"idempotencyKey\": \"<string>\",\n \"note\": \"<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}/penalties/refund")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"idempotencyKey\": \"<string>\",\n \"note\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund")
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 \"idempotencyKey\": \"<string>\",\n \"note\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"refunded": 123,
"transactions": [
{}
],
"loanStatus": "<string>",
"totalOverpaid": 123
}Repayments
Refund a paid penalty
Return a penalty the customer already paid, when it turns out not to have been owed.
POST
/
v1
/
loans
/
external
/
{loan_external_id}
/
penalties
/
refund
Refund a paid penalty
curl --request POST \
--url https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"idempotencyKey": "<string>",
"note": "<string>"
}
'import requests
url = "https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund"
payload = {
"idempotencyKey": "<string>",
"note": "<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({idempotencyKey: '<string>', note: '<string>'})
};
fetch('https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund', 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}/penalties/refund",
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([
'idempotencyKey' => '<string>',
'note' => '<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}/penalties/refund"
payload := strings.NewReader("{\n \"idempotencyKey\": \"<string>\",\n \"note\": \"<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}/penalties/refund")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"idempotencyKey\": \"<string>\",\n \"note\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api-staging.bsa.ai/v1/loans/external/{loan_external_id}/penalties/refund")
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 \"idempotencyKey\": \"<string>\",\n \"note\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"refunded": 123,
"transactions": [
{}
],
"loanStatus": "<string>",
"totalOverpaid": 123
}An overdue penalty is charged at the loan’s due instant and, because the LMS
settles penalties before interest and principal, the next repayment pays it
first. Sometimes that penalty should never have applied: the customer paid on
the wallet before the due instant, but the repayment reached us after it and
without a
There is no
Retrying a refund that already succeeded is not an error: with the same
transactionAt, so it settled the penalty instead of removing it.
This endpoint corrects that. It does not move money. It returns what was
paid on the penalty to the loan as a credit balance, and the loan moves to
Overpaid. You then return that credit to the customer with
Refund an overpayment, exactly as for any other
overpayment.
Two equivalent forms, /v1/loans/{loan_id}/penalties/refund and the
externalId form above. Prefer the externalId form for partner integrations.
This is a two-step correction. After this call the customer is owed money
and the loan shows it as
totalOverpaid. The correction is complete only
when you have credited the wallet and recorded it with POST .../refunds.
Read totalOverpaid from this response and pass it as that call’s amount.Path parameters
string
required
The loan’s externalId. On the
/v1/loans/{loan_id}/penalties/refund form,
this is the numeric LMS id instead.Request body
string
required
Your own reference for this correction, for example the support ticket or
the wallet transaction id that proved the payment was on time (max 100
characters). If a penalty refund carrying this key has already been recorded
on the loan, the original is returned unchanged instead of a second refund
being made. This is what makes a retry safe.
string
Optional free text recorded on the LMS transaction, such as why the penalty
was not owed (max 500 characters).
amount: the whole paid penalty is returned, and the amount you
state is on the follow-up refund call. There is no transactionDate either:
the LMS books a charge refund on the day it is recorded and accepts no other.
Examples
# 1. Return the paid penalty to the loan
curl -sf -X POST "$BASE/v1/loans/external/loan-ext-12345/penalties/refund" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "idempotencyKey": "cs-ticket-4411", "note": "wallet shows payment before the due instant" }'
# 2. Credit the wallet, then record it (amount = totalOverpaid from step 1)
curl -sf -X POST "$BASE/v1/loans/external/loan-ext-12345/refunds" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "amount": 1000, "idempotencyKey": "wallet-refund-abc123" }'
Response
200 OK returns the refund transaction(s) and the loan’s position afterwards.
{
"refunded": 1000,
"transactions": [
{
"id": "1786",
"type": "Charge Refund",
"date": "2026-09-28",
"amount": 1000,
"overpayment": 1000,
"idempotencyKey": "cs-ticket-4411",
"status": "posted"
}
],
"idempotencyKey": "cs-ticket-4411",
"loanStatus": "Overpaid",
"totalOutstanding": 0,
"totalOverpaid": 1000
}
number
The total returned to the loan by this call.
array
One
Charge Refund transaction per penalty charge refunded. A loan normally
carries one penalty, so this is normally one entry.string
Overpaid after a successful refund on a settled loan.number
The credit now owed to the customer. Pass this as
amount to
POST .../refunds.Errors
| Code | Sub-code | When |
|---|---|---|
failed_precondition | no_paid_penalty | The loan carries no paid penalty: none was charged, it was waived, or it is still outstanding. An outstanding penalty is settled by the repayment, not refunded |
failed_precondition | penalty_already_refunded | Every paid penalty on the loan has already been returned to it, under a different key. Retrying with the original key returns that refund instead |
invalid_argument | validation_error | idempotencyKey is missing or longer than 100 characters; note is longer than 500 characters |
not_found | loan_not_found | No loan with that id or externalId |
idempotencyKey you get the original transaction back with 200.
Avoiding the need for this
This endpoint exists for a penalty that was paid because timing evidence arrived late. SendtransactionAt, the exact wallet time, on every repayment
including the first call. A payment made before the due instant then removes
its penalty as the repayment posts, and there is nothing to refund. See
Record a repayment.
