Skip to main content

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​

ParamTypeDefault / limitMeaning
limitintegerdefault 100, max 500Page size.
cursorstring—Opaque token from the previous page's nextCursor. Resume here.
sinceISO 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 cursor and since are sent, cursor wins.
  • An unparseable cursor or since → 422 VALIDATION_FAILED.
  • Rate limit is 60 requests/min per key; on 429 RATE_LIMITED honour Retry-After.

Response​

200
{
"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
}
  • events are oldest first, in the common envelope.
  • nextCursor — pass it as cursor on 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 your eventId dedupe absorbs the repeats.
  • hasMore — true means call again immediately with nextCursor; false means 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.collected is 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.

poll.js (Node 18+)
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
}
})();
poll.py (Python 3.9+, pip install requests)
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. :::