Skip to main content

Pull API

If your system cannot call ours, expose one read endpoint and we fetch from it on a schedule — every 30 minutes, globally (the schedule is not configurable per organisation).

You build​

  • One cursor-paged GET endpoint (below), HTTPS, on a public IP
  • A static Authorization header value you give us at onboarding

We provide​

  • The scheduled fetcher with a durable cursor
  • Everything downstream identical to push (validation, dedupe, webhooks, payments)

The contract you implement​

GET <your-url>?cursor=<opaque>&limit=500
Accept: application/json
Authorization: <the exact value you registered — sent verbatim, stored encrypted on our side>

→ 200 Content-Type: application/json
{
"rows": [ /* Row objects — the same shape as the push API */ ],
"nextCursor": "<opaque string>" | null,
"hasMore": true | false
}
PartRule
cursor (query)Opaque to us. Absent on the very first call. After that we send back exactly the nextCursor you last returned.
limit (query)We always send 500. Return at most that many rows per page.
rowsArray of Row objects — identical validation to the push API (phone, pincode, DPD ranges, date formats). Empty array when there is nothing new.
nextCursorWhere the next page starts. When you have nothing new, return hasMore: false and either the cursor you received or null — we keep our position.
hasMoretrue if another page is available right now. We fetch up to 10 pages per run (5,000 rows); anything beyond continues on the next run.

How the fetcher behaves:

  • Runs every 30 minutes. HTTPS only, public IP only, 30 s timeout, response body ≤ 10 MB, redirects are not followed.
  • Each page is imported before the cursor moves: we persist your nextCursor only after that page imported successfully. A failed fetch, timeout, non-2xx or malformed response leaves the cursor where it was and the same page is fetched again on the next run — so a read for a given cursor must be repeatable.
  • We stop the run when hasMore is false, nextCursor is null/missing or unchanged, or 10 pages have been fetched.
  • Re-serving an identical page is always safe — a page whose content matches an already-committed batch is a no-op (content-hash dedupe).
  • Rows are applied with the push API's re-push semantics: a row for an existing active case updates it in place, a row for a closed case opens a fresh one, and omitted loan.dpd / loan.lateCharges reset to 0.

:::warning What pull cannot do

  • Per-row errors are not surfaced back to you. A row that fails validation is dropped from that page silently. Verify with GET /cases/{sourceLoanNumber} (a 404 CASE_NOT_FOUND means the row never landed) or by watching for case.received on your webhook / the poll feed. Dry-run your export against the push API once (POST /cases?dryRun=1) before switching pull on — same rules, visible errors.
  • fullSnapshot is never applied on pull. A loan disappearing from your feed flags nothing. To withdraw a case, call POST /cases/{sourceLoanNumber}/recall. :::

Design guidance for the endpoint:

  • Stamp every overdue-loan record with a change sequence (a global counter bumped whenever the loan is created or changes) and let the cursor be the last sequence you served. WHERE seq > cursor ORDER BY seq gives you a repeatable, monotonic page — and one row per loan per page (send a loan's latest state, never two versions of the same loan in one page).
  • Send the canonical Row shape. If you can only expose your existing export columns, ask [email protected] — the same column mapping used for CSV can be configured for your organisation.
  • Reject requests whose Authorization header does not equal the value you registered.

Minimal implementations​

Each sample serves two loans from an in-memory list stamped with seq; swap the list for a query on your database (SELECT … WHERE seq > :cursor ORDER BY seq LIMIT :limit + 1). PULL_AUTH is the exact Authorization value you gave us at onboarding.

pull-endpoint.js (Node 18+, express)
const express = require("express");

const PULL_AUTH = process.env.PULL_AUTH; // e.g. "Bearer 7f3c…" — the exact value registered with Fieldproof
const PAGE_MAX = 500;

// One row per loan. `seq` is bumped from a global counter every time the loan
// is created or changes, so a page is repeatable and carries each loan once.
const LOANS = [
{
seq: 1,
row: {
sourceLoanNumber: "LN-2026-000123",
borrower: {
name: "Ravi Sharma", phone: "+919876543210",
address: { line1: "12 MG Road", city: "Bengaluru", state: "Karnataka", pincode: "560001" },
},
loan: { outstandingAmount: 12500, dpd: 45, emiAmount: 2500, lateCharges: 350, nextDueDate: "2026-09-05" },
},
},
{
seq: 2,
row: {
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" },
},
},
];

const app = express();

app.get("/fieldproof/cases", (req, res) => {
if (req.get("authorization") !== PULL_AUTH) return res.status(401).end();

const cursorParam = typeof req.query.cursor === "string" ? req.query.cursor : ""; // absent on the first call
const cursor = cursorParam === "" ? 0 : Number(cursorParam);
if (!Number.isInteger(cursor) || cursor < 0) return res.status(400).json({ error: "bad cursor" });
const limit = Math.min(Number(req.query.limit) || PAGE_MAX, PAGE_MAX);

const page = LOANS
.filter((l) => l.seq > cursor)
.sort((a, b) => a.seq - b.seq)
.slice(0, limit + 1); // fetch one extra row to compute hasMore
const hasMore = page.length > limit;
const rows = page.slice(0, limit);

res.json({
rows: rows.map((l) => l.row),
nextCursor: rows.length ? String(rows[rows.length - 1].seq) : (cursorParam || null),
hasMore,
});
});

app.listen(8080, () => console.log("pull endpoint on :8080"));

Checklist before we switch pull on​

  • Endpoint is HTTPS on a public IP, answers within 30 s, page ≤ 10 MB.
  • Rejects a wrong or missing Authorization header.
  • First call (no cursor) returns your oldest unsent page; a repeated call with the same cursor returns the same rows.
  • Each page carries each loan once, with its latest state, in the canonical Row shape (dates and DPD in an accepted format — see the data contract).
  • You have a way to notice missing cases (GET /cases/{sourceLoanNumber} or case.received events) and you recall closed loans explicitly.
  • At go-live, production integration configuration is entered again — nothing carries over from the sandbox — so plan for a production URL and Authorization value too (environments).

Push is still recommended where possible: it is real-time, returns per-row errors immediately, and supports fullSnapshot reconciliation; pull is polled.