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
GETendpoint (below), HTTPS, on a public IP - A static
Authorizationheader 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
}
| Part | Rule |
|---|---|
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. |
rows | Array of Row objects — identical validation to the push API (phone, pincode, DPD ranges, date formats). Empty array when there is nothing new. |
nextCursor | Where the next page starts. When you have nothing new, return hasMore: false and either the cursor you received or null — we keep our position. |
hasMore | true 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
nextCursoronly 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
hasMoreisfalse,nextCursorisnull/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.lateChargesreset 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}(a404 CASE_NOT_FOUNDmeans the row never landed) or by watching forcase.receivedon 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. fullSnapshotis never applied on pull. A loan disappearing from your feed flags nothing. To withdraw a case, callPOST /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 seqgives 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
Authorizationheader 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.
- Node.js / Express
- Python / Flask
- Java / Spring Boot
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"));
pull_endpoint.py (Python 3.8+, flask)
import os
from flask import Flask, abort, jsonify, request
PULL_AUTH = os.environ["PULL_AUTH"] # e.g. "Bearer 7f3c…" — the exact value registered with Fieldproof
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.
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"},
},
},
]
app = Flask(__name__)
@app.get("/fieldproof/cases")
def fieldproof_cases():
if request.headers.get("Authorization") != PULL_AUTH:
abort(401)
cursor_param = request.args.get("cursor", "") # absent on the first call
try:
cursor = int(cursor_param) if cursor_param else 0
limit = min(int(request.args.get("limit", PAGE_MAX)), PAGE_MAX)
except ValueError:
return jsonify({"error": "bad cursor or limit"}), 400
page = sorted((l for l in LOANS if l["seq"] > cursor), key=lambda l: l["seq"])[: limit + 1] # one extra for hasMore
has_more = len(page) > limit
rows = page[:limit]
return jsonify({
"rows": [l["row"] for l in rows],
"nextCursor": str(rows[-1]["seq"]) if rows else (cursor_param or None),
"hasMore": has_more,
})
if __name__ == "__main__":
app.run(port=8080)
FieldproofPullController.java (Java 17+, Spring Boot 3)
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class FieldproofPullController {
private static final String PULL_AUTH = System.getenv("PULL_AUTH"); // e.g. "Bearer 7f3c…" — the exact value registered with Fieldproof
private static final int 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.
record LoanChange(long seq, Map<String, Object> row) {}
private final List<LoanChange> loans = List.of(
new LoanChange(1, Map.of(
"sourceLoanNumber", "LN-2026-000123",
"borrower", Map.of("name", "Ravi Sharma", "phone", "+919876543210",
"address", Map.of("line1", "12 MG Road", "city", "Bengaluru", "state", "Karnataka", "pincode", "560001")),
"loan", Map.of("outstandingAmount", 12500, "dpd", 45, "emiAmount", 2500, "lateCharges", 350, "nextDueDate", "2026-09-05"))),
new LoanChange(2, Map.of(
"sourceLoanNumber", "LN-2026-000124",
"borrower", Map.of("name", "Priya Nair", "phone", "9123456789",
"address", Map.of("line1", "Flat 4B, Sea View Apartments", "city", "Kochi", "state", "Kerala", "pincode", "682001")),
"loan", Map.of("outstandingAmount", 8200, "dpd", "61-90", "emiAmount", 4100, "lateCharges", 0, "nextDueDate", "05-09-2026")))
);
@GetMapping("/fieldproof/cases")
public ResponseEntity<Map<String, Object>> cases(
@RequestHeader(value = "Authorization", required = false) String auth,
@RequestParam(required = false) String cursor, // absent on the first call
@RequestParam(defaultValue = "500") int limit) {
if (auth == null || !auth.equals(PULL_AUTH)) return ResponseEntity.status(401).build();
long after;
try {
after = (cursor == null || cursor.isEmpty()) ? 0L : Long.parseLong(cursor);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().build();
}
int size = Math.min(limit, PAGE_MAX);
List<LoanChange> page = loans.stream()
.filter(l -> l.seq() > after)
.sorted(Comparator.comparingLong(LoanChange::seq))
.limit(size + 1L) // one extra to compute hasMore
.collect(Collectors.toList());
boolean hasMore = page.size() > size;
List<LoanChange> rows = hasMore ? page.subList(0, size) : page;
Map<String, Object> body = new LinkedHashMap<>();
body.put("rows", rows.stream().map(LoanChange::row).collect(Collectors.toList()));
body.put("nextCursor", rows.isEmpty() ? cursor : String.valueOf(rows.get(rows.size() - 1).seq()));
body.put("hasMore", hasMore);
return ResponseEntity.ok(body);
}
}
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
Authorizationheader. - First call (no
cursor) returns your oldest unsent page; a repeated call with the samecursorreturns 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}orcase.receivedevents) 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
Authorizationvalue too (environments).
Push is still recommended where possible: it is real-time, returns per-row
errors immediately, and supports fullSnapshot reconciliation; pull is polled.