Developers Get API keys
Lenduh API · v1

Build on your lending data

The Lenduh API gives your own systems read access to your organization's borrowers, loans, repayment schedules, balances and payments — and, once your organization is granted it, a way to send things back in: payments collected elsewhere, new clients, loan applications. Webhooks tell them the moment a loan is released, falls overdue or is paid off, or a payment is recorded, so they never have to poll.

What teams build with it

  • Post collections into an accounting system every night, already split into principal, interest and penalty.
  • Push in the payments a partner or payment centre collected for you, as they happen.
  • Show borrowers their balance and next due date in your own app or member site.
  • Send due-date reminders or a thank-you SMS from your own tools.
  • Feed a BI dashboard or a board report with portfolio and collection figures.
  • Keep a member database or core-banking system in step with Lenduh, copying only what changed.

How it works

  • REST over HTTPS. JSON in, JSON out.
  • API keys. Your server sends a secret key with each request. Keys belong to your organization, not to a staff member.
  • Reading is yours to switch on. Writing is granted. Every key can read. Posting payments, creating clients and submitting loan applications each need a scope Lenduh grants your organization on request, because they change your books. How to ask.
  • Releasing money is never a key's to do. A loan submitted through the API is a draft; a person in the office releases it. There is no endpoint for it under any scope.
  • Webhooks. Signed HTTPS POST requests to a URL you choose.

New here? The Quickstart takes you from switching the API on to your first response in three steps.

Get started

Quickstart

You need an admin account in Lenduh and a plan that includes the Developer API.

  1. Switch on the Developer APIAn admin opens Settings → Developer API and turns it on. It's included in the Enterprise plan, and Professional organizations can add it as the Developer API & webhooks add-on.
  2. Create an API keyIn the sidebar, open Developer API → API keys and choose Create key. Name it after the system that will use it, such as "Xero sync", then copy the key. It starts with lk_ and is shown only once.
  3. Make your first requestPut the key in an environment variable on your server and call the API. The example on the right fetches your most recent loan.

A 200 response with an items list means you're connected. If you see 401, check that the Developer API is still switched on and that you copied the whole key. Authentication lists every cause.

GET/v1/loans
curl "https://app.lenduh.com/api/v1/loans?pageSize=1" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans?pageSize=1',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const loans = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loans",
    params={"pageSize": 1},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
loans = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loans', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['pageSize' => 1],
]);
$loans = json_decode((string) $res->getBody(), true);
Response · 200
{
  "items": [
    {
      "id": "0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04",
      "loanNo": "LN-000142",
      "borrowerId": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
      "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
      "principal": "25000.00",
      "totalInterest": "3000.00",
      "totalRepayable": "28000.00",
      "repaymentFrequency": "semi_monthly",
      "installmentCount": 24,
      "installmentAmount": "1166.67",
      "status": "active",
      "restructured": false,
      "releaseDate": "2026-09-15",
      "maturityDate": "2027-09-15",
      "createdAt": "2026-09-14T02:11:47.512Z",
      "updatedAt": "2026-09-15T01:04:12.118Z"
    }
  ],
  "total": 142,
  "page": 1,
  "pageSize": 1,
  "pageCount": 142
}
Get started

Authentication

Send your API key in the Authorization header of every request, as a bearer token:

Authorization: Bearer lk_7Qm2xVfK9aLp…

Keep keys on your servers

  • Use a key only from server-side code. Never put one in a web page, a mobile app, a spreadsheet macro or anything a borrower or member can download. Browsers on other websites can't call the API directly.
  • Store keys in environment variables or a secrets manager, not in source control.
  • Lenduh keeps only a one-way hash of each key, so nobody at Lenduh can show it to you again. If you lose a key, create a new one and revoke the old one.

What a key can see

A key belongs to your organization, not to the admin who created it, and it keeps working after that person leaves. It reads every branch of your organization. It can never see another organization's data. Asking for another organization's record returns 404, as if the record didn't exist.

When authentication fails

You get 401 Unauthorized with the same message whatever the reason, so the response never tells an attacker which part was wrong. Check these in order:

  • The header is missing, or doesn't start with Bearer lk_.
  • The key was revoked, has passed its expiry date, or was mistyped.
  • An admin switched off the Developer API in Settings.
  • Your plan no longer includes the Developer API, or your organization is suspended.

A 403 Forbidden means the key is valid but lacks the scope an endpoint needs. Every key has the read scope today, so you should only see this if new scopes are added.

GET/v1/borrowers
curl "https://app.lenduh.com/api/v1/borrowers?pageSize=1" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/borrowers?pageSize=1',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const borrowers = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/borrowers",
    params={"pageSize": 1},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
borrowers = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('borrowers', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['pageSize' => 1],
]);
$borrowers = json_decode((string) $res->getBody(), true);
Response
{
  "statusCode": 401,
  "message": "A valid API key is required (Authorization: Bearer lk_…).",
  "error": "Unauthorized"
}
{
  "statusCode": 403,
  "message": "This API key lacks the required 'read' scope.",
  "error": "Forbidden"
}
Get started

API keys and plans

Plans

The Developer API and webhooks are included in the Enterprise plan. On Professional you can add them as the Developer API & webhooks add-on. See plans and add-ons. If your subscription later drops below a plan that includes them, your API keys stop working and webhooks stop sending straight away, as they do when an admin switches the Developer API off. Your data stays in Lenduh.

Who manages keys

Only admins can see, create and revoke keys, under Developer API → API keys. Each key shows its name, a masked handle such as lk_7Qm2xVfK9aLp…, its scopes, when it was last used and when it expires.

Scopes

ScopeGrants
readRead borrowers, loans, schedules, balances, payments, loan products and branches. Every key has this scope, and it is the only one you can give yourself.
payments:writePush in payments collected outside Lenduh, and reverse ones you pushed. Granted on request.
borrowers:writeCreate and update clients. Granted on request.
loans:writeSubmit loan applications, which create drafts. Granted on request.

Good practice

  • One key per integration. Then you can revoke one system's access without touching the others, and last used tells you which integrations are alive.
  • Set an expiry date on keys for projects, contractors and tests.
  • Rotate without downtime. Create the new key, deploy it, confirm its last used time moves, then revoke the old key.
  • Revoke at once if a key appears in a log, a chat message, an email or a repository. Revoking takes effect on the next request.
.env on your server
# Server-side only. Never commit this file.
LENDUH_API_KEY=lk_7Qm2xVfK9aLp3RtY8wBn4cHs6dJe1uZo5iGk0MvXqTb
LENDUH_WEBHOOK_SECRET=whsec_…
A key is lk_ followed by 43 random characters. The first 12 identify it in lists and are safe to show; the rest is secret.
Get started

Write access

Reading is something your organization switches on for itself. Writing is not. A write posts money against a loan, creates a client record or originates a loan in your books, so Lenduh grants it to your organization after reading what the integration is for.

What can be granted

ScopeLets an integration
payments:writePush in a payment collected outside Lenduh — a payment centre, a partner, your own field app — and reverse one it pushed.
borrowers:writeCreate and update clients, for an organization whose intake happens in its own system and shouldn't be keyed twice.
loans:writeSubmit a loan application. It creates a draft.

Releasing money is never a key's to do. A loan submitted through the API waits in the office as a draft, with no schedule and nothing disbursed, until a person releases it. There is no release endpoint on this API under any scope — not guarded, absent.

How to ask

  1. Open the request formAn admin goes to Developer API → API keys. The card at the top shows what your organization holds today and offers Request write access.
  2. Say what you need and whyTick the capabilities, name who will be calling — the provider, or your own system — and describe what it will send and roughly how much. Give the address we should reply to.
  3. We reply to that addressYou can be granted part of what you asked for; the reason is shown on the same page. One request at a time: withdraw the open one if you need to change it.

Then mint a key that can use it

Once granted, the capabilities appear as tick boxes when you create a key. Give each integration only what it needs: a key for pushing payments has no business creating clients. A key already in use does not gain the new scope — mint a new one.

Two different 403s

Both say you may not do this, for different reasons, and the message tells you which:

  • The key doesn't carry the scope. It was minted without it. Mint a new key with the box ticked.
  • Your organization no longer holds the grant. The key is untouched and still reads; every call using that capability is refused until it's granted again. Your keys page marks the scope not currently granted.

A grant can be withdrawn at any time, and it takes effect on the very next request without anyone revoking a key. Build for a 403 arriving on a call that worked yesterday: stop, alert someone, and keep the collection — don't drop it.

Response
{
  "statusCode": 403,
  "message": "This API key lacks the required 'payments:write' scope.",
  "error": "Forbidden"
}
{
  "statusCode": 403,
  "message": "API write access for \"Push payments collected outside the system\" is not currently granted to this organization. Request it in Settings → Developer API.",
  "error": "Forbidden"
}
Get started

Testing your integration

There is no separate test mode yet. Every key works against your organization's live data, so a key that can write can write for real. Keep these habits while you build:

  • Create a separate key for development, give it an expiry date, and revoke it when you go live.
  • Give a development key only the scopes it needs — a read-only one cannot damage anything, so build everything you can with one before you switch it for a writing key.
  • Dry-run every push. POST /v1/payments/preview and POST /v1/loans/preview answer exactly what the real call would do and write nothing at all.
  • Treat the responses as confidential. They contain your borrowers' names, so don't paste them into tickets or chat.
  • Test webhooks with Send test event on the endpoint. It delivers an endpoint.test event through the same signing and retry path as real events.
  • Run curl -i once to see the rate-limit headers your code should respect.
Check a key
# -i prints the response headers, including your rate-limit allowance
curl -i "https://app.lenduh.com/api/v1/borrowers?pageSize=1" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
Response
HTTP/2 200
content-type: application/json; charset=utf-8
x-ratelimit-limit: 200
x-ratelimit-remaining: 199
x-ratelimit-reset: 60

{"items":[{"id":"5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37","borrowerCode":"BRW-00318","name":"Juan Dela Cruz","status":"active","branchId":"7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b","createdAt":"2026-08-03T01:15:22.407Z","updatedAt":"2026-09-12T06:40:18.221Z"}],"total":318,"page":1,"pageSize":1,"pageCount":318}
Conventions

Errors

Lenduh uses standard HTTP status codes. 2xx means success, 4xx means something in the request needs to change, and 5xx means a problem on our side. Error bodies share one shape:

statusCodeinteger
The HTTP status, repeated in the body.
messagestring
A readable explanation. Show it in your logs, but don't match on its text: wording can improve over time.
errorstringsometimes
The status name, such as Not Found. It isn't included on 429 or 500.
StatusWhen it happensWhat to do
400An :id in the path isn't a UUID, or a filter has a value the endpoint doesn't accept, such as status=bogus. The message names the valid values.Fix the request. Retrying won't help.
401The key is missing, invalid, revoked or expired, or API access is off.See Authentication.
403The key lacks the scope the endpoint needs.Use a key with that scope.
404No record with that ID in your organization.Check the ID. Don't retry.
429Too many requests. See Rate limits.Wait for Retry-After seconds, then retry.
500An unexpected error on our side.Retry with backoff. If it persists, contact support.
Response
{
  "statusCode": 400,
  "message": "status must be one of the following values: draft, released, active, overdue, paid, cancelled, written_off, foreclosed",
  "error": "Bad Request"
}
{
  "statusCode": 404,
  "message": "Loan not found.",
  "error": "Not Found"
}
{
  "statusCode": 429,
  "message": "Too many requests. Please wait a moment and try again."
}
{
  "statusCode": 500,
  "message": "Internal server error"
}
Conventions

Pagination

List endpoints return one page at a time. Choose the page with two query parameters:

pageintegeroptional
Which page to return, starting at 1. Defaults to 1.
pageSizeintegeroptional
Records per page, from 1 to 100. Defaults to 25. Out-of-range values are clamped: pageSize=500 returns 100 records. A value that isn't a number falls back to the default.

Every list responds with the same envelope:

itemsarray
The records on this page.
totalinteger
How many records match, across all pages.
pageinteger
The page returned.
pageSizeinteger
The page size used, after clamping.
pageCountinteger
How many pages there are. Always at least 1, even when items is empty.

Ordering

Borrowers and loans are listed newest first by creation time. Payments are listed newest first by payment date. A loan's installments are listed in schedule order, and loan products and branches by name. With updatedSince, lists run oldest change first instead: see Syncing changes.

Paging through changing data. Because lists are newest first, a record created while you page through can push an item onto the next page, so you see it twice. Deduplicate by id, as the example does.

Every page of a list
// Walk every page of a list, 100 records at a time.
async function* listAll(path, params = {}) {
  const headers = { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` };
  for (let page = 1; ; page += 1) {
    const qs = new URLSearchParams({ ...params, page: String(page), pageSize: '100' });
    const res = await fetch(`https://app.lenduh.com/api/v1${path}?${qs}`, { headers });
    if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
    const body = await res.json();
    yield* body.items;
    if (page >= body.pageCount) return;
  }
}

const seen = new Set();
for await (const loan of listAll('/loans', { status: 'active' })) {
  if (seen.has(loan.id)) continue; // a new loan can push an item onto the next page
  seen.add(loan.id);
  // ...upsert the loan into your system
}
import os

import requests


def list_all(path, **params):
    headers = {"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"}
    page = 1
    while True:
        res = requests.get(
            f"https://app.lenduh.com/api/v1{path}",
            params={**params, "page": page, "pageSize": 100},
            headers=headers,
            timeout=30,
        )
        res.raise_for_status()
        body = res.json()
        yield from body["items"]
        if page >= body["pageCount"]:
            return
        page += 1


seen = set()
for loan in list_all("/loans", status="active"):
    if loan["id"] in seen:
        continue  # a new loan can push an item onto the next page
    seen.add(loan["id"])
    # ...upsert the loan into your system
<?php
function listAll(GuzzleHttp\Client $client, string $path, array $params = []): Generator
{
    for ($page = 1; ; $page++) {
        $res = $client->get($path, [
            'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
            'query'   => $params + ['page' => $page, 'pageSize' => 100],
        ]);
        $body = json_decode((string) $res->getBody(), true);
        yield from $body['items'];
        if ($page >= $body['pageCount']) {
            return;
        }
    }
}

$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);
$seen = [];
foreach (listAll($client, 'loans', ['status' => 'active']) as $loan) {
    if (isset($seen[$loan['id']])) {
        continue; // a new loan can push an item onto the next page
    }
    $seen[$loan['id']] = true;
    // ...upsert the loan into your system
}
Response · 200
{
  "items": [
    {
      "id": "0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04",
      "loanNo": "LN-000142",
      "borrowerId": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
      "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
      "principal": "25000.00",
      "totalInterest": "3000.00",
      "totalRepayable": "28000.00",
      "repaymentFrequency": "semi_monthly",
      "installmentCount": 24,
      "installmentAmount": "1166.67",
      "status": "active",
      "restructured": false,
      "releaseDate": "2026-09-15",
      "maturityDate": "2027-09-15",
      "createdAt": "2026-09-14T02:11:47.512Z",
      "updatedAt": "2026-09-15T01:04:12.118Z"
    },
    {
      "id": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "loanNo": "LN-000141",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "principal": "15000.00",
      "totalInterest": "1800.00",
      "totalRepayable": "16800.00",
      "repaymentFrequency": "monthly",
      "installmentCount": 6,
      "installmentAmount": "2800.00",
      "status": "overdue",
      "restructured": false,
      "releaseDate": "2026-07-01",
      "maturityDate": "2027-01-01",
      "createdAt": "2026-06-29T08:30:15.006Z",
      "updatedAt": "2026-09-02T00:30:04.771Z"
    }
  ],
  "total": 142,
  "page": 1,
  "pageSize": 2,
  "pageCount": 71
}
Conventions

Syncing changes

To keep a copy of your borrowers, loans or payments up to date, fetch only what changed since your last run. Every one of these objects has an updatedAt timestamp, and their lists take an updatedSince filter.

  1. Read with updatedSincePass the newest updatedAt you saw last time. Records whose updatedAt is at or after it come back oldest change first, so you can page forward through them.
  2. Save as you goAfter each page, store the last record's updatedAt. If your job stops halfway, it resumes from there.
  3. Expect the boundary record againThe record you saw last reappears at the start of the next run, because the filter includes its own timestamp. Upsert by id, and repeats do no harm.

updatedSince must be a date and time with a time zone, such as 2026-09-17T00:00:00.000Z. Passing back an updatedAt value exactly as Lenduh returned it always works. A bare date is refused with 400, because it wouldn't say which day it means in Manila.

What counts as a change

  • A payment's updatedAt moves when it's recorded and when it's reversed.
  • A loan's updatedAt moves when the loan itself changes: its status, its terms or a restructure. It doesn't move on every payment. To keep balances current, sync payments, then fetch the balance of each loan they touched.
  • A borrower's updatedAt moves when their record is edited.

Webhooks and syncing work together. Webhooks tell you within minutes. A nightly sync with updatedSince catches anything a webhook missed, for example while your endpoint was down or the Developer API was switched off.

GET/v1/payments
curl "https://app.lenduh.com/api/v1/payments?updatedSince=2026-09-17T00:00:00.000Z&pageSize=100" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/payments?updatedSince=2026-09-17T00:00:00.000Z&pageSize=100',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const changes = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/payments",
    params={"updatedSince": "2026-09-17T00:00:00.000Z", "pageSize": 100},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
changes = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('payments', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['updatedSince' => '2026-09-17T00:00:00.000Z', 'pageSize' => 100],
]);
$changes = json_decode((string) $res->getBody(), true);
Response
{
  "items": [
    {
      "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
      "paymentNo": "PAY-01877",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "amount": "1250.00",
      "breakdown": {
        "principal": "1071.43",
        "interest": "128.57",
        "penalty": "50.00"
      },
      "channel": "cash",
      "externalReference": null,
      "paymentDate": "2026-09-17T01:20:00.000Z",
      "status": "posted",
      "reversedAt": null,
      "createdAt": "2026-09-17T01:20:03.884Z",
      "updatedAt": "2026-09-17T01:20:03.884Z"
    }
  ],
  "total": 1,
  "page": 1,
  "pageSize": 100,
  "pageCount": 1
}
{
  "statusCode": 400,
  "message": "updatedSince must be an ISO 8601 date-time with a time zone, such as 2026-09-18T00:00:00Z",
  "error": "Bad Request"
}
Conventions

Rate limits

Each endpoint accepts up to 200 requests per minute from one IP address. Requests to different endpoints count separately, so listing loans doesn't use up your allowance for payments.

Successful responses carry three headers:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the window: 200.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetSeconds until the window resets.

Over the limit, you get 429 Too Many Requests with a Retry-After header in seconds. Wait that long before trying again. Failed requests count too, including ones with a bad key.

Staying well under the limit

  • Use pageSize=100 for bulk reads.
  • Fetch only what changed with updatedSince, instead of re-reading everything.
  • Subscribe to webhooks instead of polling for new payments.
  • On a 429, back off for the Retry-After period. Don't retry in a tight loop.
Response
HTTP/2 200
content-type: application/json; charset=utf-8
x-ratelimit-limit: 200
x-ratelimit-remaining: 187
x-ratelimit-reset: 41
HTTP/2 429
content-type: application/json; charset=utf-8
retry-after: 41

{"statusCode":429,"message":"Too many requests. Please wait a moment and try again."}
Conventions

Data formats

IDsstring (UUID)
Every record has an id such as 0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04. Use it to link records and to deduplicate.
Reference numbersstring
The numbers your staff see: BRW-00318 for a borrower, LN-000142 for a loan, PAY-01877 for a payment. Unique within your organization, and good for display and reconciliation.
Moneystring
Philippine pesos, as a string with exactly two decimals: "25000.00". Parse it as a decimal or convert to integer centavos. Never parse money as a floating-point number.
Timestampsstring
ISO 8601 in UTC with milliseconds: "2026-09-17T01:20:00.000Z". Lenduh runs on Philippine time (UTC+8), so convert to Asia/Manila before you group payments by day.
Datesstring
A calendar date with no time or zone: "2026-09-15". Used for releaseDate, maturityDate, an installment's dueDate and a balance's asOf and nextDueDate. Filters that take a date, such as paymentDateFrom, read it as a whole day in Manila.
Enumsstring
Lowercase words with underscores, such as written_off. New values can appear, so handle ones you don't recognize.
Nullnull
A field with no value yet is null, not missing. For example, a loan's releaseDate is null until it is released.
Money and dates
// Amounts always have exactly two decimals, so drop the point to get centavos.
const toCentavos = (amount) => BigInt(amount.replace('.', '')); // "1250.00" -> 125000n
const collected = payments
  .filter((p) => p.status === 'posted')
  .reduce((sum, p) => sum + toCentavos(p.amount), 0n);

// Group by the Philippine calendar day, not the UTC one.
const manilaDay = (iso) =>
  new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Manila' }).format(new Date(iso));
manilaDay('2026-09-16T17:30:00.000Z'); // 2026-09-17
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo

# Decimal keeps pesos exact. Never use float() on money.
collected = sum(Decimal(p["amount"]) for p in payments if p["status"] == "posted")


# Group by the Philippine calendar day, not the UTC one.
def manila_day(iso):
    moment = datetime.fromisoformat(iso.replace("Z", "+00:00"))
    return moment.astimezone(ZoneInfo("Asia/Manila")).date()


manila_day("2026-09-16T17:30:00.000Z")  # 2026-09-17
<?php
// bcmath keeps pesos exact. Never cast money to float.
$posted = array_filter($payments, fn ($p) => $p['status'] === 'posted');
$collected = array_reduce($posted, fn ($sum, $p) => bcadd($sum, $p['amount'], 2), '0.00');

// Group by the Philippine calendar day, not the UTC one.
$day = (new DateTimeImmutable('2026-09-16T17:30:00.000Z'))
    ->setTimezone(new DateTimeZone('Asia/Manila'))
    ->format('Y-m-d'); // 2026-09-17
Conventions

Versioning

The version is part of the path: /v1. Within a version we only make changes that don't break a well-written integration:

  • new endpoints and new optional query parameters
  • new fields in responses and in webhook payloads
  • new values in enums such as a loan's status
  • new webhook event types (you receive only the ones you subscribe to)

Write your code to ignore fields it doesn't know and to handle unknown enum values. Anything that would break an existing integration, such as removing a field, renaming it or changing its type, ships as a new version. We announce it in the changelog well before the old version is retired.

Webhooks

Get told when things happen

Lenduh sends a signed HTTPS request to your server when a loan is released, changes status or is restructured, and when a payment is recorded or reversed.

Webhooks

Overview

When an event you subscribe to happens, Lenduh sends an HTTPS POST with a JSON body to your endpoint. Use webhooks to react to payments and loan changes as they happen, then use the API to fetch anything else you need, such as a loan's balance.

Add an endpoint

  1. Open Developer API → WebhooksAdmins with the Developer API switched on can add endpoints.
  2. Enter an HTTPS URL and choose eventsThe URL must be public HTTPS. Addresses on private networks, localhost and hosts ending in .local or .internal are refused.
  3. Save the signing secretThe secret starts with whsec_ and is shown once. Store it with your API key. You'll need it to verify signatures.
  4. Send a test eventUse Send test event and confirm your server answers with a 2xx.

What we send

HeaderValue
Content-Typeapplication/json
User-AgentLenduh-Webhooks/1
X-Lenduh-EventThe event type, such as payment.recorded.
X-Lenduh-DeliveryThe delivery ID. The same value as id in the body.
X-Lenduh-Signaturet=…,v1=…. See Verify signatures.

The body is an envelope around the event's data:

idstring (UUID)
Unique per delivery, and unchanged across retries. Use it to ignore duplicates.
typestring
The event type.
createdAtstring
When the event was queued for this endpoint, as an ISO 8601 timestamp.
organizationIdstring (UUID)
The Lenduh organization the event belongs to. Useful when one server receives events for several organizations.
dataobject
The record the event is about, in exactly the shape the API returns it: a loan object or a payment object. Code that reads API responses can read event data unchanged.

Reply with any 2xx status within 5 seconds. Do slow work, such as posting to your ledger, after you've replied.

Request to your endpoint
POST /webhooks/lenduh HTTP/1.1
Host: erp.example.coop
Content-Type: application/json
User-Agent: Lenduh-Webhooks/1
X-Lenduh-Event: payment.recorded
X-Lenduh-Delivery: e2b8c6d4-1f07-4a39-95ce-6b3d0a8f7e21
X-Lenduh-Signature: t=1789608300,v1=b73f72c64f89d8bfb56ce38d26aef8b206636f498df5c1293cbadf0024234bac

{"id":"e2b8c6d4-1f07-4a39-95ce-6b3d0a8f7e21","type":"payment.recorded","createdAt":"2026-09-17T01:20:04.102Z","organizationId":"2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615","data":{"id":"c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22","paymentNo":"PAY-01877","loanId":"3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86","borrowerId":"a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64","branchId":"b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f","amount":"1250.00","breakdown":{"principal":"1071.43","interest":"128.57","penalty":"50.00"},"channel":"cash","externalReference":null,"paymentDate":"2026-09-17T01:20:00.000Z","status":"posted","reversedAt":null,"createdAt":"2026-09-17T01:20:03.884Z","updatedAt":"2026-09-17T01:20:03.884Z"}}
Webhooks

Event types

Subscribe an endpoint to any of these events. Each event is delivered at most once per endpoint, plus any retries. data is always the full object, as the API returns it at that moment: a loan object for every loan.* event and a payment object for every payment.* event, each as the change left it.

EventWhen it's sent
loan.releasedA loan is released to the borrower, directly or after an approval. Sent once per loan, with status active.
loan.overdueA loan falls behind: an installment is past due and still owes money. Usually sent by the nightly run at 8:30 am Manila, or straight away when a reversal puts a loan back into arrears.
loan.back_to_currentAn overdue loan has nothing past due anymore: the borrower caught up, or a restructure cleared the arrears.
loan.paid_offA payment clears everything the loan owes, penalties included. status is paid.
loan.reopenedThe payment that paid a loan off is reversed, so it owes money again. Sent instead of loan.overdue or loan.back_to_current for that change. status says which it is.
loan.restructuredA restructure is applied, directly or after an approval. The loan carries its new terms and restructured true.
loan.written_offStaff write the loan off as bad debt.
loan.foreclosedThe loan is settled by foreclosing its collateral.
payment.recordedA payment is recorded against a loan, with status posted.
payment.reversedStaff reverse a payment, for example after a bounced cheque. Sent once per payment, with status reversed and reversedAt set.
endpoint.testOnly when you press Send test event. You can't subscribe to it. Its data holds just a message.

Mirroring payments? Subscribe to both payment.recorded and payment.reversed. A reversed payment keeps its original amount and paymentDate. Undo it in your system when payment.reversed arrives, and it won't be counted twice.

Following a loan's status? One status change sends one event, and its data is the loan as the change left it. A payment that pays off an overdue loan sends loan.paid_off, not loan.back_to_current as well. A restructure sends loan.restructured, plus loan.back_to_current if it cleared the arrears.

Events
{
  "id": "7f1c2a9e-3b64-4d8e-a1f0-5c9b2e7d4a18",
  "type": "loan.released",
  "createdAt": "2026-09-15T01:04:12.331Z",
  "organizationId": "2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615",
  "data": {
    "id": "0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04",
    "loanNo": "LN-000142",
    "borrowerId": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
    "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
    "principal": "25000.00",
    "totalInterest": "3000.00",
    "totalRepayable": "28000.00",
    "repaymentFrequency": "semi_monthly",
    "installmentCount": 24,
    "installmentAmount": "1166.67",
    "status": "active",
    "restructured": false,
    "releaseDate": "2026-09-15",
    "maturityDate": "2027-09-15",
    "createdAt": "2026-09-14T02:11:47.512Z",
    "updatedAt": "2026-09-15T01:04:12.118Z"
  }
}
{
  "id": "3d6f8a1c-5b2e-4d7f-9a0c-8e1b4f6d2a95",
  "type": "loan.overdue",
  "createdAt": "2026-09-02T00:30:04.812Z",
  "organizationId": "2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615",
  "data": {
    "id": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
    "loanNo": "LN-000141",
    "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
    "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
    "principal": "15000.00",
    "totalInterest": "1800.00",
    "totalRepayable": "16800.00",
    "repaymentFrequency": "monthly",
    "installmentCount": 6,
    "installmentAmount": "2800.00",
    "status": "overdue",
    "restructured": false,
    "releaseDate": "2026-07-01",
    "maturityDate": "2027-01-01",
    "createdAt": "2026-06-29T08:30:15.006Z",
    "updatedAt": "2026-09-02T00:30:04.771Z"
  }
}
{
  "id": "e2b8c6d4-1f07-4a39-95ce-6b3d0a8f7e21",
  "type": "payment.recorded",
  "createdAt": "2026-09-17T01:20:04.102Z",
  "organizationId": "2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615",
  "data": {
    "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
    "paymentNo": "PAY-01877",
    "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
    "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
    "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
    "amount": "1250.00",
    "breakdown": {
      "principal": "1071.43",
      "interest": "128.57",
      "penalty": "50.00"
    },
    "channel": "cash",
    "externalReference": null,
    "paymentDate": "2026-09-17T01:20:00.000Z",
    "status": "posted",
    "reversedAt": null,
    "createdAt": "2026-09-17T01:20:03.884Z",
    "updatedAt": "2026-09-17T01:20:03.884Z"
  }
}
{
  "id": "5b3e9d17-2c84-4f6a-8e05-d1a7c9b24f63",
  "type": "payment.reversed",
  "createdAt": "2026-09-18T02:14:09.412Z",
  "organizationId": "2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615",
  "data": {
    "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
    "paymentNo": "PAY-01877",
    "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
    "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
    "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
    "amount": "1250.00",
    "breakdown": {
      "principal": "1071.43",
      "interest": "128.57",
      "penalty": "50.00"
    },
    "channel": "cash",
    "externalReference": null,
    "paymentDate": "2026-09-17T01:20:00.000Z",
    "status": "reversed",
    "reversedAt": "2026-09-18T02:14:09.330Z",
    "createdAt": "2026-09-17T01:20:03.884Z",
    "updatedAt": "2026-09-18T02:14:09.330Z"
  }
}
{
  "id": "9a0d4f62-8e1b-4c57-a3f9-0b6e2d7c1f48",
  "type": "endpoint.test",
  "createdAt": "2026-09-18T02:00:00.000Z",
  "organizationId": "2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615",
  "data": {
    "message": "This is a test event from Lenduh."
  }
}
Webhooks

Verify signatures

Every request carries an X-Lenduh-Signature header so you can check that it came from Lenduh and wasn't changed on the way. Reject any request that fails the check.

X-Lenduh-Signature: t=1789608300,v1=5f0c…

  1. Split the headerSplit on commas. t is when the request was signed, in Unix seconds. v1 is the signature, as hex.
  2. Build the signed stringJoin t, a period, and the raw request body exactly as you received it: {t}.{body}.
  3. Compute the expected signatureHMAC-SHA256 of that string, keyed with your endpoint's whole signing secret including the whsec_ prefix, encoded as lowercase hex.
  4. Compare in constant timeCompare it with v1 using your language's constant-time comparison, not ==.
  5. Check the timeReject the request if t is more than five minutes from your server's clock. That stops someone replaying an old request.

Use the raw body. Parsing JSON and serializing it again changes the bytes, and the signature won't match. In Express, use express.raw() on the webhook route. In Laravel, use $request->getContent().

If you rotate the secret, every delivery from then on, retries included, is signed with the new one. Deploy the new secret promptly.

Check your code against the test vector on the right. With that secret, time and body, your function must produce exactly that v1.

Verify a webhook
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.LENDUH_WEBHOOK_SECRET; // the whole value, including "whsec_"
const TOLERANCE_SECONDS = 300;

export function verifyLenduhWebhook(rawBody, header) {
  const parts = Object.fromEntries((header ?? '').split(',').map((pair) => pair.split('=')));
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'utf8');
  const valid =
    given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected, 'utf8'));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= TOLERANCE_SECONDS;
  if (!valid || !fresh) throw new Error('Invalid Lenduh webhook signature');
  return JSON.parse(rawBody);
}

const app = express();

// Keep this route's body raw: parsing and re-serializing JSON changes the bytes.
app.post('/webhooks/lenduh', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = verifyLenduhWebhook(req.body.toString('utf8'), req.get('X-Lenduh-Signature'));
  } catch {
    return res.sendStatus(400);
  }
  // ...handle event.type, and skip an event.id you've already processed
  res.sendStatus(200);
});
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["LENDUH_WEBHOOK_SECRET"].encode()  # includes "whsec_"
TOLERANCE_SECONDS = 300

app = Flask(__name__)


def verify_lenduh_webhook(raw_body: bytes, header: str) -> dict:
    parts = dict(pair.split("=", 1) for pair in header.split(",") if "=" in pair)
    signed = parts.get("t", "").encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise ValueError("bad signature")
    if not parts.get("t", "").isdigit() or abs(time.time() - int(parts["t"])) > TOLERANCE_SECONDS:
        raise ValueError("stale request")
    return json.loads(raw_body)


@app.post("/webhooks/lenduh")
def lenduh_webhook():
    try:
        # get_data() is the raw body, exactly as received
        event = verify_lenduh_webhook(request.get_data(), request.headers.get("X-Lenduh-Signature", ""))
    except ValueError:
        abort(400)
    # ...handle event["type"], and skip an event["id"] you've already processed
    return "", 200
<?php
function verifyLenduhWebhook(string $rawBody, string $header, string $secret): array
{
    // $secret is the whole value, including "whsec_"
    parse_str(str_replace(',', '&', $header), $parts); // t=…&v1=…
    $expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $rawBody, $secret);
    if (!hash_equals($expected, $parts['v1'] ?? '')) {
        throw new RuntimeException('Invalid Lenduh webhook signature');
    }
    if (abs(time() - (int) ($parts['t'] ?? 0)) > 300) {
        throw new RuntimeException('Stale Lenduh webhook');
    }
    return json_decode($rawBody, true);
}

try {
    $event = verifyLenduhWebhook(
        file_get_contents('php://input'),           // the raw body, exactly as received
        $_SERVER['HTTP_X_LENDUH_SIGNATURE'] ?? '',
        getenv('LENDUH_WEBHOOK_SECRET'),
    );
} catch (RuntimeException $e) {
    http_response_code(400);
    exit;
}
// ...handle $event['type'], and skip an $event['id'] you've already processed
http_response_code(200);
Test vector
Secret     whsec_Zt6pK1vQx9sLm3bN8cRw2yHf5dGj0aEu
t          1789608300
Body       {"id":"e2b8c6d4-1f07-4a39-95ce-6b3d0a8f7e21","type":"payment.recorded","createdAt":"2026-09-17T01:20:04.102Z","organizationId":"2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615","data":{"id":"c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22","paymentNo":"PAY-01877","loanId":"3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86","borrowerId":"a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64","branchId":"b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f","amount":"1250.00","breakdown":{"principal":"1071.43","interest":"128.57","penalty":"50.00"},"channel":"cash","externalReference":null,"paymentDate":"2026-09-17T01:20:00.000Z","status":"posted","reversedAt":null,"createdAt":"2026-09-17T01:20:03.884Z","updatedAt":"2026-09-17T01:20:03.884Z"}}
Signed     1789608300.{"id":"e2b8c6d4-1f07-4a39-95ce-6b3d0a8f7e21","type":"payment.recorded","createdAt":"2026-09-17T01:20:04.102Z","organizationId":"2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615","data":{"id":"c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22","paymentNo":"PAY-01877","loanId":"3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86","borrowerId":"a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64","branchId":"b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f","amount":"1250.00","breakdown":{"principal":"1071.43","interest":"128.57","penalty":"50.00"},"channel":"cash","externalReference":null,"paymentDate":"2026-09-17T01:20:00.000Z","status":"posted","reversedAt":null,"createdAt":"2026-09-17T01:20:03.884Z","updatedAt":"2026-09-17T01:20:03.884Z"}}
v1         b73f72c64f89d8bfb56ce38d26aef8b206636f498df5c1293cbadf0024234bac
Header     X-Lenduh-Signature: t=1789608300,v1=b73f72c64f89d8bfb56ce38d26aef8b206636f498df5c1293cbadf0024234bac
Webhooks

Delivery and retries

Events are queued as soon as they happen and sent within about five minutes. A delivery succeeds when your endpoint answers with a 2xx status within 5 seconds. Anything else counts as a failure: a timeout, a 4xx or 5xx, or a redirect, because redirects aren't followed.

Retry schedule

A failed delivery is retried up to five more times, six attempts in all. After the sixth failure it is marked failed.

AttemptWaits after the previous failure
1Sent within about 5 minutes of the event
21 minute
35 minutes
430 minutes
52 hours
66 hours

The queue runs every five minutes, so each wait rounds up to the next run. From the first attempt to the last takes about 8½ hours.

Endpoints that keep failing

After 15 failed attempts in a row to the same endpoint, Lenduh disables it and stops sending. Fix the problem, then switch the endpoint back on under Developer API → Webhooks. That resets the count. The endpoint's delivery log shows its last 50 deliveries, and you can resend any of them.

While the Developer API is off

If an admin switches the Developer API off, or your plan stops including it, Lenduh stops sending webhooks straight away. Events that happen while it's off aren't queued, and deliveries still waiting to go out are marked failed with the message Not sent: the Developer API was turned off before this delivery went out.

You can still disable or delete an endpoint while it's off. Once it's back on, you can resend those failed deliveries from the delivery log. To catch up on anything that happened while it was off, fetch the records from the API.

Duplicates and order

  • You can receive the same delivery more than once, for example when your server finishes the work but replies after the timeout. Store each id you've processed and skip repeats.
  • Deliveries aren't guaranteed to arrive in order. If order matters, fetch the current record from the API rather than trusting the payload's order.
Reply fast, work later
// Answer within 5 seconds, then do the real work from your own queue.
app.post('/webhooks/lenduh', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = verifyLenduhWebhook(req.body.toString('utf8'), req.get('X-Lenduh-Signature'));
  } catch {
    return res.sendStatus(400);
  }
  if (await processed.has(event.id)) return res.sendStatus(200); // a duplicate delivery
  await queue.add(event.type, event); // e.g. BullMQ, SQS, or a jobs table
  res.sendStatus(200);
});
# Answer within 5 seconds, then do the real work from your own queue.
@app.post("/webhooks/lenduh")
def lenduh_webhook():
    try:
        event = verify_lenduh_webhook(request.get_data(), request.headers.get("X-Lenduh-Signature", ""))
    except ValueError:
        abort(400)
    if processed.exists(event["id"]):  # a duplicate delivery
        return "", 200
    queue.enqueue(handle_lenduh_event, event)  # e.g. Celery, RQ, or a jobs table
    return "", 200
<?php
// Laravel: answer within 5 seconds, then do the real work in a queued job.
Route::post('/webhooks/lenduh', function (Illuminate\Http\Request $request) {
    try {
        $event = verifyLenduhWebhook(
            $request->getContent(),
            $request->header('X-Lenduh-Signature', ''),
            config('services.lenduh.webhook_secret'),
        );
    } catch (RuntimeException $e) {
        abort(400);
    }
    if (Cache::add('lenduh-event:' . $event['id'], true, now()->addDays(3))) {
        ProcessLenduhEvent::dispatch($event); // skipped when the id was already seen
    }
    return response()->noContent(200);
});
API reference

Borrowers, loans, schedules and payments

Every endpoint is a GET under https://app.lenduh.com/api/v1 and needs a key with the read scope.

Borrowers

The borrower object

A person or business that borrows from your organization. The API returns identifying fields only. Contact details and government IDs are never included.

Attributes

idstring (UUID)
Unique identifier.
borrowerCodestring
The borrower number your staff see, such as BRW-00318.
namestring
Full name as recorded in Lenduh.
statusenum
activeCan take new loans. inactiveNo longer borrowing. Existing loans are unaffected. blacklistedBlocked from new loans by your organization.
branchIdstring (UUID) or null
The branch the borrower belongs to.
createdAtstring
When the borrower was added, as an ISO 8601 timestamp.
updatedAtstring
When the borrower record last changed. See Syncing changes.
The borrower object
{
  "id": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
  "borrowerCode": "BRW-00318",
  "name": "Juan Dela Cruz",
  "status": "active",
  "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
  "createdAt": "2026-08-03T01:15:22.407Z",
  "updatedAt": "2026-09-12T06:40:18.221Z"
}
Borrowers

List borrowers

GET/v1/borrowers

Returns your organization's borrowers, newest first.

Query parameters

borrowerCodestringoptional
Only the borrower with this number, such as BRW-00318. Returns an empty page if there's none.
updatedSincestringoptional
Only borrowers changed at or after this time, oldest change first. See Syncing changes.
pageintegeroptional
Page number, from 1. See Pagination.
pageSizeintegeroptional
Records per page, 1 to 100. Defaults to 25.

Returns

A page of borrower objects.

GET/v1/borrowers
curl "https://app.lenduh.com/api/v1/borrowers?pageSize=2" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/borrowers?pageSize=2',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const borrowers = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/borrowers",
    params={"pageSize": 2},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
borrowers = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('borrowers', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['pageSize' => 2],
]);
$borrowers = json_decode((string) $res->getBody(), true);
Response · 200
{
  "items": [
    {
      "id": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
      "borrowerCode": "BRW-00318",
      "name": "Juan Dela Cruz",
      "status": "active",
      "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
      "createdAt": "2026-08-03T01:15:22.407Z",
      "updatedAt": "2026-09-12T06:40:18.221Z"
    },
    {
      "id": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "borrowerCode": "BRW-00317",
      "name": "Ma. Clara Reyes",
      "status": "active",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "createdAt": "2026-08-02T06:48:09.113Z",
      "updatedAt": "2026-08-02T06:48:09.113Z"
    }
  ],
  "total": 318,
  "page": 1,
  "pageSize": 2,
  "pageCount": 159
}
Borrowers

Retrieve a borrower

GET/v1/borrowers/:id

Returns one borrower.

Path parameters

idstring (UUID)required
The borrower's id. A value that isn't a UUID returns 400.

Returns

A borrower object, or 404 if there's no such borrower in your organization.

GET/v1/borrowers/:id
curl "https://app.lenduh.com/api/v1/borrowers/5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/borrowers/5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const borrower = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/borrowers/5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
borrower = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('borrowers/5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$borrower = json_decode((string) $res->getBody(), true);
Response
{
  "id": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
  "borrowerCode": "BRW-00318",
  "name": "Juan Dela Cruz",
  "status": "active",
  "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
  "createdAt": "2026-08-03T01:15:22.407Z",
  "updatedAt": "2026-09-12T06:40:18.221Z"
}
{
  "statusCode": 404,
  "message": "Borrower not found.",
  "error": "Not Found"
}
{
  "statusCode": 400,
  "message": "Validation failed (uuid is expected)",
  "error": "Bad Request"
}
Borrowers

Create a client

POST/v1/borrowers

Creates a client. Needs borrowers:write.

Your organization's own rules apply exactly as they do to a client typed in at a desk: the details it requires, its duplicate rules on code, email and mobile, and its numbering. This is not a second, looser way in.

This endpoint accepts more than the API returns. You can send a mobile number, a birth date, a government ID; none of them come back out of this API. A client object has six fields and always will, so a key can't be used to pull a client roster's personal details back out.

Body parameters

firstNamestringrequired
Given name.
lastNamestringrequired
Family name.
borrowerCodestringoptional
Your own client number. Left out, Lenduh issues one. A duplicate returns 409.
branchIdstring (UUID)optional
Which branch the client belongs to. A key is an organization-level credential with no branch of its own, so send this — a client created without it belongs to no branch and is invisible on every branch-scoped screen in the office. An id that isn't yours returns 404.
middleName, suffix, birthDate, gender, civilStatus, mobileNumber, email, …variousoptional
The profile fields the office form takes. Send what you hold; your organization's required details setting decides which are compulsory, and a missing one is refused by name so you know what to add.

Returns

The new client object, 201.

POST/v1/borrowers
curl -X POST "https://app.lenduh.com/api/v1/borrowers" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"borrowerCode":"BRW-00319","firstName":"Rosalinda","lastName":"Mabini","mobileNumber":"09171234567","branchId":"b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f"}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/borrowers',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "borrowerCode": "BRW-00319",
      "firstName": "Rosalinda",
      "lastName": "Mabini",
      "mobileNumber": "09171234567",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f"
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const borrower = await res.json();
import os

import requests

res = requests.post(
    "https://app.lenduh.com/api/v1/borrowers",
    json={
        "borrowerCode": "BRW-00319",
        "firstName": "Rosalinda",
        "lastName": "Mabini",
        "mobileNumber": "09171234567",
        "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
borrower = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->post('borrowers', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['borrowerCode' => 'BRW-00319', 'firstName' => 'Rosalinda', 'lastName' => 'Mabini', 'mobileNumber' => '09171234567', 'branchId' => 'b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f'],
]);
$borrower = json_decode((string) $res->getBody(), true);
Response
{
  "id": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
  "borrowerCode": "BRW-00319",
  "name": "Rosalinda Mabini",
  "status": "active",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "createdAt": "2026-09-18T07:41:02.663Z",
  "updatedAt": "2026-09-18T07:41:02.663Z"
}
{
  "statusCode": 409,
  "message": "A client with that code already exists.",
  "error": "Conflict"
}
{
  "statusCode": 403,
  "message": "API write access for \"Push payments collected outside the system\" is not currently granted to this organization. Request it in Settings → Developer API.",
  "error": "Forbidden"
}
Borrowers

Update a client

PATCH/v1/borrowers/:id

Updates a client. Needs borrowers:write. Send only the fields you're changing; anything you leave out is untouched.

Takes the same fields as creating one, except borrowerCode and branchId: a client's number and the branch it belongs to are changed in the office, where the consequences are visible.

Returns

The updated client object, or 404 if there's no such client in your organization.

PATCH/v1/borrowers/:id
curl -X PATCH "https://app.lenduh.com/api/v1/borrowers/6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mobileNumber":"09179876543"}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/borrowers/6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74',
  {
    method: 'PATCH',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "mobileNumber": "09179876543"
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const borrower = await res.json();
import os

import requests

res = requests.patch(
    "https://app.lenduh.com/api/v1/borrowers/6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
    json={
        "mobileNumber": "09179876543",
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
borrower = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->patch('borrowers/6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['mobileNumber' => '09179876543'],
]);
$borrower = json_decode((string) $res->getBody(), true);
Response
{
  "id": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
  "borrowerCode": "BRW-00319",
  "name": "Rosalinda Mabini",
  "status": "active",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "createdAt": "2026-09-18T07:41:02.663Z",
  "updatedAt": "2026-09-18T08:02:55.401Z"
}
{
  "statusCode": 404,
  "message": "No client with that id.",
  "error": "Not Found"
}
Loans

The loan object

A loan to one of your borrowers, from draft through to settlement.

Attributes

idstring (UUID)
Unique identifier.
loanNostring
The loan number your staff see, such as LN-000142.
borrowerIdstring (UUID)
The borrower this loan belongs to.
productIdstring (UUID) or null
The loan product it was opened under. null for loans created before products existed.
branchIdstring (UUID) or null
The branch the loan belongs to.
principalstring
Amount lent, in pesos, such as "25000.00".
totalIntereststring
Interest over the life of the loan, in pesos.
totalRepayablestring
What the borrower repays in all, before any penalty or discount. A restructure updates it.
repaymentFrequencyenum
dailyEvery day. weeklyEvery week. every_2_weeksEvery two weeks. semi_monthlyTwice a month. monthlyEvery month. single_payOne payment at maturity.
installmentCountinteger
How many installments the schedule has.
installmentAmountstring
The regular installment, in pesos. The last one can differ by the rounding. For each installment's exact amount, list the schedule.
statusenum
draftBeing prepared. Not yet released, and releaseDate is null. activeReleased and being repaid, with nothing overdue. releasedReleased. Treat it the same as active. New releases go straight to active, but some loans still carry this status. overdueReleased, with an installment past due. paidFully repaid. cancelledCancelled before release. No money went out. written_offWritten off as bad debt and out of the live portfolio. foreclosedSettled by repossessing and foreclosing the collateral.
restructuredboolean
true once the loan has been restructured. Its schedule then has more than one version.
releaseDatestring or null
The date the loan was released, YYYY-MM-DD. null for drafts and cancelled loans.
maturityDatestring or null
The date the last installment falls due, YYYY-MM-DD. null whenever releaseDate is.
createdAtstring
When the loan was created, as an ISO 8601 timestamp.
updatedAtstring
When the loan record last changed: its status, its terms or a restructure. Payments don't change it. See Syncing changes.

A loan never shows its interest rate, fees or penalty settings, only the amounts that result from them.

The loan object
{
  "id": "0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04",
  "loanNo": "LN-000142",
  "borrowerId": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
  "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
  "principal": "25000.00",
  "totalInterest": "3000.00",
  "totalRepayable": "28000.00",
  "repaymentFrequency": "semi_monthly",
  "installmentCount": 24,
  "installmentAmount": "1166.67",
  "status": "active",
  "restructured": false,
  "releaseDate": "2026-09-15",
  "maturityDate": "2027-09-15",
  "createdAt": "2026-09-14T02:11:47.512Z",
  "updatedAt": "2026-09-15T01:04:12.118Z"
}
Loans

List loans

GET/v1/loans

Returns your organization's loans, newest first. Filter by status to fetch, for example, only overdue loans.

Query parameters

statusenumoptional
Only loans with this status. One value per request. Any other value returns 400 with the list of valid ones.
borrowerIdstring (UUID)optional
Only this borrower's loans. A value that isn't a UUID returns 400.
loanNostringoptional
Only the loan with this number, such as LN-000142. Returns an empty page if there's none.
updatedSincestringoptional
Only loans changed at or after this time, oldest change first. See Syncing changes.
pageintegeroptional
Page number, from 1.
pageSizeintegeroptional
Records per page, 1 to 100. Defaults to 25.

Filters combine: borrowerId with status=overdue gives one borrower's overdue loans.

Returns

A page of loan objects.

GET/v1/loans
curl "https://app.lenduh.com/api/v1/loans?status=overdue&pageSize=2" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans?status=overdue&pageSize=2',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const loans = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loans",
    params={"status": "overdue", "pageSize": 2},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
loans = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loans', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['status' => 'overdue', 'pageSize' => 2],
]);
$loans = json_decode((string) $res->getBody(), true);
Response
{
  "items": [
    {
      "id": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "loanNo": "LN-000141",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "principal": "15000.00",
      "totalInterest": "1800.00",
      "totalRepayable": "16800.00",
      "repaymentFrequency": "monthly",
      "installmentCount": 6,
      "installmentAmount": "2800.00",
      "status": "overdue",
      "restructured": false,
      "releaseDate": "2026-07-01",
      "maturityDate": "2027-01-01",
      "createdAt": "2026-06-29T08:30:15.006Z",
      "updatedAt": "2026-09-02T00:30:04.771Z"
    },
    {
      "id": "d8e4b2c6-0a9f-4e13-8b7d-5c2f1a6e9b30",
      "loanNo": "LN-000118",
      "borrowerId": "f0b3d7a2-6e58-4c1d-9a04-7b2e8d5c3f19",
      "productId": "4c5d6e7f-8a9b-4c0d-8e1f-2a3b4c5d6e7f",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "principal": "8000.00",
      "totalInterest": "960.00",
      "totalRepayable": "8960.00",
      "repaymentFrequency": "weekly",
      "installmentCount": 16,
      "installmentAmount": "560.00",
      "status": "overdue",
      "restructured": true,
      "releaseDate": "2026-05-20",
      "maturityDate": "2026-10-28",
      "createdAt": "2026-05-19T03:42:51.662Z",
      "updatedAt": "2026-09-10T00:30:02.905Z"
    }
  ],
  "total": 11,
  "page": 1,
  "pageSize": 2,
  "pageCount": 6
}
{
  "statusCode": 400,
  "message": "status must be one of the following values: draft, released, active, overdue, paid, cancelled, written_off, foreclosed",
  "error": "Bad Request"
}
Loans

Retrieve a loan

GET/v1/loans/:id

Returns one loan.

Path parameters

idstring (UUID)required
The loan's id. You'll find it as loanId on payments and in webhook events.

Returns

A loan object, or 404 if there's no such loan in your organization.

GET/v1/loans/:id
curl "https://app.lenduh.com/api/v1/loans/0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans/0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const loan = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loans/0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
loan = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loans/0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$loan = json_decode((string) $res->getBody(), true);
Response
{
  "id": "0b6f3c2e-8d41-4c1a-9f2e-3a7d5e1b9c04",
  "loanNo": "LN-000142",
  "borrowerId": "5d2a9e61-7c3b-4f0a-8e15-b24c6f9d0a37",
  "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "branchId": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
  "principal": "25000.00",
  "totalInterest": "3000.00",
  "totalRepayable": "28000.00",
  "repaymentFrequency": "semi_monthly",
  "installmentCount": 24,
  "installmentAmount": "1166.67",
  "status": "active",
  "restructured": false,
  "releaseDate": "2026-09-15",
  "maturityDate": "2027-09-15",
  "createdAt": "2026-09-14T02:11:47.512Z",
  "updatedAt": "2026-09-15T01:04:12.118Z"
}
{
  "statusCode": 404,
  "message": "Loan not found.",
  "error": "Not Found"
}
Loans

Retrieve a loan's balance

GET/v1/loans/:id/balance

What the loan owes and has paid, worked out when you ask. These are the same figures as the loan page in Lenduh, so what your system shows a borrower matches what your staff see.

Path parameters

idstring (UUID)required
The loan's id.

The balance object

loanIdstring (UUID)
The loan.
asOfstring
The day in Manila the figures are for, YYYY-MM-DD. Whether an installment is overdue depends on it.
outstandingstring
Principal and interest still owed on the current schedule. Penalties aren't included.
overdueAmountstring
The part of outstanding that is past due.
overdueInstallmentsinteger
How many installments are past due and not fully paid.
penaltyOutstandingstring
Penalties charged and not yet paid.
nextDueDatestring or null
The due date of the earliest installment that still owes money. It's in the past while the loan is overdue. null when nothing is owed.
nextDueAmountstring or null
What's still owed on that installment, not its full amount.
totalPaidstring
Principal and interest paid so far, including on installments a restructure later replaced. Reversed payments don't count.
penaltyPaidstring
Penalties paid so far.
paidInstallmentsinteger
Installments paid in full.
totalInstallmentsinteger
Installments on the current schedule. Ones a restructure replaced aren't counted.

A draft has no schedule yet, so its figures are zero. A written-off or foreclosed loan owes nothing more.

GET/v1/loans/:id/balance
curl "https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/balance" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/balance',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const balance = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/balance",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
balance = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/balance', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$balance = json_decode((string) $res->getBody(), true);
Response
{
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "asOf": "2026-09-18",
  "outstanding": "12800.00",
  "overdueAmount": "1600.00",
  "overdueInstallments": 1,
  "penaltyOutstanding": "0.00",
  "nextDueDate": "2026-09-01",
  "nextDueAmount": "1600.00",
  "totalPaid": "4000.00",
  "penaltyPaid": "50.00",
  "paidInstallments": 1,
  "totalInstallments": 6
}
{
  "statusCode": 404,
  "message": "Loan not found.",
  "error": "Not Found"
}
Loans

Quote a loan

POST/v1/loans/preview

Works out the schedule these terms would produce, and writes nothing. Needs loans:write.

Every term is required here, because a quote answers "what would these produce" — it isn't the place to discover a product's defaults. Leave the terms out when you apply and the product's own defaults are used instead.

Body parameters

borrowerIdstring (UUID)required
Who the loan is for.
loanProductIdstring (UUID)required
Which product. It decides how interest is calculated; you can't assert that yourself, because it would change what the same rate means.
principalnumberrequired
Amount borrowed.
interestRatenumberrequired
Per period, as a decimal: 0.01 is 1%.
repaymentFrequencystringrequired
daily, weekly, every_2_weeks, semi_monthly, monthly, single_pay — the same spellings every read returns, so a product's defaultRepaymentFrequency can be passed straight back in.
installmentCountintegerrequired
How many payments.
releaseDatestringoptional
YYYY-MM-DD. Without it the totals come back but installments is empty — there's nothing to date the rows from. Send it if you want the dated schedule.

Returns

The totals, and installments: each row's number, dueDate, principal, interest and amountDue.

POST/v1/loans/preview
curl -X POST "https://app.lenduh.com/api/v1/loans/preview" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"borrowerId":"6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74","loanProductId":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","principal":20000,"interestRate":0.01,"repaymentFrequency":"semi_monthly","installmentCount":24,"releaseDate":"2026-09-30"}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans/preview',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "borrowerId": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
      "loanProductId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "principal": 20000,
      "interestRate": 0.01,
      "repaymentFrequency": "semi_monthly",
      "installmentCount": 24,
      "releaseDate": "2026-09-30"
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const quote = await res.json();
import os

import requests

res = requests.post(
    "https://app.lenduh.com/api/v1/loans/preview",
    json={
        "borrowerId": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
        "loanProductId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
        "principal": 20000,
        "interestRate": 0.01,
        "repaymentFrequency": "semi_monthly",
        "installmentCount": 24,
        "releaseDate": "2026-09-30",
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
quote = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->post('loans/preview', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['borrowerId' => '6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74', 'loanProductId' => '9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d', 'principal' => 20000, 'interestRate' => 0.01, 'repaymentFrequency' => 'semi_monthly', 'installmentCount' => 24, 'releaseDate' => '2026-09-30'],
]);
$quote = json_decode((string) $res->getBody(), true);
Response · 201
{
  "principal": "20000.00",
  "interestRate": "0.0100",
  "repaymentFrequency": "semi_monthly",
  "installmentCount": 24,
  "totalInterest": "2400.00",
  "totalRepayable": "22400.00",
  "installmentAmount": "933.33",
  "lastInstallment": "933.41",
  "maturityDate": "2027-09-30",
  "installments": [
    {
      "number": 1,
      "dueDate": "2026-10-15",
      "principal": "833.33",
      "interest": "100.00",
      "amountDue": "933.33"
    },
    {
      "number": 2,
      "dueDate": "2026-10-31",
      "principal": "833.33",
      "interest": "100.00",
      "amountDue": "933.33"
    }
  ]
}
Loans

Apply for a loan

POST/v1/loans

Submits a loan application. Needs loans:write.

The result is a draft. Nothing is disbursed, no schedule is generated, and the money waits for someone in your office to release it. There is no release endpoint on this API under any scope.

Body parameters

borrowerIdstring (UUID)required
Who the loan is for.
principalnumberrequired
Amount applied for.
loanProductIdstring (UUID)optional
Which product.
loanNostringoptional
Your own application number. Left out, Lenduh issues one.
interestRate, repaymentFrequency, installmentCount, releaseDate, maturityDate, firstDueDatevariousoptional
Terms. Left out, the product's own defaults apply, exactly as in the office.
coBorrowerId, collectorIdstring (UUID)optional
A co-borrower, and the officer who will handle it.

What an application can't do

Some of the office form's fields mean "a person looked at this warning and went ahead", and an integration can't assert that on someone's behalf. They aren't accepted here, so:

  • A rate far outside your reference rate is refused, rather than waved through.
  • A client's credit limit can't be overridden.
  • Penalty and early-settlement terms come from the product. An application that could rewrite them wouldn't be an application.

The checks a person is meant to look at — collateral cover, take-home pay, an open credit investigation — all stay closed to a key.

Returns

The new loan object, 201, with status draft and no releaseDate or maturityDate. loan.released arrives later, when someone releases it.

POST/v1/loans
curl -X POST "https://app.lenduh.com/api/v1/loans" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"loanNo":"APP-2026-0917","borrowerId":"6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74","loanProductId":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","principal":20000,"installmentCount":24}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "loanNo": "APP-2026-0917",
      "borrowerId": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
      "loanProductId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "principal": 20000,
      "installmentCount": 24
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const application = await res.json();
import os

import requests

res = requests.post(
    "https://app.lenduh.com/api/v1/loans",
    json={
        "loanNo": "APP-2026-0917",
        "borrowerId": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
        "loanProductId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
        "principal": 20000,
        "installmentCount": 24,
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
application = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->post('loans', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['loanNo' => 'APP-2026-0917', 'borrowerId' => '6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74', 'loanProductId' => '9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d', 'principal' => 20000, 'installmentCount' => 24],
]);
$application = json_decode((string) $res->getBody(), true);
Response
{
  "id": "2a7e5c19-0d63-4b8f-9e14-6c3a8b2d7f05",
  "loanNo": "APP-2026-0917",
  "borrowerId": "6e1f8a20-4b7c-4d39-9f52-8a0c3e6b1d74",
  "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "principal": "20000.00",
  "totalInterest": "2400.00",
  "totalRepayable": "22400.00",
  "repaymentFrequency": "semi_monthly",
  "installmentCount": 24,
  "installmentAmount": "933.33",
  "status": "draft",
  "restructured": false,
  "releaseDate": null,
  "maturityDate": null,
  "createdAt": "2026-09-18T07:44:19.028Z",
  "updatedAt": "2026-09-18T07:44:19.028Z"
}
{
  "statusCode": 404,
  "message": "Borrower not found.",
  "error": "Not Found"
}
Installments

The installment object

One scheduled repayment on a loan: the same schedule the borrower sees on their statement.

Attributes

idstring (UUID)
Unique identifier.
loanIdstring (UUID)
The loan it belongs to.
scheduleVersioninteger
0 for the schedule the loan was released with. Each restructure adds a version.
numberinteger
Its place within its schedule version, from 1. A restructure's new schedule starts again at 1, so identify an installment by id, or by scheduleVersion and number together.
dueDatestring
When it falls due, YYYY-MM-DD.
principalstring
The principal part of amountDue.
intereststring
The interest part of amountDue.
amountDuestring
principal plus interest.
amountPaidstring
Cash applied to amountDue so far.
discountstring
Any early-settlement discount applied to it. It isn't cash, so it isn't part of amountPaid.
penaltyChargedstring
Every penalty charged on it.
penaltyPaidstring
How much of that has been paid.
statusenum
unpaidNothing paid yet. partialPart paid. paidPaid in full. overduePast due and not paid in full. The nightly run at 8:30 am Manila sets it, so for a few hours after its due date an installment can still read unpaid or partial. The balance counts it as overdue straight away. waivedForgiven by your staff. derecognisedNo longer owed, because the loan was written off or foreclosed.
lastPaymentAtstring or null
When a payment last went to this installment. null if none has.
The installment object
{
  "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "scheduleVersion": 0,
  "number": 2,
  "dueDate": "2026-09-01",
  "principal": "2500.00",
  "interest": "300.00",
  "amountDue": "2800.00",
  "amountPaid": "1200.00",
  "discount": "0.00",
  "penaltyCharged": "50.00",
  "penaltyPaid": "50.00",
  "status": "overdue",
  "lastPaymentAt": "2026-09-17T01:20:00.000Z"
}
Installments

List a loan's installments

GET/v1/loans/:id/schedule

Returns the loan's repayment schedule in order: oldest schedule version first, then by number.

Installments that a restructure replaced are left out, as they are on the borrower's statement, because they're no longer owed. Payments made on them still appear in List payments and count in the balance's totalPaid. Installments from an earlier version that a restructure didn't replace are still owed and are included.

Path parameters

idstring (UUID)required
The loan's id.

Query parameters

pageintegeroptional
Page number, from 1.
pageSizeintegeroptional
Installments per page, 1 to 100. Defaults to 25. A daily loan can have hundreds of installments, so page through them.

Returns

A page of installment objects, or 404 if there's no such loan in your organization.

GET/v1/loans/:id/schedule
curl "https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/schedule?pageSize=3" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/schedule?pageSize=3',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const installments = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/schedule",
    params={"pageSize": 3},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
installments = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/schedule', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['pageSize' => 3],
]);
$installments = json_decode((string) $res->getBody(), true);
Response
{
  "items": [
    {
      "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "scheduleVersion": 0,
      "number": 1,
      "dueDate": "2026-08-01",
      "principal": "2500.00",
      "interest": "300.00",
      "amountDue": "2800.00",
      "amountPaid": "2800.00",
      "discount": "0.00",
      "penaltyCharged": "0.00",
      "penaltyPaid": "0.00",
      "status": "paid",
      "lastPaymentAt": "2026-08-01T02:31:44.019Z"
    },
    {
      "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "scheduleVersion": 0,
      "number": 2,
      "dueDate": "2026-09-01",
      "principal": "2500.00",
      "interest": "300.00",
      "amountDue": "2800.00",
      "amountPaid": "1200.00",
      "discount": "0.00",
      "penaltyCharged": "50.00",
      "penaltyPaid": "50.00",
      "status": "overdue",
      "lastPaymentAt": "2026-09-17T01:20:00.000Z"
    },
    {
      "id": "3c4d5e6f-7a8b-4c9d-8e0f-2a3b4c5d6e7f",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "scheduleVersion": 0,
      "number": 3,
      "dueDate": "2026-10-01",
      "principal": "2500.00",
      "interest": "300.00",
      "amountDue": "2800.00",
      "amountPaid": "0.00",
      "discount": "0.00",
      "penaltyCharged": "0.00",
      "penaltyPaid": "0.00",
      "status": "unpaid",
      "lastPaymentAt": null
    }
  ],
  "total": 6,
  "page": 1,
  "pageSize": 3,
  "pageCount": 2
}
{
  "statusCode": 404,
  "message": "Loan not found.",
  "error": "Not Found"
}
Installments

List installments across loans

GET/v1/schedules

What falls due between two dates, across every loan, earliest first. This is the call to make before a collection run: one request, rather than listing the loans and asking each one for its schedule.

Query parameters

dueDateFrom, dueDateTostringoptional
YYYY-MM-DD, both ends included.
statusstringoptional
One or more, comma-separated: unpaid, partial, paid, overdue, waived, restructured, derecognised. Everything still owed is unpaid,partial,overdue. A value we don't recognise is refused rather than ignored — a dropped typo would quietly return the wrong installments.
loanId, borrowerIdstring (UUID)optional
Narrow to one loan or one client.
page, pageSizeintegeroptional
See Pagination.

With no status, it returns every installment a loan still recognises — the same rows /v1/loans/:id/schedule returns for that loan, so the two never disagree. Rows replaced by a restructure, and rows on a written-off or foreclosed loan, are left out unless you name them.

Returns

A page of installment objects, ordered by due date.

GET/v1/schedules
curl "https://app.lenduh.com/api/v1/schedules?dueDateFrom=2026-09-21&dueDateTo=2026-09-27&status=unpaid,partial,overdue" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/schedules?dueDateFrom=2026-09-21&dueDateTo=2026-09-27&status=unpaid,partial,overdue',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const due = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/schedules",
    params={"dueDateFrom": "2026-09-21", "dueDateTo": "2026-09-27", "status": "unpaid,partial,overdue"},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
due = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('schedules', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['dueDateFrom' => '2026-09-21', 'dueDateTo' => '2026-09-27', 'status' => 'unpaid,partial,overdue'],
]);
$due = json_decode((string) $res->getBody(), true);
Response
{
  "items": [
    {
      "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "scheduleVersion": 0,
      "number": 2,
      "dueDate": "2026-09-01",
      "principal": "2500.00",
      "interest": "300.00",
      "amountDue": "2800.00",
      "amountPaid": "1200.00",
      "discount": "0.00",
      "penaltyCharged": "50.00",
      "penaltyPaid": "50.00",
      "status": "overdue",
      "lastPaymentAt": "2026-09-17T01:20:00.000Z"
    },
    {
      "id": "3c4d5e6f-7a8b-4c9d-8e0f-2a3b4c5d6e7f",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "scheduleVersion": 0,
      "number": 3,
      "dueDate": "2026-10-01",
      "principal": "2500.00",
      "interest": "300.00",
      "amountDue": "2800.00",
      "amountPaid": "0.00",
      "discount": "0.00",
      "penaltyCharged": "0.00",
      "penaltyPaid": "0.00",
      "status": "unpaid",
      "lastPaymentAt": null
    }
  ],
  "total": 37,
  "page": 1,
  "pageSize": 25,
  "pageCount": 2
}
{
  "statusCode": 400,
  "message": "status must be one of the following values, comma-separated: unpaid, partial, paid, overdue, waived, restructured, derecognised",
  "error": "Bad Request"
}
Installments

Total what falls due

GET/v1/schedules/summary

Totals over the whole filtered set, not the page you happen to be on. Takes exactly the same query as the list.

Use it when you need the figure rather than the rows — how much a run should expect to collect this week. Adding up items would answer for 25 installments out of 900.

Returns

installmentsinteger
How many rows match.
amountDuestring
Principal plus interest across them.
amountPaidstring
What has been paid against them.
outstandingstring
amountDue less amountPaid. Penalties aren't in it, exactly as they aren't in amountDue.
penaltyChargedstring
Every penalty charged on the set.
GET/v1/schedules/summary
curl "https://app.lenduh.com/api/v1/schedules/summary?dueDateFrom=2026-09-21&dueDateTo=2026-09-27" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/schedules/summary?dueDateFrom=2026-09-21&dueDateTo=2026-09-27',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const totals = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/schedules/summary",
    params={"dueDateFrom": "2026-09-21", "dueDateTo": "2026-09-27"},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
totals = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('schedules/summary', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['dueDateFrom' => '2026-09-21', 'dueDateTo' => '2026-09-27'],
]);
$totals = json_decode((string) $res->getBody(), true);
Response · 200
{
  "installments": 37,
  "amountDue": "96400.00",
  "amountPaid": "18200.00",
  "outstanding": "78200.00",
  "penaltyCharged": "350.00"
}
Payments

The payment object

A repayment recorded against a loan.

Attributes

idstring (UUID)
Unique identifier.
paymentNostring
The payment number your staff see, such as PAY-01877.
loanIdstring (UUID)
The loan the payment was applied to.
borrowerIdstring (UUID)
The borrower who paid.
branchIdstring (UUID) or null
The branch of the loan it was applied to.
amountstring
Amount paid, in pesos, such as "1250.00". A reversed payment keeps its original amount.
breakdownobject
How amount divides up, as Lenduh books it and prints it on the official receipt. The three parts always add up to amount. A reversed payment keeps its breakdown.
breakdown.principalstring
Paid toward principal.
breakdown.intereststring
Paid toward interest.
breakdown.penaltystring
Paid toward penalties.
channelenum
Where the money came from: cash at the counter or from a collector, online through a payment link, external pushed in through this API, or cbu_offset applied from a client's capital build-up. This is how you ignore your own pushes in the payment.recorded webhook one of them fires.
externalReferencestring or null
The reference sent with a pushed payment; null for a payment taken any other way. Match it against your own records to reconcile.
paymentDatestring
When the payment was received, as an ISO 8601 timestamp.
statusenum
postedThe payment stands and counts toward the loan. reversedStaff reversed it, for example after a bounced cheque. It no longer counts toward the loan.
reversedAtstring or null
When the payment was reversed, as an ISO 8601 timestamp. null while it's posted.
createdAtstring
When it was recorded in Lenduh.
updatedAtstring
When the payment last changed: when it was recorded, or when it was reversed. See Syncing changes.

Posting to an accounting system? Use breakdown, not your own split of amount. The split depends on which installments the payment reached and what penalties they carried, and breakdown is the one Lenduh's own books use.

Totalling collections? Reversed payments stay in the list, so your records keep matching Lenduh's. Count only posted payments, or pass status=posted.

The payment object
{
  "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
  "paymentNo": "PAY-01877",
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "amount": "1250.00",
  "breakdown": {
    "principal": "1071.43",
    "interest": "128.57",
    "penalty": "50.00"
  },
  "channel": "cash",
  "externalReference": null,
  "paymentDate": "2026-09-17T01:20:00.000Z",
  "status": "posted",
  "reversedAt": null,
  "createdAt": "2026-09-17T01:20:03.884Z",
  "updatedAt": "2026-09-17T01:20:03.884Z"
}
Payments

List payments

GET/v1/payments

Returns your organization's payments, newest payment date first. Pass loanId to see one loan's payment history.

Query parameters

loanIdstring (UUID)optional
Only payments applied to this loan. A value that isn't a UUID returns 400.
borrowerIdstring (UUID)optional
Only this borrower's payments, across all their loans.
statusenumoptional
posted or reversed. Leave it out to get both.
paymentDateFromstringoptional
Only payments received on or after this day in Manila, YYYY-MM-DD.
paymentDateTostringoptional
Only payments received on or before this day in Manila, YYYY-MM-DD. Use the same day for both to get one day's collections. A range that ends before it starts returns 400.
updatedSincestringoptional
Only payments recorded or reversed at or after this time, oldest change first. See Syncing changes.
pageintegeroptional
Page number, from 1.
pageSizeintegeroptional
Records per page, 1 to 100. Defaults to 25.

Filters combine, so paymentDateFrom, paymentDateTo and status=posted together give one day's collections.

Returns

A page of payment objects.

GET/v1/payments
curl "https://app.lenduh.com/api/v1/payments?loanId=3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86&pageSize=2" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/payments?loanId=3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86&pageSize=2',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const payments = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/payments",
    params={"loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86", "pageSize": 2},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
payments = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('payments', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['loanId' => '3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86', 'pageSize' => 2],
]);
$payments = json_decode((string) $res->getBody(), true);
Response
{
  "items": [
    {
      "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
      "paymentNo": "PAY-01877",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "amount": "1250.00",
      "breakdown": {
        "principal": "1071.43",
        "interest": "128.57",
        "penalty": "50.00"
      },
      "channel": "cash",
      "externalReference": null,
      "paymentDate": "2026-09-17T01:20:00.000Z",
      "status": "posted",
      "reversedAt": null,
      "createdAt": "2026-09-17T01:20:03.884Z",
      "updatedAt": "2026-09-17T01:20:03.884Z"
    },
    {
      "id": "81f5d3a0-9c2e-4e7b-b6a4-2d0f8e1c7b93",
      "paymentNo": "PAY-01876",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "amount": "1250.00",
      "breakdown": {
        "principal": "1116.07",
        "interest": "133.93",
        "penalty": "0.00"
      },
      "channel": "cash",
      "externalReference": null,
      "paymentDate": "2026-09-03T02:05:00.000Z",
      "status": "reversed",
      "reversedAt": "2026-09-04T03:10:27.915Z",
      "createdAt": "2026-09-03T02:05:41.270Z",
      "updatedAt": "2026-09-04T03:10:27.915Z"
    }
  ],
  "total": 3,
  "page": 1,
  "pageSize": 2,
  "pageCount": 2
}
{
  "statusCode": 400,
  "message": "loanId must be a UUID",
  "error": "Bad Request"
}
Payments

Retrieve a payment

GET/v1/payments/:id

Returns one payment. Useful after a webhook, to check a payment's current status.

Path parameters

idstring (UUID)required
The payment's id.

Returns

A payment object, or 404 if there's no such payment in your organization.

GET/v1/payments/:id
curl "https://app.lenduh.com/api/v1/payments/c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/payments/c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const payment = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/payments/c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
payment = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('payments/c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$payment = json_decode((string) $res->getBody(), true);
Response
{
  "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
  "paymentNo": "PAY-01877",
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "amount": "1250.00",
  "breakdown": {
    "principal": "1071.43",
    "interest": "128.57",
    "penalty": "50.00"
  },
  "channel": "cash",
  "externalReference": null,
  "paymentDate": "2026-09-17T01:20:00.000Z",
  "status": "posted",
  "reversedAt": null,
  "createdAt": "2026-09-17T01:20:03.884Z",
  "updatedAt": "2026-09-17T01:20:03.884Z"
}
{
  "statusCode": 404,
  "message": "Payment not found.",
  "error": "Not Found"
}
Payments

List a loan's payments

GET/v1/loans/:id/payments

One loan's payments, newest first. The same records as /v1/payments?loanId=, except that a loan that isn't yours is a 404 here rather than an empty page — useful when you want to tell "no payments" apart from "wrong id".

Query parameters

statusstringoptional
posted or reversed.
updatedSincestringoptional
See Syncing changes.
page, pageSizeintegeroptional
See Pagination.

Returns

A page of payment objects.

GET/v1/loans/:id/payments
curl "https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/payments?pageSize=2" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/payments?pageSize=2',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const payments = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/payments",
    params={"pageSize": 2},
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
payments = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loans/3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86/payments', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'query'   => ['pageSize' => 2],
]);
$payments = json_decode((string) $res->getBody(), true);
Response
{
  "items": [
    {
      "id": "f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37",
      "paymentNo": "PAY-01878",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "amount": "1600.00",
      "breakdown": {
        "principal": "1428.57",
        "interest": "171.43",
        "penalty": "0.00"
      },
      "channel": "external",
      "externalReference": "BC-2026-09-18-004412",
      "paymentDate": "2026-09-18T06:02:00.000Z",
      "status": "posted",
      "reversedAt": null,
      "createdAt": "2026-09-18T06:02:11.907Z",
      "updatedAt": "2026-09-18T06:02:11.907Z"
    },
    {
      "id": "c47a2e19-5f3d-4b86-a0e1-9d6b3f7c5a22",
      "paymentNo": "PAY-01877",
      "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
      "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "amount": "1250.00",
      "breakdown": {
        "principal": "1071.43",
        "interest": "128.57",
        "penalty": "50.00"
      },
      "channel": "cash",
      "externalReference": null,
      "paymentDate": "2026-09-17T01:20:00.000Z",
      "status": "posted",
      "reversedAt": null,
      "createdAt": "2026-09-17T01:20:03.884Z",
      "updatedAt": "2026-09-17T01:20:03.884Z"
    }
  ],
  "total": 3,
  "page": 1,
  "pageSize": 2,
  "pageCount": 2
}
{
  "statusCode": 404,
  "message": "Loan not found.",
  "error": "Not Found"
}
Payments

Preview a payment

POST/v1/payments/preview

Answers what a payment would do, and writes nothing at all. Same body as pushing one.

This is how you avoid the refusal below. A loan takes only what it still owes, so a payment larger than that is refused outright rather than left sitting as credit — the preview tells you the number to send instead, in applied, and what wouldn't fit, in remainder. Anything above zero there means the real call would refuse.

It needs the same scope as posting a payment, because working out what a payment would pay off accrues any penalty the loan has earned by today.

Returns

appliedstring
What the loan would actually take.
remainderstring
What it couldn't. Above zero means the real call returns 400.
penaltyCollectedstring
How much of it would go to penalties rather than the loan.
outstandingAfterstring
What the loan would still owe.
willClearLoanboolean
Whether it would pay the loan off entirely.
allocationsarray
Which installments it would touch, and by how much.
POST/v1/payments/preview
curl -X POST "https://app.lenduh.com/api/v1/payments/preview" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"loanNo":"LN-000141","amount":1600,"externalReference":"BC-2026-09-18-004412"}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/payments/preview',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "loanNo": "LN-000141",
      "amount": 1600,
      "externalReference": "BC-2026-09-18-004412"
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const preview = await res.json();
import os

import requests

res = requests.post(
    "https://app.lenduh.com/api/v1/payments/preview",
    json={
        "loanNo": "LN-000141",
        "amount": 1600,
        "externalReference": "BC-2026-09-18-004412",
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
preview = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->post('payments/preview', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['loanNo' => 'LN-000141', 'amount' => 1600, 'externalReference' => 'BC-2026-09-18-004412'],
]);
$preview = json_decode((string) $res->getBody(), true);
Response
{
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "loanNo": "LN-000141",
  "amount": "1600.00",
  "paymentDate": "2026-09-18T06:02:00.000Z",
  "applied": "1600.00",
  "remainder": "0.00",
  "willClearLoan": false,
  "penaltyCollected": "0.00",
  "outstandingAfter": "11200.00",
  "allocations": [
    {
      "installmentId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
      "number": 2,
      "dueDate": "2026-09-01",
      "remainingDue": "1600.00",
      "appliedToDue": "1600.00",
      "appliedToPenalty": "0.00"
    }
  ]
}
{
  "statusCode": 403,
  "message": "API write access for \"Push payments collected outside the system\" is not currently granted to this organization. Request it in Settings → Developer API.",
  "error": "Forbidden"
}
{
  "statusCode": 404,
  "message": "No loan numbered LN-000141.",
  "error": "Not Found"
}
Payments

Push a payment

POST/v1/payments

Records a payment your organization collected outside Lenduh — at a payment centre, through a partner, in your own field app. Needs payments:write.

It goes through the same allocation as a payment typed in at the counter: penalties first, then the oldest installment, then forward. It issues a receipt, it fires payment.recorded, and it appears in your reports.

Body parameters

loanId or loanNostringone of
Which loan. Send exactly one; sending both is a 400.
amountnumberrequired
Pesos collected for the loan, at most two decimals. More than the loan owes is refused — preview first.
externalReferencestringrequired
Your own reference for this collection — the payment centre's transaction number, your own receipt number. It must be unique within your organization, and it is what makes the push safe to retry. See below.
paymentDatestringoptional
When the money was taken, ISO-8601 with a time zone. Defaults to now. Not in the future, and not more than 7 days back: this is a collection feed, not a backfill. Older records are loaded by migration.
cbuAmountnumberoptional
Capital build-up taken with the loan payment — microfinance NGOs only, refused for anyone else. Separate from amount, which stays the loan part: one collection, one receipt, two lines.
remarksstringoptional
Up to 500 characters, kept on the payment.

Retrying is safe

If a request times out, send it again with the same externalReference. You'll get the payment that was already made, with 200 instead of 201 — never a second one. Watch the status code to tell a new payment from a replay.

Sending the same reference with a different amount returns 409: that's a contradiction, not a retry, and guessing which one you meant is not our place.

After a reversal, that reference is free again. Reversing a pushed payment releases its reference, so sending it once more creates a new payment rather than returning the reversed one. That is deliberate — a bounced collection that is later made good is a real second payment — but it means a blind retry loop running after a reversal will post the money twice. Retry on a timeout, not on a reversal.

What you don't send

No collector, no cash drawer, no GPS. Those answer questions only someone in your office can answer, and a pushed payment has nobody of ours behind it: no collector is credited for it, no remittance is opened against one, and no commission is earned on a collection they never made. In your books the money lands in collections in transit — it's with your partner until they settle with you — rather than in a cash drawer nobody opened.

Returns

The payment object, 201 for a new one and 200 for a replay. Its channel is external and its externalReference is the one you sent, which is how you recognise your own pushes — including in the payment.recorded webhook your push fires.

POST/v1/payments
curl -X POST "https://app.lenduh.com/api/v1/payments" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"loanNo":"LN-000141","amount":1600,"externalReference":"BC-2026-09-18-004412","paymentDate":"2026-09-18T06:02:00.000Z"}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/payments',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "loanNo": "LN-000141",
      "amount": 1600,
      "externalReference": "BC-2026-09-18-004412",
      "paymentDate": "2026-09-18T06:02:00.000Z"
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const payment = await res.json();
import os

import requests

res = requests.post(
    "https://app.lenduh.com/api/v1/payments",
    json={
        "loanNo": "LN-000141",
        "amount": 1600,
        "externalReference": "BC-2026-09-18-004412",
        "paymentDate": "2026-09-18T06:02:00.000Z",
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
payment = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->post('payments', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['loanNo' => 'LN-000141', 'amount' => 1600, 'externalReference' => 'BC-2026-09-18-004412', 'paymentDate' => '2026-09-18T06:02:00.000Z'],
]);
$payment = json_decode((string) $res->getBody(), true);
Response
{
  "id": "f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37",
  "paymentNo": "PAY-01878",
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "amount": "1600.00",
  "breakdown": {
    "principal": "1428.57",
    "interest": "171.43",
    "penalty": "0.00"
  },
  "channel": "external",
  "externalReference": "BC-2026-09-18-004412",
  "paymentDate": "2026-09-18T06:02:00.000Z",
  "status": "posted",
  "reversedAt": null,
  "createdAt": "2026-09-18T06:02:11.907Z",
  "updatedAt": "2026-09-18T06:02:11.907Z"
}
{
  "id": "f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37",
  "paymentNo": "PAY-01878",
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "amount": "1600.00",
  "breakdown": {
    "principal": "1428.57",
    "interest": "171.43",
    "penalty": "0.00"
  },
  "channel": "external",
  "externalReference": "BC-2026-09-18-004412",
  "paymentDate": "2026-09-18T06:02:00.000Z",
  "status": "posted",
  "reversedAt": null,
  "createdAt": "2026-09-18T06:02:11.907Z",
  "updatedAt": "2026-09-18T06:02:11.907Z"
}
{
  "statusCode": 400,
  "message": "paymentDate is more than 7 days ago. Payments this old are loaded by migration, not pushed.",
  "error": "Bad Request"
}
{
  "statusCode": 403,
  "message": "This API key lacks the required 'payments:write' scope.",
  "error": "Forbidden"
}
{
  "statusCode": 409,
  "message": "Idempotency-Key was already used with a different request body",
  "error": "Conflict"
}
Response
{
  "statusCode": 400,
  "message": "paymentDate is in the future — a payment cannot be recorded before it is taken.",
  "error": "Bad Request"
}
{
  "statusCode": 400,
  "message": "Send exactly one of loanId or loanNo.",
  "error": "Bad Request"
}
Payments

Reverse a payment you pushed

POST/v1/payments/:id/reverse

Takes a pushed payment back off the loan — a collection that bounced, or one sent against the wrong loan. Needs payments:write.

Only payments this API pushed. A payment your own cashier took is a 404, not a 403: a key shouldn't be able to learn that it exists, let alone reach for it.

Body parameters

reasonstringrequired
Why the money is coming back off. Required here although the office's own form doesn't demand it: an office reversal has a named person behind it in the audit trail, and a pushed one has only a key, so these words are the whole account of it.

When your organization signs reversals off

If your organization requires approval for payment reversals and this one would meet the rule, the call returns 409 and nothing changes. An API key has no approver behind it, so the reversal has to be made in the office, where someone can sign for it. A key must never walk past a control your organization set up.

Returns

The reversed payment object, with status reversed. It fires payment.reversed. Reversing twice is a 409.

POST/v1/payments/:id/reverse
curl -X POST "https://app.lenduh.com/api/v1/payments/f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37/reverse" \
  -H "Authorization: Bearer $LENDUH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Cheque returned by the bank — insufficient funds."}'
const res = await fetch(
  'https://app.lenduh.com/api/v1/payments/f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37/reverse',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LENDUH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      "reason": "Cheque returned by the bank — insufficient funds."
    }),
  },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const payment = await res.json();
import os

import requests

res = requests.post(
    "https://app.lenduh.com/api/v1/payments/f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37/reverse",
    json={
        "reason": "Cheque returned by the bank — insufficient funds.",
    },
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
payment = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->post('payments/f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37/reverse', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
    'json'    => ['reason' => 'Cheque returned by the bank — insufficient funds.'],
]);
$payment = json_decode((string) $res->getBody(), true);
Response
{
  "id": "f6a1b4c8-3d2e-4790-8a5b-1c6d9e0f2a37",
  "paymentNo": "PAY-01878",
  "loanId": "3e8d1b57-6a2f-4c90-b7d3-1f5a9e0c2b86",
  "borrowerId": "a93c0f4e-2b71-4d6a-9c58-0e7f3b2d1a64",
  "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "amount": "1600.00",
  "breakdown": {
    "principal": "1428.57",
    "interest": "171.43",
    "penalty": "0.00"
  },
  "channel": "external",
  "externalReference": "BC-2026-09-18-004412",
  "paymentDate": "2026-09-18T06:02:00.000Z",
  "status": "reversed",
  "reversedAt": "2026-09-19T01:30:44.216Z",
  "createdAt": "2026-09-18T06:02:11.907Z",
  "updatedAt": "2026-09-19T01:30:44.216Z"
}
{
  "statusCode": 404,
  "message": "No payment pushed through this API has that id.",
  "error": "Not Found"
}
{
  "statusCode": 409,
  "message": "Reversing PAY-01878 needs sign-off in your organization. An API key has no approver behind it, so this one has to be reversed in the office.",
  "error": "Conflict"
}
Reference data

Who this key is

GET/v1/me

The organization and key behind the request, and the write capabilities your organization currently holds. It takes no arguments — which is the point. Make this call first when an integration isn't behaving.

There's no user here, because there's no person behind an API key. It describes your organization.

Returns

organizationobject
The id and name of the organization this key belongs to. Check it first if you manage several.
keyobject
Its id, name, the scopes it was minted with, and expiresAtnull for a key that never expires.
grantedWriteScopesarray
What your organization still holds. A write needs its scope in both lists.

These two lists are the answer to most "why was that refused?" A scope in key.scopes but missing from grantedWriteScopes is one the grant no longer covers — see Write access. A key's own scopes never change after it's minted.

GET/v1/me
curl "https://app.lenduh.com/api/v1/me" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/me',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const identity = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
identity = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('me', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$identity = json_decode((string) $res->getBody(), true);
Response
{
  "organization": {
    "id": "2f9b7d14-0e6c-4a53-8b21-c7d9e4f0a615",
    "name": "Bicol Multipurpose Cooperative"
  },
  "key": {
    "id": "0f4c8b2a-7e15-4d93-a60b-3c9f1e8d2b47",
    "name": "Bayad Center feed",
    "scopes": [
      "read",
      "payments:write"
    ],
    "expiresAt": null
  },
  "grantedWriteScopes": [
    "payments:write"
  ]
}
{
  "statusCode": 401,
  "message": "A valid API key is required (Authorization: Bearer lk_…).",
  "error": "Unauthorized"
}
Reference data

Loan products

GET/v1/loan-products

The loan products your organization offers, by name, so you can label loans or build an application form. A product's rates, fees and penalty settings aren't included.

The loan product object

idstring (UUID)
Unique identifier. Loans refer to it as productId.
codestring
Your short code for it, such as SAL-12.
namestring
Its name.
descriptionstring or null
Your description, if you wrote one.
activeboolean
false once staff stop offering it. Existing loans keep it.
branchIdstring (UUID) or null
The only branch that offers it, or null if every branch does.
defaultRepaymentFrequencyenum
How its loans are usually repaid, with the same values as a loan's repaymentFrequency. A loan can differ, so read the loan's own field.
installmentCountMin, installmentCountMaxinteger or null
The fewest and most installments a loan can have. null means no limit.
termDaysMin, termDaysMaxinteger or null
The shortest and longest term in days. null means no limit.
createdAt, updatedAtstring
When it was added and last changed.

Takes page and pageSize like every list, and returns a page of loan products.

Retrieve one

GET/v1/loan-products/:id

Resolves the productId every loan already carries, without paging the whole list to find it. 404 if there's no such product in your organization.

GET/v1/loan-products
curl "https://app.lenduh.com/api/v1/loan-products" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loan-products',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const products = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loan-products",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
products = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loan-products', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$products = json_decode((string) $res->getBody(), true);
Response · 200
{
  "items": [
    {
      "id": "4c5d6e7f-8a9b-4c0d-8e1f-2a3b4c5d6e7f",
      "code": "MICRO-D",
      "name": "Daily microloan",
      "description": null,
      "active": true,
      "branchId": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "defaultRepaymentFrequency": "daily",
      "installmentCountMin": 30,
      "installmentCountMax": 120,
      "termDaysMin": 30,
      "termDaysMax": 120,
      "createdAt": "2026-02-03T01:00:00.000Z",
      "updatedAt": "2026-02-03T01:00:00.000Z"
    },
    {
      "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "code": "SAL-12",
      "name": "Salary loan",
      "description": "For employees of partner companies, repaid by payroll deduction.",
      "active": true,
      "branchId": null,
      "defaultRepaymentFrequency": "semi_monthly",
      "installmentCountMin": 6,
      "installmentCountMax": 24,
      "termDaysMin": null,
      "termDaysMax": null,
      "createdAt": "2026-01-12T02:30:00.000Z",
      "updatedAt": "2026-06-01T03:15:40.552Z"
    }
  ],
  "total": 2,
  "page": 1,
  "pageSize": 25,
  "pageCount": 1
}
GET/v1/loan-products/:id
curl "https://app.lenduh.com/api/v1/loan-products/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/loan-products/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const product = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/loan-products/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
product = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('loan-products/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$product = json_decode((string) $res->getBody(), true);
Response
{
  "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "code": "SAL-12",
  "name": "Salary loan",
  "description": "For employees of partner companies, repaid by payroll deduction.",
  "active": true,
  "branchId": null,
  "defaultRepaymentFrequency": "semi_monthly",
  "installmentCountMin": 6,
  "installmentCountMax": 24,
  "termDaysMin": null,
  "termDaysMax": null,
  "createdAt": "2026-01-12T02:30:00.000Z",
  "updatedAt": "2026-06-01T03:15:40.552Z"
}
{
  "statusCode": 404,
  "message": "Loan product not found.",
  "error": "Not Found"
}
Reference data

Branches

GET/v1/branches

Your organization's branches, by name. Borrowers, loans and payments carry a branchId you can resolve here, either from this list or one at a time with GET /v1/branches/:id.

The branch object

idstring (UUID)
Unique identifier.
codestring
Your short code for it, such as MAIN.
namestring
Its name.
statusenum
activeOpen for business. inactivePaused. Its records stay. closedClosed for good. Its records stay.
createdAt, updatedAtstring
When it was added and last changed.

Takes page and pageSize like every list, and returns a page of branches.

GET/v1/branches
curl "https://app.lenduh.com/api/v1/branches" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/branches',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const branches = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/branches",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
branches = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('branches', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$branches = json_decode((string) $res->getBody(), true);
Response · 200
{
  "items": [
    {
      "id": "7f1d2c3b-4a5e-4f60-8b71-9c0d1e2f3a4b",
      "code": "MAIN",
      "name": "Main office",
      "status": "active",
      "createdAt": "2026-01-12T02:00:00.000Z",
      "updatedAt": "2026-01-12T02:00:00.000Z"
    },
    {
      "id": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
      "code": "NAGA",
      "name": "Naga City",
      "status": "active",
      "createdAt": "2026-03-02T00:45:10.300Z",
      "updatedAt": "2026-03-02T00:45:10.300Z"
    }
  ],
  "total": 2,
  "page": 1,
  "pageSize": 25,
  "pageCount": 1
}
GET/v1/branches/:id
curl "https://app.lenduh.com/api/v1/branches/b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f" \
  -H "Authorization: Bearer $LENDUH_API_KEY"
const res = await fetch(
  'https://app.lenduh.com/api/v1/branches/b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f',
  { headers: { Authorization: `Bearer ${process.env.LENDUH_API_KEY}` } },
);
if (!res.ok) throw new Error(`Lenduh API ${res.status}`);
const branch = await res.json();
import os

import requests

res = requests.get(
    "https://app.lenduh.com/api/v1/branches/b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
    headers={"Authorization": f"Bearer {os.environ['LENDUH_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
branch = res.json()
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://app.lenduh.com/api/v1/']);

$res = $client->get('branches/b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('LENDUH_API_KEY')],
]);
$branch = json_decode((string) $res->getBody(), true);
Response
{
  "id": "b2c4d6e8-1a3b-4c5d-9e7f-0a1b2c3d4e5f",
  "code": "NAGA",
  "name": "Naga City",
  "status": "active",
  "createdAt": "2026-03-02T00:45:10.300Z",
  "updatedAt": "2026-03-02T00:45:10.300Z"
}
{
  "statusCode": 404,
  "message": "Branch not found.",
  "error": "Not Found"
}
More

Changelog

DateChange
2026-09-20The API can now write. Three capabilities your organization asks for and Lenduh grants: payments:write to push in payments collected outside Lenduh and reverse ones you pushed; borrowers:write to create and update clients; loans:write to submit loan applications, which create drafts. Releasing a loan is not on this API under any scope. Dry runs for both: payments and loans. Payments gain channel and externalReference, so you can recognise your own pushes. New reads: installments across loans and their totals, a loan's payments, who a key is, and a single loan product or branch by id.
2026-09-18For integrations. New endpoints: a loan's balance, its installments, a single payment, loan products and branches. Loans gain their terms (totalInterest, totalRepayable, repaymentFrequency, installmentCount, installmentAmount, maturityDate), plus productId, branchId and restructured. Payments gain a breakdown into principal, interest and penalty, and branchId. Borrowers, loans and payments gain updatedAt. New filters: updatedSince, borrowerId, loanNo, borrowerCode, and a payment date range. Seven new loan events: loan.overdue, loan.back_to_current, loan.paid_off, loan.reopened, loan.restructured, loan.written_off and loan.foreclosed. A draft's releaseDate now reads null, as documented. It used to return the planned date.
2026-09-18Payments gain status (posted / reversed) and reversedAt, and List payments takes a status filter. New payment.reversed event. Webhook data is now the same object the API returns, and the envelope gains organizationId. loan.released is also sent for releases that went through an approval. Invalid filter values return 400.
2026-06-16v1. API keys with the read scope. List and retrieve borrowers and loans, and list payments. Webhooks for loan.released and payment.recorded, with signed deliveries and retries.
More

Support

Email info@codero.ph with your organization's name and, for a failed request, the method and path, the time with its time zone, the status code and the response body. Never send your API key or webhook secret. We never need them, and anyone who asks for them isn't from Lenduh.

Lenduh API v1 Base URL https://app.lenduh.com/api/v1