Skip to main content

Push API

You build​

  • A signed HTTPS client (signing)
  • A job that pushes new/changed loans (real-time or batched)

We provide​

  • Validation with per-row errors and warnings, dedupe, partner matching on your coordinates
  • case.received webhooks + the full event stream
  • Batch idempotency (content hash) + Idempotency-Key replay safety

Endpoints​

All paths are under https://alpha-gig.fluxusforge.in/api/v1/partner (sandbox, TEST keys) or https://gig.fluxusforge.in/api/v1/partner (production, LIVE keys).

MethodPathPurpose
POST/casesPush a batch of rows. ?dryRun=1 validates without writing.
GET/cases/{sourceLoanNumber}Authoritative live snapshot of one case.
POST/cases/{sourceLoanNumber}/recallWithdraw a case (borrower paid you directly / restructured). Idempotent.

Machine-readable spec: the API reference (its OpenAPI file is /openapi/partner-v1.json on this docs site) is the canonical spec — use it for client generation. The signed GET /api/v1/partner/openapi.json on the API host is only a machine listing of the routes (generic response schemas, no error codes, no auth header parameters).

  1. New overdue loans — push as they cross your DPD threshold (or hourly).
  2. Changes — re-push the row when outstanding/contact/address/coordinates change; it updates in place. If the loan's previous case is already closed, the re-push opens a fresh case (new caseId, sequence back to 1) — use this when a borrower re-defaults after an earlier cycle.
  3. Daily reconciliation — one fullSnapshot: true push of your complete active book; anything we hold that is missing from it is flagged for our operations review.
  4. Recalls — call recall the moment a borrower settles with you directly, so no partner makes a wasted (and borrower-annoying) visit.

POST /cases — push a batch​

Request​

POST /api/v1/partner/cases # commit
POST /api/v1/partner/cases?dryRun=1 # validate only — nothing written, no events

Authorization: Bearer <apiKeyId>
Content-Type: application/json
X-Fieldproof-Timestamp: <unix ms>
X-Fieldproof-Signature: <lowercase hex HMAC-SHA256 over "POST\n/api/v1/partner/cases\n<ts>\n<raw body>">
Idempotency-Key: <your unique key for this batch> # optional, see below

The signed PATH is /api/v1/partner/cases — never include ?dryRun=1 in the canonical string (signing rules).

Body:

{
"fullSnapshot": false,
"rows": [ { "sourceLoanNumber": "…", "borrower": { "…": "…" }, "loan": { "…": "…" } } ]
}
PropertyTypeRules
rowsRow[]Required, at least 1 row. Hard cap 20,000 (413 TOO_MANY_ROWS); keep batches ≤ 5,000 for fast responses. Row schema: data contract.
fullSnapshotbooleanOptional, default false. See full snapshots.

The envelope reads only these two properties. Any other top-level key is silently dropped, not rejected — so check the spelling of fullSnapshot: a misspelled key means the batch is treated as a partial push. Send fullSnapshot as a JSON boolean: the string "true" is coerced to true, and a value that cannot be read as a boolean is a 422 VALIDATION_FAILED. Inside a row, unknown fields are ignored (data contract). Every row must carry the borrower's full address and the real coordinates of that address (borrower.address.latitude / longitude, sent as strings) — see address and coordinates.

Idempotency-Key header​

Protects a network retry of the same commit: if your client times out after we already processed the batch, resend with the same key and you get the original result instead of a second import.

  • Scope: per organisation. Any string that is unique for the logical batch.
  • TTL: 24 hours.
  • Only 200 responses are cached. A 4xx/5xx is never replayed — fix and resend with the same key.
  • Ignored on ?dryRun=1 — dry-runs are neither cached nor served from the cache.
  • No payload comparison. A replay with the same key returns the original response body verbatim — even if you changed the rows — plus the response header X-Idempotent-Replay: true. Detect a replay by that header; the idempotentReplay field inside the body is whatever the original response said.
  • Replays count toward the 10 batches/hour limit.

200 response​

200 — commit
{
"success": true,
"dryRun": false,
"batchId": "66c1f0a2b3c4d5e6f7a8b9c0",
"idempotentReplay": false,
"summary": {
"totalRowsInCsv": 200,
"validRows": 197,
"created": 150,
"updatedSnapshot": 39,
"unchanged": 8,
"flaggedNeedsReview": 0,
"errored": 3,
"warned": 1
},
"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.dpd must be a number or a \"lo-hi\" range (got \"n/a\")" },
{ "index": 31, "sourceLoanNumber": "LN-2026-000154", "reason": "Row 31 (LN-2026-000154): borrower.address.latitude/longitude need at least 4 decimal places (≈11 m) — got 12.97, 77.6. Send the coordinates of the door, not a rounded or city-level point" }
],
"warnings": [
{ "index": 4, "sourceLoanNumber": "LN-2026-000127", "reason": "Row 4 (LN-2026-000127): borrower.address.latitude/longitude are about 63 km from our reference point for pincode 560001 — imported, but please verify they are this borrower's address" }
]
}
FieldMeaning
dryRuntrue when called with ?dryRun=1. Nothing was written and no event was emitted; the summary shows what a commit of the same batch would do.
batchIdOur id for this batch (quote it to support).
idempotentReplaytrue when the batch content matched an already-committed batch — nothing was written again and the original summary is returned (see re-push semantics).
summary.totalRowsInCsvRows received (the name is historical — it applies to JSON pushes too).
summary.validRowstotalRowsInCsv − errored.
summary.createdNew cases opened.
summary.updatedSnapshotExisting active cases updated in place.
summary.unchangedRows identical to what we already hold.
summary.flaggedNeedsReviewOnly with fullSnapshot: true: active cases absent from the batch that were flagged for operations review.
summary.erroredRows rejected — the true count, even when errors[] is capped.
summary.warnedRows that imported with at least one warning — the true count, even when warnings[] is capped. One row can carry several entries in warnings[].
errors[]{ index, sourceLoanNumber, reason } per rejected row (index is the row's position in rows, from 0). Capped at 50 entries.
warnings[]Same shape as errors[], for rows that were imported but need your attention — a pin far from its pincode, or (during a coordinate grace period) missing address fields or coordinates. Capped at 50 entries. See per-row warnings.

Rows fail individually — one bad phone number never blocks the batch. Address and coordinate problems are per-row errors too: a missing line1, city or state, missing or half coordinates, a value that is not decimal degrees, fewer than 4 decimal places, 0,0, a pin outside India or with latitude and longitude swapped, or one pin reused for addresses in different pincodes. The exact reason texts are listed in the data contract.

Only the envelope can fail the whole request:

422 VALIDATION_FAILED — envelope
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "request body failed validation",
"details": [ { "field": "rows", "reason": "must NOT have fewer than 1 items" } ]
}
}
413 TOO_MANY_ROWS
{ "success": false, "error": { "code": "TOO_MANY_ROWS", "message": "Batch exceeds EXTERNAL_INGEST_MAX_ROWS (20000). Got 25000." } }

Other envelope failures: 413 PAYLOAD_TOO_LARGE (request body too large), 415 UNSUPPORTED_MEDIA_TYPE (a body whose Content-Type is not application/json), and 422 VALIDATION_FAILED with the message malformed request body (send valid JSON) for malformed JSON (it never quotes your body). Every response carries an X-Request-Id header — quote it to [email protected]. Auth failures (401/403/429) are listed on the errors page.

Order of checks: the auth headers must be present (401 otherwise), then a non-JSON Content-Type gets 415, then the body is parsed (too large → 413, malformed JSON → 422), then the signature is verified, then the envelope schema. So an unauthenticated call never sees schema errors, but 413, 415 and a malformed-JSON 422 can come back before the signature is checked.

Rate limits​

  • 60 requests/minute per API key (all endpoints).
  • 10 batches/hour per API key on POST /cases. Dry-runs and idempotent replays count toward the 10 — a dry-run followed by a commit uses two.

Over either limit → 429 RATE_LIMITED with a Retry-After header. Size your batches (up to 5,000 rows each) rather than your request rate.

Re-push and update semantics​

  • Identical batch re-sent — we content-hash every committed batch; an identical re-send is a no-op that returns idempotentReplay: true with the original summary, as long as every loan in it still has a live case. If any loan in the batch no longer does (its case was collected, closed or recalled since), the batch is processed normally and those loans get a fresh case, with a new case.received. A batch committed under the strict address rules and re-sent unchanged after your organisation is put on a grace period is also processed again. (This is separate from the Idempotency-Key header and needs no header at all.)
  • Row for an existing active case — updates the case in place (outstanding, DPD, address, coordinates, contact…). Optional fields you omit are preserved, except loan.dpd and loan.lateCharges, which reset to 0 when omitted. Nothing can be cleared to null. New coordinates move the case's pin, except over a pin one of our partners verified on site for an unchanged address (details).
  • Same sourceLoanNumber twice in one batch — the first row is kept; later duplicates are per-row errors (duplicate sourceLoanNumber in file — row N kept, this row skipped). Send each loan once per batch, with its latest state.
  • Row for a loan whose case is closed (collected or closed_unrecovered) — opens a fresh case: new caseId, a new case.received, and the event sequence restarts at 1. Key your event watermark on (sourceLoanNumber, caseId) or reset it when you see case.received — see updating your LMS and the event reference.

fullSnapshot — reconciling your whole book​

fullSnapshot: true asserts "this batch is my complete open book". Every active case we hold for you that is absent from the batch is flagged for our operations review (reason snapshot_dropped); the response gives you only the count, in summary.flaggedNeedsReview. A loan whose row is in the batch counts as present even if that row was rejected for a data error — only loans genuinely missing from the batch are flagged. Rules:

  • It must be one single request — chunking a snapshot would flag every case that lives in the other chunks. That is why the batch cap is 20,000 rows.
  • Use it for a daily or weekly reconciliation push, never for incremental pushes.
  • To actively withdraw a case, use recall — a snapshot only flags.

Code — dry-run, then commit​

Every sample below sends a realistic two-row batch as a dry-run first, then commits the same bytes with an Idempotency-Key. Set FP_KEY_ID and FP_SECRET from your sandbox credentials.

push-cases.js (Node 18+)
const crypto = require("crypto");

const BASE = process.env.FP_BASE || "https://alpha-gig.fluxusforge.in"; // production: https://gig.fluxusforge.in
const KEY_ID = process.env.FP_KEY_ID; // pk_yourorg_test_…
const SECRET = process.env.FP_SECRET; // shown once at key issuance

async function signedRequest(method, path, { query = "", body = null, headers = {} } = {}) {
const rawBody = body === null ? "" : JSON.stringify(body); // serialise ONCE — sign and send these exact bytes
const ts = Date.now().toString(); // unix milliseconds
const canonical = `${method}\n${path}\n${ts}\n${rawBody}`; // PATH only — never the query string
const sig = crypto.createHmac("sha256", SECRET).update(canonical, "utf8").digest("hex"); // lowercase hex
const res = await fetch(BASE + path + query, {
method,
headers: {
authorization: `Bearer ${KEY_ID}`,
"content-type": "application/json",
"x-fieldproof-timestamp": ts,
"x-fieldproof-signature": sig,
...headers,
},
...(body === null ? {} : { body: rawBody }),
});
return { status: res.status, replay: res.headers.get("x-idempotent-replay") === "true", json: await res.json() };
}

const batch = {
rows: [
{
sourceLoanNumber: "LN-2026-000123",
borrower: {
name: "Ravi Sharma",
phone: "+919876543210",
language: "hindi",
address: {
line1: "12 MG Road", line2: "Near Trinity Metro", landmark: "Opp. Garuda Mall",
city: "Bengaluru", state: "Karnataka", pincode: "560001",
latitude: "12.975326", longitude: "77.606262", // strings keep every decimal place
},
},
loan: {
outstandingAmount: 12500, dpd: 45, emiAmount: 2500, loanAmount: 50000, disbursedAmount: 48500,
lateCharges: 350, interestRate: 24, tenure: 24, tenureUnit: "months",
nextDueDate: "2026-09-05", disbursedDate: "15-Mar-25", productName: "Personal Loan",
},
},
{
sourceLoanNumber: "LN-2026-000124",
borrower: {
name: "Priya Nair",
phone: "9123456789",
address: {
line1: "Flat 4B, Sea View Apartments", city: "Kochi", state: "Kerala", pincode: "682001",
latitude: "9.965389", longitude: "76.242083",
},
},
loan: { outstandingAmount: 8200, dpd: "61-90", emiAmount: 4100, lateCharges: 0, nextDueDate: "05-09-2026" },
},
],
};

(async () => {
// 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
const dry = await signedRequest("POST", "/api/v1/partner/cases", { query: "?dryRun=1", body: batch });
console.log("dry-run", dry.status, dry.json.summary, dry.json.errors);

// 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
const commit = await signedRequest("POST", "/api/v1/partner/cases", {
body: batch,
headers: { "idempotency-key": "nightly-2026-08-18-001" },
});
console.log("commit", commit.status, "replay:", commit.replay, commit.json.summary, commit.json.errors);
})();

:::tip Signature failing? Sandbox-only POST /api/v1/partner/debug/echo-signature returns the canonical string the server computed for your request — see debugging signatures. :::

GET /cases/{sourceLoanNumber} — read one case​

The authoritative live snapshot of a loan's case: use it to reconcile after an event gap, to check a case before recalling it, or on demand. It returns the active case if there is one, otherwise the most recent closed one.

GET /api/v1/partner/cases/LN-2026-000123

Signing note: the canonical PATH is the path exactly as transmitted, percent-encoded. A loan number containing / must be encoded (LN/001 → /api/v1/partner/cases/LN%2F001), and that encoded string is what you sign. RAW_BODY is the empty string on a GET.

get-case.js — reuses signedRequest() from the Node sample above
const loan = "LN-2026-000123";
const res = await signedRequest("GET", `/api/v1/partner/cases/${encodeURIComponent(loan)}`);
console.log(res.status, res.json);
200
{
"success": true,
"case": {
"sourceLoanNumber": "LN-2026-000123",
"caseId": "CC-2026-1A2B3C4D5E",
"status": "in_progress",
"isActive": true,
"assignedAt": "2026-08-18T06:12:40.000Z",
"totalCollected": 5000,
"outstandingAmount": 12500,
"visits": [
{
"visitAt": "2026-08-18T11:05:12.000Z",
"outcome": "partial",
"amountCollected": 5000,
"paymentMode": "upi",
"paymentReference": "UTR123456789012",
"ptpDate": null
}
],
"createdAt": "2026-08-17T18:30:02.000Z",
"updatedAt": "2026-08-18T11:05:12.000Z"
}
}
FieldMeaning
sourceLoanNumberYour loan number, as requested.
caseIdOur id for this case. Changes when a closed loan is re-pushed (fresh case).
statuspending · in_progress · collected · closed_unrecovered.
isActivetrue while the case is open on the marketplace / with a partner.
assignedAtWhen a field partner took the case, or null.
totalCollectedAbsolute amount collected on this case (INR).
outstandingAmountOutstanding as we currently hold it (INR).
visits[]The last 20 visits: visitAt, outcome, amountCollected, paymentMode, paymentReference, ptpDate. On the static-QR payment mode, amountCollected before our verification is the partner's unverified claim — trust the payment.collected event for booked money.
createdAt / updatedAtISO timestamps of the case record.

404 CASE_NOT_FOUND — no case for that loan number in your organisation (another organisation's loan numbers are indistinguishable from unknown ones).

POST /cases/{sourceLoanNumber}/recall — withdraw a case​

Call it the moment a borrower settles with you directly, the loan is restructured or sold, or you otherwise want field activity to stop.

POST /api/v1/partner/cases/LN-2026-000123/recall
Content-Type: application/json

{ "reason": "settled directly with lender" }
  • Body: { "reason": "…" }, sent as application/json. reason is optional, ≤ 200 characters — free text kept on the case history for audit. Send {} (or an empty body with Content-Type: application/json) if you have no reason. A POST with neither a body nor a Content-Type gets 422 VALIDATION_FAILED. Other properties are ignored.
  • Same percent-encoding rule as GET /cases/{sourceLoanNumber}; the signed path is /api/v1/partner/cases/<encoded loan>/recall.
200 — case was active
{ "success": true, "alreadyClosed": false, "caseId": "CC-2026-1A2B3C4D5E", "status": "closed_unrecovered" }
200 — case was already terminal (idempotent)
{ "success": true, "alreadyClosed": true, "caseId": "CC-2026-1A2B3C4D5E", "status": "collected" }

What happens on an active case:

  • The case is closed as closed_unrecovered and detached from the field partner; the partner is notified to stop any visit in progress.
  • A case.closed event is emitted with payload.status = "closed_unrecovered" and payload.reason = "recalled" (plus totalCollected so far — event reference).
  • The call is idempotent: repeating it (or recalling a case that already reached collected / closed_unrecovered) returns alreadyClosed: true with the case's current status and emits nothing.
  • 404 CASE_NOT_FOUND if we hold no case for that loan number.

A recalled loan can be pushed again later — that opens a fresh case with a new caseId and sequence starting at 1.

recall-case.js — reuses signedRequest() from the Node sample above
const loan = "LN-2026-000123";
const res = await signedRequest("POST", `/api/v1/partner/cases/${encodeURIComponent(loan)}/recall`, {
body: { reason: "settled directly with lender" },
});
console.log(res.status, res.json); // { success: true, alreadyClosed: false, caseId: "…", status: "closed_unrecovered" }

:::note Sandbox There are no simulated field agents in the sandbox: pushing a case emits case.received, and you can dry-run, recall, poll, replay and test webhooks yourself. Assignment and visit events are driven by the Fieldproof team on request — see sandbox testing. :::