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, dedupe, geocoding, assignment
  • 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: signed GET /openapi.json, or the API reference.

  1. New overdue loans — push as they cross your DPD threshold (or hourly).
  2. Changes — re-push the row when outstanding/contact/address changes; 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 accepts only these two properties — any other top-level key is a 422 VALIDATION_FAILED. Inside a row, unknown fields are ignored (data contract).

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": 198,
"created": 150,
"updatedSnapshot": 40,
"unchanged": 8,
"flaggedNeedsReview": 0,
"errored": 2
},
"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\")" }
]
}
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.
errors[]{ index, sourceLoanNumber, reason } per rejected row (index is the row's position in rows, from 0). Capped at 50 entries.

Rows fail individually — one bad phone number never blocks the batch. 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 (send application/json), 422 VALIDATION_FAILED with a message for malformed JSON. Every response carries an X-Request-Id header — quote it to [email protected]. Auth failures (401/403/429) are listed on the errors page; auth runs before body validation, so an unauthenticated call never sees schema errors.

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 guaranteed no-op that returns idempotentReplay: true with the original summary. (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, 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.
  • 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. 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", city: "Bengaluru", state: "Karnataka", pincode: "560001", landmark: "Opp. Garuda Mall" },
},
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" },
},
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": "…" }, optional, ≤ 200 characters — free text kept on the case history for audit. Send {} if you have no reason. No other properties are accepted (422 VALIDATION_FAILED).
  • 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. :::