Poll API
The same events as webhooks, pulled on your schedule. Use it if you cannot
receive webhooks, and as your recovery lane after an outage on your side.
It is a second projection of the same durable event store — it does not care
whether a webhook was delivered, and it returns every event type
regardless of your webhook subscription (so poll consumers see
case.assigned and visit.completed too).
GET /api/v1/partner/cases/updates?limit=100
GET /api/v1/partner/cases/updates?cursor=<nextCursor from the last page>
GET /api/v1/partner/cases/updates?since=2026-08-17T00:00:00Z (first call only)
Signed like every request — GET, empty body, and the canonical PATH is
/api/v1/partner/cases/updates without the query string (see
signing).
Parameters
| Param | Type | Default / limit | Meaning |
|---|---|---|---|
limit | integer | default 100, max 500 | Page size. |
cursor | string | — | Opaque token from the previous page's nextCursor. Resume here. |
since | ISO 8601 datetime | — | First call only. Start from events received by our server at or after this instant — it filters on server receipt time, not occurredAt. |
- If both
cursorandsinceare sent,cursorwins. - An unparseable
cursororsince→422 VALIDATION_FAILED. - Rate limit is 60 requests/min per key; on
429 RATE_LIMITEDhonourRetry-After.
Response
{
"success": true,
"events": [
{
"eventId": "evt_66a1f0c2d3e4f5a6b7c8d9e0",
"type": "payment.collected",
"occurredAt": "2026-08-17T09:31:00.000Z",
"version": 1,
"lenderCode": "YOURORG",
"sourceLoanNumber": "LN-2026-0001",
"sequence": 4,
"payload": {
"sourceLoanNumber": "LN-2026-0001",
"caseId": "CC-2026-2388ED42",
"amount": 3000,
"mode": "upi",
"reference": "UTR2026081712345",
"totalCollected": 3000,
"outstandingAfter": 9500,
"collectedAt": "2026-08-17T09:30:12.000Z"
}
}
],
"nextCursor": "66a1f0c2d3e4f5a6b7c8d9e0",
"hasMore": false
}
eventsare oldest first, in the common envelope.nextCursor— pass it ascursoron the next call. Persist it only after you have processed the page (inbox rows written / handler run); if you crash mid-page you re-read the page and youreventIddedupe absorbs the repeats.hasMore—truemeans call again immediately withnextCursor;falsemeans you are caught up — sleep, then poll again with the same cursor.
Rules for the consumer
Same as the webhook receiver, because they are the same events:
- Dedupe on
eventId— you may see an event on both lanes. - Sequence is per case, gaps are normal, and a
payment.collectedis always booked regardless of ordering — run the same handler as Updating your LMS. - Events that were emitted while you had no callback configured are always here, and can also be pushed to your callback later with a replay once one is configured.
A persisting poll loop
Both samples store the cursor in a file for brevity — use a database row in
production. handleFieldproofEvent is the inbox insert + apply handler from
Updating your LMS.
const crypto = require("crypto");
const fs = require("fs");
const BASE = process.env.FIELDPROOF_BASE || "https://alpha-gig.fluxusforge.in"; // production: https://gig.fluxusforge.in
const KEY_ID = process.env.FIELDPROOF_KEY_ID; // pk_yourorg_test_…
const SECRET = process.env.FIELDPROOF_SECRET;
const START_FROM = process.env.FIELDPROOF_SINCE || new Date(Date.now() - 24 * 3600 * 1000).toISOString(); // first run only
const CURSOR_FILE = "./fieldproof-cursor.txt";
const PATH = "/api/v1/partner/cases/updates";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function handleFieldproofEvent(event) { /* inbox insert (dedupe) + applyFieldproofEvent — see "Updating your LMS" */ }
function signedHeaders(method, path, body = "") {
const ts = Date.now().toString(); // unix ms
const canonical = `${method}\n${path}\n${ts}\n${body}`; // path WITHOUT the query string; body "" for GET
const sig = crypto.createHmac("sha256", SECRET).update(canonical).digest("hex"); // lowercase hex
return {
Authorization: `Bearer ${KEY_ID}`,
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
};
}
async function pollOnce() {
let cursor = fs.existsSync(CURSOR_FILE) ? fs.readFileSync(CURSOR_FILE, "utf8").trim() : "";
for (;;) {
const qs = new URLSearchParams({ limit: "500" });
if (cursor) qs.set("cursor", cursor); else qs.set("since", START_FROM); // cursor wins if both were sent
const res = await fetch(`${BASE}${PATH}?${qs}`, { headers: signedHeaders("GET", PATH) });
if (res.status === 429) { // RATE_LIMITED — honour Retry-After
await sleep(Number(res.headers.get("retry-after") || 60) * 1000);
continue;
}
if (!res.ok) throw new Error(`poll failed: ${res.status} ${await res.text()}`);
const page = await res.json();
for (const event of page.events) await handleFieldproofEvent(event); // dedupe on eventId inside
if (page.nextCursor) { // persist AFTER processing the page
cursor = page.nextCursor;
fs.writeFileSync(CURSOR_FILE, cursor);
}
if (!page.hasMore) return; // caught up
}
}
(async () => {
for (;;) {
try { await pollOnce(); } catch (e) { console.error(e); }
await sleep(60 * 1000); // poll interval — stay well inside 60 req/min
}
})();
import hashlib, hmac, os, time
from pathlib import Path
import requests
BASE = os.environ.get("FIELDPROOF_BASE", "https://alpha-gig.fluxusforge.in") # production: https://gig.fluxusforge.in
KEY_ID = os.environ["FIELDPROOF_KEY_ID"] # pk_yourorg_test_…
SECRET = os.environ["FIELDPROOF_SECRET"].encode()
START_FROM = os.environ.get("FIELDPROOF_SINCE", "2026-08-17T00:00:00Z") # first run only
CURSOR_FILE = Path("fieldproof-cursor.txt")
PATH = "/api/v1/partner/cases/updates"
def handle_fieldproof_event(event: dict) -> None:
"""Inbox insert (dedupe on eventId) + apply_fieldproof_event — see "Updating your LMS"."""
def signed_headers(method: str, path: str, body: bytes = b"") -> dict:
ts = str(int(time.time() * 1000)) # unix ms
canonical = f"{method}\n{path}\n{ts}\n".encode() + body # path WITHOUT the query string; b"" for GET
sig = hmac.new(SECRET, canonical, hashlib.sha256).hexdigest() # lowercase hex
return {
"Authorization": f"Bearer {KEY_ID}",
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
}
def poll_once() -> None:
cursor = CURSOR_FILE.read_text().strip() if CURSOR_FILE.exists() else ""
while True:
params = {"limit": 500}
if cursor:
params["cursor"] = cursor # cursor wins if both were sent
else:
params["since"] = START_FROM
r = requests.get(BASE + PATH, params=params, headers=signed_headers("GET", PATH), timeout=30)
if r.status_code == 429: # RATE_LIMITED — honour Retry-After
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
r.raise_for_status()
page = r.json()
for event in page["events"]:
handle_fieldproof_event(event) # dedupe on eventId inside
if page.get("nextCursor"): # persist AFTER processing the page
cursor = page["nextCursor"]
CURSOR_FILE.write_text(cursor)
if not page.get("hasMore"):
return # caught up
if __name__ == "__main__":
while True:
try:
poll_once()
except Exception as e: # log and keep polling
print("poll failed:", e)
time.sleep(60) # poll interval — stay well inside 60 req/min
:::tip Recovery after an outage
Webhooks retry for ≈31 hours and are replayable for 30 days, so a short
outage needs nothing from you. For anything longer, or if you are unsure what
you missed, run the loop once with since set to just before the outage: the
handler's eventId dedupe makes overlap with already-delivered webhooks
harmless.
:::