Data contract
One row = one overdue loan. The same Row shape is used by the push API, the pull API and (as columns, under different header names) the CSV path.
Ground rules that apply everywhere:
- Money is whole INR rupees, sent as a JSON number. Never paise. Any amount is rounded to an integer on our side.
- Unknown fields are ignored, never fatal — you can send your full export and let us pick the fields we know.
- Rows fail individually. A row that fails validation is reported in
errors[]with its index and reason; the rest of the batch still commits. - Optional fields that are present must parse. An unparseable
loan.dpd,loan.nextDueDateorloan.disbursedDateis a per-row error (visible on a dry-run) — it does not silently become0or an empty date.
The Row
| Field | Type | Required | Notes |
|---|---|---|---|
sourceLoanNumber | string, ≤ 64 chars | Yes | Your stable loan ID and the key for every later lookup, update, event and payment. Unique within your organisation. Appears in URL paths (GET /cases/{sourceLoanNumber}) — percent-encode it there. |
borrower.name | string | Yes | Full name. |
borrower.phone | string | Yes | Indian mobile. A +91, 91 or leading 0 prefix is stripped; the result must be exactly 10 digits or the row errors. |
borrower.email | string | No | Borrower email. |
borrower.language | string | No | Borrower's preferred language (free text, e.g. hindi). Briefs the field partner. |
borrower.employerName | string | No | Employer name. |
borrower.address.pincode | string, exactly 6 digits | Yes | Drives partner matching — accuracy directly affects collection speed. |
borrower.address.line1 | string | No | Street address. Better address → better geocoding → faster visits. |
borrower.address.line2 | string | No | Second address line. |
borrower.address.city | string | No | City. |
borrower.address.state | string | No | State. |
borrower.address.landmark | string | No | Landmark for the field partner. |
loan.outstandingAmount | number (INR), > 0 | Yes | Total currently due. Rounded to whole rupees. |
loan.dpd | number or string range "lo-hi" | No | Days past due. A range such as "61-90" is accepted and its upper bound (90) is used. Anything else that is present but unparseable → per-row error. Omitted on a re-push → reset to 0. |
loan.dpdBucket | — | — | Derived by us from loan.dpd. Do not send it. |
loan.loanAmount | number (INR) | No | Sanctioned amount. |
loan.disbursedAmount | number (INR) | No | Disbursed amount. |
loan.emiAmount | number (INR) | No | EMI. Shown to the partner. |
loan.lateCharges | number (INR) | No | Late charges / penalty. Omitted on a re-push → reset to 0. |
loan.interestRate | number | No | Rate of interest (%). |
loan.tenure | number | No | Loan tenure. |
loan.tenureUnit | string | No | Unit for loan.tenure (for example months). |
loan.totalRepayable | number (INR) | No | Total repayable. |
loan.totalCollected | number (INR) | No | Amount already collected by you before hand-over. |
loan.productName | string | No | Product / loan type shown to the partner. |
loan.nextDueDate | date string | No | Accepted formats below. Parsed in UTC. Present but unparseable → per-row error. |
loan.disbursedDate | date string | No | Accepted formats below. Parsed in UTC. Present but unparseable → per-row error. |
Any other property — at the top level, inside borrower, borrower.address
or loan — is ignored.
Accepted date formats
loan.nextDueDate and loan.disbursedDate accept any of:
| Format | Example |
|---|---|
YYYY-MM-DD | 2026-09-05 |
| ISO 8601 datetime | 2026-09-05T00:00:00.000Z |
DD-MM-YYYY | 05-09-2026 |
DD/MM/YYYY | 05/09/2026 |
DD-Mon-YY | 28-Apr-26 |
All are parsed in UTC — send the calendar date you mean, not a
locally-shifted timestamp. A date that is present but matches none of these
fails the row (loan.nextDueDate is not a recognised date …).
DPD
loan.dpd is either a plain number (45) or a "lo-hi" bucket string
("0-30", "31-60", "61-90", "91-180" …); for a range we use the
upper bound. We derive loan.dpdBucket from it. Present but unparseable
("n/a", "-", "overdue") → per-row error
loan.dpd must be a number or a "lo-hi" range. Omit the field rather than
sending a placeholder — but remember that an omitted dpd on a re-push
resets the stored value to 0, so send it on every push if you have it.
Phone normalisation
+919876543210, 919876543210, 09876543210 and 9876543210 all become
9876543210. Anything that does not reduce to 10 digits fails the row with
borrower.phone does not normalize to a 10-digit Indian mobile.
A complete row
{
"sourceLoanNumber": "LN-2026-000123",
"borrower": {
"name": "Ravi Sharma",
"phone": "+919876543210",
"language": "hindi",
"employerName": "Acme Textiles Pvt Ltd",
"address": {
"line1": "12 MG Road",
"line2": "Near Trinity Metro",
"city": "Bengaluru",
"state": "Karnataka",
"pincode": "560001",
"landmark": "Opp. Garuda Mall"
}
},
"loan": {
"outstandingAmount": 12500,
"dpd": 45,
"loanAmount": 50000,
"disbursedAmount": 48500,
"emiAmount": 2500,
"lateCharges": 350,
"interestRate": 24,
"tenure": 24,
"tenureUnit": "months",
"totalRepayable": 60000,
"totalCollected": 47500,
"productName": "Personal Loan",
"nextDueDate": "2026-09-05",
"disbursedDate": "15-Mar-25"
}
}
The batch envelope (push API)
{
"fullSnapshot": false,
"rows": [ { "sourceLoanNumber": "LN-…", "borrower": { "…": "…" }, "loan": { "…": "…" } } ]
}
rows— at least 1 row. Hard cap 20,000 rows per request (413 TOO_MANY_ROWSabove that); keep batches ≤ 5,000 for fast responses.fullSnapshot(optional, defaultfalse) — asserts "this batch is my complete open book". Any of your active cases absent from the batch is flagged for our operations review (borrower may have paid you directly); you get only the count back insummary.flaggedNeedsReview. Because of that, a snapshot must be one single request (chunking it would flag everything in the other chunks) — so it is limited to 20,000 rows. Use it for a daily/weekly reconciliation push, never for incremental pushes.- Duplicate
sourceLoanNumberinside one batch — the first row is kept and every later duplicate is reported as a per-row error (duplicate sourceLoanNumber in file — row N kept, this row skipped). Send each loan once per batch, with its latest state.
Re-push semantics
Re-sending a row for a loan that has an active case updates that case in place — outstanding, DPD, contact details, address. We hold at most one active case per loan number, so a re-push can never create a duplicate.
- Optional fields you omit on a re-push are preserved from the earlier
push — with two exceptions:
loan.dpdandloan.lateChargesare reset to 0 when omitted, so always resend them. - Nothing can be cleared to
nullvia a re-push. - If the loan's previous case is already closed (
collectedorclosed_unrecovered), a re-push opens a fresh case with a newcaseId, and its eventsequencerestarts at 1 — see push API and updating your LMS.
Per-row errors
Validation failures come back per row, never as a whole-batch failure:
"errors": [
{ "index": 17, "sourceLoanNumber": "LN-2026-000140", "reason": "Row 17 (LN-2026-000140): borrower.phone does not normalize to a 10-digit Indian mobile" },
{ "index": 23, "sourceLoanNumber": "LN-2026-000146", "reason": "Row 23 (LN-2026-000146): loan.nextDueDate is not a recognised date (use YYYY-MM-DD, DD-MM-YYYY or DD-Mon-YY; got \"31/13/2026\")" }
]
errors[] is capped at 50 entries; summary.errored is always the true count.
Only the envelope (missing rows, malformed JSON, too many rows) fails the
whole request — see errors.
:::tip Dry-run first
POST /api/v1/partner/cases?dryRun=1 runs exactly this validation and returns
the same errors[] without writing anything — the fastest way to check a new
export against the contract.
:::