Skip to main content

Complete integration by language

One file per language that does the whole loop against the sandbox: a signed client, a push of overdue loans, a webhook receiver that verifies, dedupes and applies events by the sequence rule, and — for Mode B (org_gateway) organisations — the payments/confirm call. Each skeleton is complete and runs as written; the pieces are the same ones explained on Signing requests, Push API, Webhooks, Updating your LMS and Mode B: your gateway.

What every skeleton does

#SectionWhat it encodes
1Signed clientCanonical string METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + RAW_BODY; PATH is the request path without the query string, percent-encoded exactly as sent; timestamp in unix milliseconds; signature is lowercase hex HMAC-SHA256 with your secret; the body is serialised once and those exact bytes are sent. Headers Authorization: Bearer <keyId>, X-Fieldproof-Timestamp, X-Fieldproof-Signature.
2Push overdue loansPOST /api/v1/partner/cases with an Idempotency-Key (reuse the same key when retrying after a timeout). Rows fail individually — read summary and errors[].
3Confirm a gateway capturePOST /api/v1/partner/cases/{sourceLoanNumber}/payments/confirm with reference = your gateway's payment id (the idempotency key) and a percent-encoded path parameter. 200 → booked; 409 DUPLICATE_CONFIRMATION → already booked, treat as success; 409 CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED → money not owed, refund or adjust with the borrower; 5xx / 429 → retry later with the same reference. Mode A (static QR) organisations never call this.
4Webhook receiverRaw bytes only; signature verified against a list of secrets over POST\n<callback path>\n<ts>\n<raw body>; timestamp within ±5 minutes; ping (null sourceLoanNumber / sequence) verified and acknowledged but never applied; dedupe on eventId; payment.collected always written to the ledger; case-state gated on sequence per (sourceLoanNumber, caseId) — gaps are normal, never wait for them; 2xx within 10 seconds.
5RunStarts the receiver, pushes a one-row demo batch, and shows where the confirm call goes.

Environment variables used by every sample:

VariableValue
FP_KEY_IDYour API key id, pk_<org>_test_<hex> on the sandbox, pk_<org>_live_<hex> in production.
FP_SECRETThe secret shown once at key issuance. Signs your requests and verifies inbound webhooks.
FP_SECRET_PREVIOUSOptional. Set it during a key rotation — webhooks are signed with the new secret from the moment of rotation, so the receiver must verify against both.
FP_BASEOptional. Defaults to https://alpha-gig.fluxusforge.in (sandbox, TEST keys). Production is https://gig.fluxusforge.in (LIVE keys).

The receiver listens on /webhooks/fieldproof on port 8080 — expose it over HTTPS on a public IP and register that URL (pathname only, no query string) with our ops for the environment you are testing in.

:::warning Demo stores are in memory (PHP: a local file) The dedupe set, watermarks, loan state and ledger in these skeletons exist to show the rules, not to survive a restart. Before go-live, back them with your database — a UNIQUE (event_id) inbox and a per-(loan, case) watermark, as laid out on Updating your LMS — and move the apply step behind a job queue if it does more than a few statements. :::

fieldproof-integration.js (Node 18+, Express 4/5)
// One process: signed client + push job + webhook receiver + org_gateway confirm.
// Env: FP_KEY_ID, FP_SECRET, FP_SECRET_PREVIOUS (during a rotation), FP_BASE (optional).
const crypto = require("crypto");
const express = require("express");

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; also verifies webhooks
const SECRETS = [SECRET, process.env.FP_SECRET_PREVIOUS].filter(Boolean); // inbound: current first, previous kept during rotation
const CALLBACK_PATH = "/webhooks/fieldproof"; // pathname of the callback URL you registered (no query string)

// ---------- 1. Signed client ----------
const hmacHex = (secret, ...parts) => {
const h = crypto.createHmac("sha256", secret);
for (const p of parts) h.update(p);
return h.digest("hex"); // lowercase hex
};

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 sig = hmacHex(SECRET, `${method}\n${path}\n${ts}\n${rawBody}`); // PATH only — never the query string
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, retryAfter: res.headers.get("retry-after"), json: await res.json() };
}

// ---------- 2. Push overdue loans ----------
async function pushOverdueLoans(rows, idempotencyKey) {
const { status, retryAfter, json } = await signedRequest("POST", "/api/v1/partner/cases", {
body: { rows }, headers: { "idempotency-key": idempotencyKey }, // reuse the SAME key when retrying after a timeout
});
if (status !== 200) throw new Error(`push ${status} ${json.error?.code}: ${json.error?.message}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}`);
console.log("push", json.batchId, json.summary, json.errors); // rows fail individually — act on errors[]
return json;
}

// ---------- 3. Confirm a gateway capture (org_gateway mode only) ----------
// Call this from your gateway's webhook handler / poller. `reference` = gateway payment id = idempotency key.
async function confirmPayment(sourceLoanNumber, { amount, reference, paidAt, mode = "link", linkId }) {
const path = `/api/v1/partner/cases/${encodeURIComponent(sourceLoanNumber)}/payments/confirm`; // percent-encoded, exactly as sent
const { status, json } = await signedRequest("POST", path, { body: { amount, reference, paidAt, mode, linkId } });
if (status === 200) return json; // { success, caseId, amount, outstandingAfter, status }
const code = json.error?.code;
if (status === 409 && code === "DUPLICATE_CONFIRMATION") return { ...json.error, duplicate: true }; // already booked → success
if (status === 409) { // CASE_ALREADY_COLLECTED | AMOUNT_EXCEEDS_OUTSTANDING | CASE_CLOSED
console.error("money not owed — refund/adjust with the borrower:", code, sourceLoanNumber, reference);
return json.error;
}
throw new Error(`confirm ${status} ${code}: ${json.error?.message}`); // 5xx / 429: retry later with the SAME reference; other 4xx: fix, then re-run
}

// ---------- 4. Webhook receiver ----------
// Demo stores — replace with your database (see "Updating your LMS").
const seenEventIds = new Set(); // dedupe on eventId (durable inbox with a UNIQUE key in production)
const watermarks = new Map(); // "loan:caseId" → highest applied sequence (sequence is per CASE and restarts at 1)
const loans = new Map(); // sourceLoanNumber → { caseId, caseSince, status, totalCollected, outstanding }
const ledger = []; // every payment.collected, idempotent on eventId / reference

function verifyWebhook(rawBody, req) {
const ts = req.get("X-Fieldproof-Timestamp") || req.get("X-Quikkred-Timestamp") || "";
const sig = (req.get("X-Fieldproof-Signature") || req.get("X-Quikkred-Signature") || "").toLowerCase();
if (!/^\d+$/.test(ts) || !sig || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false; // ±5 min replay window
const prefix = Buffer.from(`POST\n${CALLBACK_PATH}\n${ts}\n`, "utf8");
return SECRETS.some((s) => {
const expected = Buffer.from(hmacHex(s, prefix, rawBody)), got = Buffer.from(sig);
return expected.length === got.length && crypto.timingSafeEqual(expected, got); // constant time
});
}

function applyEvent(event) {
if (seenEventIds.has(event.eventId)) return; // at-least-once delivery → dedupe
seenEventIds.add(event.eventId);
const p = event.payload, loanNo = event.sourceLoanNumber;
if (event.type === "payment.collected") { // MONEY: always booked, never gated by ordering
ledger.push({ eventId: event.eventId, loanNo, caseId: p.caseId, amount: p.amount, mode: p.mode, reference: p.reference, collectedAt: p.collectedAt });
}
let loan = loans.get(loanNo) || {};
if (loan.caseId !== p.caseId) { // an event from a case we do not track yet
if (loan.caseSince && Date.parse(event.occurredAt) < Date.parse(loan.caseSince)) return; // straggler from an earlier, closed case
loan = { caseId: p.caseId, caseSince: event.occurredAt }; // re-push after close = new case → adopt it (fresh watermark)
}
const key = `${loanNo}:${p.caseId}`;
if (event.sequence <= (watermarks.get(key) || 0)) return; // older than what we applied → case-state unchanged
watermarks.set(key, event.sequence); // gaps (1 → 4) are normal — never wait for them
switch (event.type) { // CASE-STATE: absolute values, SET never +=
case "case.received": loan.status = "pending"; break;
case "case.assigned":
case "visit.completed": loan.status = "in_progress"; break;
case "payment.collected": loan.totalCollected = p.totalCollected; loan.outstanding = p.outstandingAfter; break; // status follows case.closed
case "case.closed": loan.status = p.status; loan.totalCollected = p.totalCollected; loan.closeReason = p.reason; break;
default: break; // unknown / future type: watermark moved only
}
loans.set(loanNo, loan);
// On a gap you MAY reconcile with GET /api/v1/partner/cases/{loan} — after applying, never instead of it.
}

const app = express();
app.post(CALLBACK_PATH, express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); // exact bytes — never re-serialise a parsed object
if (!verifyWebhook(rawBody, req)) return res.status(401).end();
const event = JSON.parse(rawBody.toString("utf8"));
if (event.type !== "ping") applyEvent(event); // ping: sourceLoanNumber / sequence are null — verify, ack, apply nothing
res.status(200).json({ received: true }); // 2xx within 10 s
});

// ---------- 5. Run ----------
app.listen(process.env.PORT || 8080, () => {
pushOverdueLoans([{
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, nextDueDate: "2026-09-05" },
}], "demo-2026-08-18-001").catch(console.error);
// From your gateway's webhook handler, after a capture:
// confirmPayment("LN-2026-000123", { amount: 12500, reference: "pay_29QQoUBi66xm2f", mode: "upi", linkId: "plink_Ab12Cd34" });
});

Prove it works

  1. Start the skeleton with your sandbox key. The console prints the push result: batchId, summary (created / updatedSnapshot / unchanged / errored …) and any per-row errors[].
  2. Make sure the receiver is reachable on the HTTPS callback URL our ops registered for your sandbox, then fire a signed self-test: POST /api/v1/partner/webhooks/test — a ping arrives, verifies, and is acknowledged without touching your stores. 422 CALLBACK_NOT_CONFIGURED means no callback is registered for this environment yet.
  3. Re-run the push with the same Idempotency-Key: the response is the original body with X-Idempotent-Replay: true. Note that replays and dry-runs still count toward the 10 batches per hour limit (60 requests per minute overall).
  4. Mode B organisations: call confirmPayment for the pushed loan with a fresh reference. On the sandbox this really books the payment and emits payment.collected (and case.closed on a full clearance) to your receiver — the fastest way to watch the whole loop end to end. Call it again with the same reference and you get 409 DUPLICATE_CONFIRMATION, which the skeleton treats as success.
  5. Anything not delivered shows in GET /api/v1/partner/webhooks/deliveries?status=exhausted; re-send with POST /api/v1/partner/webhooks/deliveries/{eventId}/replay (delivery log).

:::tip Signature mismatch on the sandbox? POST /api/v1/partner/debug/echo-signature (test key, sandbox host only) returns the canonical string and the signature we expected for the request you just sent — see Debugging signatures. The usual causes: signing the query string, a re-serialised body, a timestamp in seconds instead of milliseconds, or an unencoded / in a path parameter. :::

:::note Global JSON body parsers If your framework already parses JSON for every route (Express app.use(express.json()), a Spring HttpMessageConverter chain that consumes the body, an ASP.NET filter, …), make sure the raw bytes still reach the webhook handler — the receivers above read them directly, and verifying a re-serialised object will fail. The Express-specific fix is on the Webhooks page. :::

Before go-live

  • Replace the demo stores with your database (UNIQUE (event_id) inbox, per-(sourceLoanNumber, caseId) watermark, ledger idempotent on eventId and reference) — Updating your LMS.
  • Add the poll API as your recovery lane after an outage; it feeds the same applyEvent handler.
  • Mode B: host the mint-link endpoint and wire confirmPayment into your gateway's webhook handler or a poller — your gateway.
  • Register the production callback URL, mint URL/header, QR or bank details and IP allowlist again — nothing carries over from the sandbox — then switch FP_BASE to https://gig.fluxusforge.in with your LIVE key. Full list on the go-live checklist.

Questions or a stuck integration: [email protected].