Skip to main content

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.nextDueDate or loan.disbursedDate is a per-row error (visible on a dry-run) — it does not silently become 0 or an empty date.

The Row​

FieldTypeRequiredNotes
sourceLoanNumberstring, ≤ 64 charsYesYour 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.namestringYesFull name.
borrower.phonestringYesIndian mobile. A +91, 91 or leading 0 prefix is stripped; the result must be exactly 10 digits or the row errors.
borrower.emailstringNoBorrower email.
borrower.languagestringNoBorrower's preferred language (free text, e.g. hindi). Briefs the field partner.
borrower.employerNamestringNoEmployer name.
borrower.address.pincodestring, exactly 6 digitsYesDrives partner matching — accuracy directly affects collection speed.
borrower.address.line1stringNoStreet address. Better address → better geocoding → faster visits.
borrower.address.line2stringNoSecond address line.
borrower.address.citystringNoCity.
borrower.address.statestringNoState.
borrower.address.landmarkstringNoLandmark for the field partner.
loan.outstandingAmountnumber (INR), > 0YesTotal currently due. Rounded to whole rupees.
loan.dpdnumber or string range "lo-hi"NoDays 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.loanAmountnumber (INR)NoSanctioned amount.
loan.disbursedAmountnumber (INR)NoDisbursed amount.
loan.emiAmountnumber (INR)NoEMI. Shown to the partner.
loan.lateChargesnumber (INR)NoLate charges / penalty. Omitted on a re-push → reset to 0.
loan.interestRatenumberNoRate of interest (%).
loan.tenurenumberNoLoan tenure.
loan.tenureUnitstringNoUnit for loan.tenure (for example months).
loan.totalRepayablenumber (INR)NoTotal repayable.
loan.totalCollectednumber (INR)NoAmount already collected by you before hand-over.
loan.productNamestringNoProduct / loan type shown to the partner.
loan.nextDueDatedate stringNoAccepted formats below. Parsed in UTC. Present but unparseable → per-row error.
loan.disbursedDatedate stringNoAccepted 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:

FormatExample
YYYY-MM-DD2026-09-05
ISO 8601 datetime2026-09-05T00:00:00.000Z
DD-MM-YYYY05-09-2026
DD/MM/YYYY05/09/2026
DD-Mon-YY28-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​

one Row with every field populated
{
"sourceLoanNumber": "LN-2026-000123",
"borrower": {
"name": "Ravi Sharma",
"phone": "+919876543210",
"email": "[email protected]",
"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_ROWS above that); keep batches ≤ 5,000 for fast responses.
  • fullSnapshot (optional, default false) — 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 in summary.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 sourceLoanNumber inside 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.dpd and loan.lateCharges are reset to 0 when omitted, so always resend them.
  • Nothing can be cleared to null via a re-push.
  • If the loan's previous case is already closed (collected or closed_unrecovered), a re-push opens a fresh case with a new caseId, and its event sequence restarts 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. :::