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
| # | Section | What it encodes |
|---|---|---|
| 1 | Signed client | Canonical 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. |
| 2 | Push overdue loans | POST /api/v1/partner/cases with an Idempotency-Key (reuse the same key when retrying after a timeout). Rows fail individually — read summary and errors[]. |
| 3 | Confirm a gateway capture | POST /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. |
| 4 | Webhook receiver | Raw 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. |
| 5 | Run | Starts the receiver, pushes a one-row demo batch, and shows where the confirm call goes. |
Environment variables used by every sample:
| Variable | Value |
|---|---|
FP_KEY_ID | Your API key id, pk_<org>_test_<hex> on the sandbox, pk_<org>_live_<hex> in production. |
FP_SECRET | The secret shown once at key issuance. Signs your requests and verifies inbound webhooks. |
FP_SECRET_PREVIOUS | Optional. 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_BASE | Optional. 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.
:::
- Node.js
- Python
- Java
- Go
- C#
- PHP
// 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" });
});
# 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).
import hashlib, hmac, json, os, threading, time
from datetime import datetime
from urllib.parse import quote
import requests
from flask import Flask, jsonify, request
BASE = os.environ.get("FP_BASE", "https://alpha-gig.fluxusforge.in") # production: https://gig.fluxusforge.in
KEY_ID = os.environ["FP_KEY_ID"] # pk_yourorg_test_…
SECRET = os.environ["FP_SECRET"] # shown once at key issuance; also verifies webhooks
SECRETS = [s for s in (SECRET, os.environ.get("FP_SECRET_PREVIOUS")) if s] # inbound: current first, previous kept during rotation
CALLBACK_PATH = "/webhooks/fieldproof" # pathname of the callback URL you registered (no query string)
# ---------- 1. Signed client ----------
def hmac_hex(secret: str, *parts: bytes) -> str:
mac = hmac.new(secret.encode(), digestmod=hashlib.sha256)
for p in parts:
mac.update(p)
return mac.hexdigest() # lowercase hex
def signed_request(method, path, query="", body=None, extra_headers=None):
raw_body = b"" if body is None else json.dumps(body, separators=(",", ":")).encode() # serialise ONCE — sign and send these bytes
ts = str(int(time.time() * 1000)) # unix milliseconds
sig = hmac_hex(SECRET, f"{method}\n{path}\n{ts}\n".encode(), raw_body) # PATH only — never the query string
headers = {"Authorization": f"Bearer {KEY_ID}", "Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts, "X-Fieldproof-Signature": sig, **(extra_headers or {})}
resp = requests.request(method, BASE + path + query, headers=headers,
data=raw_body if body is not None else None, timeout=60)
return resp.status_code, resp.json()
# ---------- 2. Push overdue loans ----------
def push_overdue_loans(rows, idempotency_key):
status, res = signed_request("POST", "/api/v1/partner/cases", body={"rows": rows},
extra_headers={"Idempotency-Key": idempotency_key}) # reuse the SAME key when retrying after a timeout
if status != 200:
raise RuntimeError(f"push {status} {res.get('error')}") # 429 carries Retry-After
print("push", res["batchId"], res["summary"], res["errors"]) # rows fail individually — act on errors[]
return res
# ---------- 3. Confirm a gateway capture (org_gateway mode only) ----------
# Call this from your gateway's webhook handler / poller. `reference` = gateway payment id = idempotency key.
def confirm_payment(source_loan_number, amount, reference, paid_at=None, mode="link", link_id=None):
path = f"/api/v1/partner/cases/{quote(source_loan_number, safe='')}/payments/confirm" # percent-encoded, exactly as sent
body = {k: v for k, v in {"amount": amount, "reference": reference, "paidAt": paid_at,
"mode": mode, "linkId": link_id}.items() if v is not None}
status, res = signed_request("POST", path, body=body)
if status == 200:
return res # { success, caseId, amount, outstandingAfter, status }
code = (res.get("error") or {}).get("code")
if status == 409 and code == "DUPLICATE_CONFIRMATION":
return {**res["error"], "duplicate": True} # already booked → treat as success
if status == 409: # CASE_ALREADY_COLLECTED | AMOUNT_EXCEEDS_OUTSTANDING | CASE_CLOSED
print("money not owed — refund/adjust with the borrower:", code, source_loan_number, reference)
return res["error"]
raise RuntimeError(f"confirm {status} {res.get('error')}") # 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").
LOCK = threading.Lock()
seen_event_ids = set() # dedupe on eventId (durable inbox with a UNIQUE key in production)
watermarks = {} # (loan, caseId) → highest applied sequence (sequence is per CASE and restarts at 1)
loans = {} # loan → {caseId, caseSince, status, totalCollected, outstanding}
ledger = [] # every payment.collected, idempotent on eventId / reference
def iso(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def verify_webhook(raw_body: bytes, headers) -> bool:
ts = headers.get("X-Fieldproof-Timestamp") or headers.get("X-Quikkred-Timestamp") or ""
sig = (headers.get("X-Fieldproof-Signature") or headers.get("X-Quikkred-Signature") or "").lower()
if not ts.isdigit() or not sig or abs(time.time() * 1000 - int(ts)) > 5 * 60 * 1000: # ±5 min replay window
return False
prefix = f"POST\n{CALLBACK_PATH}\n{ts}\n".encode()
return any(hmac.compare_digest(hmac_hex(s, prefix, raw_body), sig) for s in SECRETS) # constant time
def apply_event(event):
with LOCK:
if event["eventId"] in seen_event_ids: # at-least-once delivery → dedupe
return
seen_event_ids.add(event["eventId"])
p, loan_no = event["payload"], event["sourceLoanNumber"]
if event["type"] == "payment.collected": # MONEY: always booked, never gated by ordering
ledger.append({"eventId": event["eventId"], "loan": loan_no, "caseId": p["caseId"], "amount": p["amount"],
"mode": p["mode"], "reference": p["reference"], "collectedAt": p["collectedAt"]})
loan = loans.get(loan_no, {})
if loan.get("caseId") != p["caseId"]: # an event from a case we do not track yet
if loan.get("caseSince") and iso(event["occurredAt"]) < iso(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)
key = (loan_no, p["caseId"])
if event["sequence"] <= watermarks.get(key, 0): # older than what we applied → case-state unchanged
return
watermarks[key] = event["sequence"] # gaps (1 → 4) are normal — never wait for them
t = event["type"] # CASE-STATE: absolute values, SET never +=
if t == "case.received":
loan["status"] = "pending"
elif t in ("case.assigned", "visit.completed"):
loan["status"] = "in_progress"
elif t == "payment.collected": # status follows the case.closed that ends a full clearance
loan["totalCollected"], loan["outstanding"] = p["totalCollected"], p["outstandingAfter"]
elif t == "case.closed":
loan.update(status=p["status"], totalCollected=p["totalCollected"], closeReason=p["reason"])
loans[loan_no] = loan
# On a gap you MAY reconcile with GET /api/v1/partner/cases/{loan} — after applying, never instead of it.
app = Flask(__name__)
@app.post(CALLBACK_PATH)
def fieldproof_webhook():
raw_body = request.get_data() # exact bytes — never re-serialise request.json
if not verify_webhook(raw_body, request.headers):
return "", 401
event = json.loads(raw_body)
if event["type"] != "ping": # ping: sourceLoanNumber / sequence are None — verify, ack, apply nothing
apply_event(event)
return jsonify(received=True) # 2xx within 10 s
# ---------- 5. Run ----------
if __name__ == "__main__":
push_overdue_loans([{
"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")
# From your gateway's webhook handler, after a capture:
# confirm_payment("LN-2026-000123", amount=12500, reference="pay_29QQoUBi66xm2f", mode="upi", link_id="plink_Ab12Cd34")
app.run(port=8080)
// One Spring Boot app: signed client + push job + webhook receiver + org_gateway confirm.
// Env: FP_KEY_ID, FP_SECRET, FP_SECRET_PREVIOUS (during a rotation), FP_BASE (optional).
package com.example.fieldproof;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.OffsetDateTime;
import java.util.*;
@SpringBootApplication
@RestController
public class FieldproofIntegration {
static final String BASE = System.getenv().getOrDefault("FP_BASE", "https://alpha-gig.fluxusforge.in"); // production: https://gig.fluxusforge.in
static final String KEY_ID = System.getenv("FP_KEY_ID"); // pk_yourorg_test_…
static final String SECRET = System.getenv("FP_SECRET"); // shown once at key issuance; also verifies webhooks
static final List<String> SECRETS = Arrays.stream(new String[] {SECRET, System.getenv("FP_SECRET_PREVIOUS")})
.filter(s -> s != null && !s.isEmpty()).toList(); // inbound: current first, previous kept during rotation
static final String CALLBACK_PATH = "/webhooks/fieldproof"; // pathname of the callback URL you registered (no query string)
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
// ---------- 1. Signed client ----------
static String hmacHex(String secret, byte[]... parts) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
for (byte[] p : parts) mac.update(p);
return HexFormat.of().formatHex(mac.doFinal()); // lowercase hex
}
record Reply(int status, JsonNode json) {}
static Reply signedRequest(String method, String path, String query, Object body, Map<String, String> extra) throws Exception {
String rawBody = body == null ? "" : JSON.writeValueAsString(body); // serialise ONCE — sign and send these exact bytes
String ts = Long.toString(System.currentTimeMillis()); // unix milliseconds
String sig = hmacHex(SECRET, (method + "\n" + path + "\n" + ts + "\n" + rawBody).getBytes(StandardCharsets.UTF_8)); // PATH only — never the query
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path + query))
.header("Authorization", "Bearer " + KEY_ID).header("Content-Type", "application/json")
.header("X-Fieldproof-Timestamp", ts).header("X-Fieldproof-Signature", sig)
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8));
extra.forEach(b::header);
HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return new Reply(r.statusCode(), JSON.readTree(r.body()));
}
// ---------- 2. Push overdue loans ----------
static JsonNode pushOverdueLoans(JsonNode rows, String idempotencyKey) throws Exception {
Reply r = signedRequest("POST", "/api/v1/partner/cases", "", JSON.createObjectNode().set("rows", rows),
Map.of("Idempotency-Key", idempotencyKey)); // reuse the SAME key when retrying after a timeout
if (r.status() != 200) throw new IllegalStateException("push " + r.status() + " " + r.json().path("error")); // 429 carries Retry-After
System.out.println("push " + r.json().path("batchId") + " " + r.json().path("summary") + " " + r.json().path("errors")); // rows fail individually
return r.json();
}
// ---------- 3. Confirm a gateway capture (org_gateway mode only) ----------
// Call from your gateway's webhook handler / poller. `reference` = gateway payment id = idempotency key.
static JsonNode confirmPayment(String loanNo, long amount, String reference, String paidAt, String mode, String linkId) throws Exception {
String path = "/api/v1/partner/cases/" + URLEncoder.encode(loanNo, StandardCharsets.UTF_8).replace("+", "%20") + "/payments/confirm"; // percent-encoded, exactly as sent
ObjectNode body = JSON.createObjectNode().put("amount", amount).put("reference", reference).put("mode", mode == null ? "link" : mode);
if (paidAt != null) body.put("paidAt", paidAt);
if (linkId != null) body.put("linkId", linkId);
Reply r = signedRequest("POST", path, "", body, Map.of());
String code = r.json().path("error").path("code").asText();
if (r.status() == 200) return r.json(); // { success, caseId, amount, outstandingAfter, status }
if (r.status() == 409 && code.equals("DUPLICATE_CONFIRMATION")) return r.json().path("error"); // already booked → treat as success
if (r.status() == 409) { // CASE_ALREADY_COLLECTED | AMOUNT_EXCEEDS_OUTSTANDING | CASE_CLOSED
System.err.println("money not owed — refund/adjust with the borrower: " + code + " " + loanNo + " " + reference);
return r.json().path("error");
}
throw new IllegalStateException("confirm " + r.status() + " " + code); // 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").
static final Set<String> SEEN = new HashSet<>(); // dedupe on eventId (durable inbox with a UNIQUE key in production)
static final Map<String, Long> WATERMARKS = new HashMap<>(); // "loan:caseId" → highest applied sequence (per CASE, restarts at 1)
static final Map<String, Map<String, Object>> LOANS = new HashMap<>(); // loan → caseId, caseSince, status, totalCollected, outstanding
static final List<JsonNode> LEDGER = new ArrayList<>(); // every payment.collected, idempotent on eventId / reference
static boolean verifyWebhook(byte[] rawBody, String ts, String sig) throws Exception {
if (ts == null || sig == null || !ts.matches("\\d+")) return false;
if (Math.abs(System.currentTimeMillis() - Long.parseLong(ts)) > 5 * 60 * 1000L) return false; // ±5 min replay window
byte[] prefix = ("POST\n" + CALLBACK_PATH + "\n" + ts + "\n").getBytes(StandardCharsets.UTF_8);
byte[] got = sig.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8);
for (String s : SECRETS)
if (MessageDigest.isEqual(hmacHex(s, prefix, rawBody).getBytes(StandardCharsets.UTF_8), got)) return true; // constant time
return false;
}
static synchronized void applyEvent(JsonNode e) {
String eventId = e.path("eventId").asText(), type = e.path("type").asText(), loanNo = e.path("sourceLoanNumber").asText();
if (!SEEN.add(eventId)) return; // at-least-once delivery → dedupe
JsonNode p = e.path("payload");
String caseId = p.path("caseId").asText();
if (type.equals("payment.collected")) LEDGER.add(e); // MONEY: always booked, never gated by ordering
Map<String, Object> loan = LOANS.getOrDefault(loanNo, new HashMap<>());
if (!caseId.equals(loan.get("caseId"))) { // an event from a case we do not track yet
Object since = loan.get("caseSince");
if (since != null && OffsetDateTime.parse(e.path("occurredAt").asText()).isBefore(OffsetDateTime.parse((String) since))) return; // straggler from an earlier, closed case
loan = new HashMap<>(Map.of("caseId", caseId, "caseSince", e.path("occurredAt").asText())); // re-push after close = new case → adopt it (fresh watermark)
}
String key = loanNo + ":" + caseId;
long seq = e.path("sequence").asLong();
if (seq <= WATERMARKS.getOrDefault(key, 0L)) return; // older than what we applied → case-state unchanged
WATERMARKS.put(key, seq); // gaps (1 → 4) are normal — never wait for them
switch (type) { // CASE-STATE: absolute values, SET never +=
case "case.received" -> loan.put("status", "pending");
case "case.assigned", "visit.completed" -> loan.put("status", "in_progress");
case "payment.collected" -> { loan.put("totalCollected", p.path("totalCollected").asLong()); loan.put("outstanding", p.path("outstandingAfter").asLong()); } // status follows case.closed
case "case.closed" -> { loan.put("status", p.path("status").asText()); loan.put("totalCollected", p.path("totalCollected").asLong()); loan.put("closeReason", p.path("reason").asText()); }
default -> {} // unknown / future type: watermark moved only
}
LOANS.put(loanNo, loan);
// On a gap you MAY reconcile with GET /api/v1/partner/cases/{loan} — after applying, never instead of it.
}
// @RequestBody byte[] hands you the exact bytes — no re-serialisation.
@PostMapping(CALLBACK_PATH)
public ResponseEntity<Map<String, Object>> webhook(@RequestBody byte[] rawBody, @RequestHeader HttpHeaders h) throws Exception {
String ts = Optional.ofNullable(h.getFirst("X-Fieldproof-Timestamp")).orElse(h.getFirst("X-Quikkred-Timestamp"));
String sig = Optional.ofNullable(h.getFirst("X-Fieldproof-Signature")).orElse(h.getFirst("X-Quikkred-Signature"));
if (!verifyWebhook(rawBody, ts, sig)) return ResponseEntity.status(401).build();
JsonNode event = JSON.readTree(rawBody);
if (!"ping".equals(event.path("type").asText())) applyEvent(event); // ping: sourceLoanNumber / sequence are null — verify, ack, apply nothing
return ResponseEntity.ok(Map.of("received", true)); // 2xx within 10 s
}
// ---------- 5. Run ----------
public static void main(String[] args) throws Exception {
SpringApplication.run(FieldproofIntegration.class, args); // receiver on :8080
pushOverdueLoans(JSON.readTree("""
[{"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");
// From your gateway's webhook handler, after a capture:
// confirmPayment("LN-2026-000123", 12500, "pay_29QQoUBi66xm2f", null, "upi", "plink_Ab12Cd34");
}
}
// 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).
package main
import (
"bytes"
"cmp"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"sync"
"time"
)
var (
base = cmp.Or(os.Getenv("FP_BASE"), "https://alpha-gig.fluxusforge.in") // production: https://gig.fluxusforge.in
keyID = os.Getenv("FP_KEY_ID") // pk_yourorg_test_…
secret = os.Getenv("FP_SECRET") // shown once at key issuance; also verifies webhooks
// Inbound webhooks: current secret first, previous kept during a rotation.
secrets = slices.DeleteFunc([]string{secret, os.Getenv("FP_SECRET_PREVIOUS")}, func(s string) bool { return s == "" })
)
const callbackPath = "/webhooks/fieldproof" // pathname of the callback URL you registered (no query string)
// ---------- 1. Signed client ----------
func hmacHex(sec string, parts ...[]byte) string {
mac := hmac.New(sha256.New, []byte(sec))
for _, p := range parts {
mac.Write(p)
}
return hex.EncodeToString(mac.Sum(nil)) // lowercase hex
}
func signedRequest(method, path, query string, body any, extra map[string]string) (int, map[string]any, error) {
var raw []byte
if body != nil {
raw, _ = json.Marshal(body) // serialise ONCE — sign and send these exact bytes
}
ts := strconv.FormatInt(time.Now().UnixMilli(), 10) // unix milliseconds
sig := hmacHex(secret, []byte(method+"\n"+path+"\n"+ts+"\n"), raw) // PATH only — never the query string
req, err := http.NewRequest(method, base+path+query, bytes.NewReader(raw))
if err != nil {
return 0, nil, err
}
req.Header.Set("Authorization", "Bearer "+keyID)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fieldproof-Timestamp", ts)
req.Header.Set("X-Fieldproof-Signature", sig)
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
var out map[string]any
err = json.NewDecoder(res.Body).Decode(&out)
return res.StatusCode, out, err
}
// ---------- 2. Push overdue loans ----------
func pushOverdueLoans(rows []map[string]any, idempotencyKey string) (map[string]any, error) {
status, res, err := signedRequest("POST", "/api/v1/partner/cases", "", map[string]any{"rows": rows},
map[string]string{"Idempotency-Key": idempotencyKey}) // reuse the SAME key when retrying after a timeout
if err != nil || status != 200 {
return nil, fmt.Errorf("push %d %v %v", status, res["error"], err) // 429 carries Retry-After
}
fmt.Println("push", res["batchId"], res["summary"], res["errors"]) // rows fail individually — act on errors[]
return res, nil
}
// ---------- 3. Confirm a gateway capture (org_gateway mode only) ----------
// Call from your gateway's webhook handler / poller. Reference = gateway payment id = idempotency key.
type Confirm struct {
Amount int64 `json:"amount"`
Reference string `json:"reference"`
PaidAt string `json:"paidAt,omitempty"`
Mode string `json:"mode,omitempty"` // default "link"
LinkID string `json:"linkId,omitempty"`
}
func confirmPayment(loanNo string, c Confirm) (map[string]any, error) {
path := "/api/v1/partner/cases/" + url.PathEscape(loanNo) + "/payments/confirm" // percent-encoded, exactly as sent
status, res, err := signedRequest("POST", path, "", c, nil)
if err != nil {
return nil, err // network / timeout: retry later with the SAME reference
}
e, _ := res["error"].(map[string]any)
code, _ := e["code"].(string)
switch {
case status == 200, status == 409 && code == "DUPLICATE_CONFIRMATION": // booked now { success, caseId, amount, outstandingAfter, status } — or already booked → success
return res, nil
case status == 409: // CASE_ALREADY_COLLECTED | AMOUNT_EXCEEDS_OUTSTANDING | CASE_CLOSED
log.Printf("money not owed — refund/adjust with the borrower: %s %s %s", code, loanNo, c.Reference)
return res, nil
}
return nil, fmt.Errorf("confirm %d %s", status, code) // 5xx / 429: retry later with the SAME reference; other 4xx: fix, then re-run
}
// ---------- 4. Webhook receiver ----------
type event struct {
EventID string `json:"eventId"`
Type string `json:"type"`
OccurredAt time.Time `json:"occurredAt"`
SourceLoanNumber *string `json:"sourceLoanNumber"` // nil on ping
Sequence *int64 `json:"sequence"` // nil on ping
Payload struct {
CaseID, Status, Reason, Mode, CollectedAt string
Reference *string
Amount, TotalCollected, OutstandingAfter int64
} `json:"payload"`
}
type loanState struct {
CaseID, Status, CloseReason string
CaseSince time.Time
TotalCollected, Outstanding int64
}
// Demo stores — replace with your database (see "Updating your LMS").
var (
mu sync.Mutex
seen = map[string]bool{} // dedupe on eventId (durable inbox with a UNIQUE key in production)
watermarks = map[string]int64{} // "loan:caseId" → highest applied sequence (per CASE, restarts at 1)
loans = map[string]*loanState{} // loan → state
ledger []event // every payment.collected, idempotent on eventId / reference
)
func verifyWebhook(rawBody []byte, ts, sig string) bool {
tsMs, err := strconv.ParseInt(ts, 10, 64)
if err != nil || sig == "" || time.Since(time.UnixMilli(tsMs)).Abs() > 5*time.Minute { // ±5 min replay window
return false
}
prefix, got := []byte("POST\n"+callbackPath+"\n"+ts+"\n"), bytes.ToLower([]byte(sig))
for _, s := range secrets {
if hmac.Equal([]byte(hmacHex(s, prefix, rawBody)), got) { // constant time
return true
}
}
return false
}
func applyEvent(e event) {
mu.Lock()
defer mu.Unlock()
if seen[e.EventID] { // at-least-once delivery → dedupe
return
}
seen[e.EventID] = true
p, loanNo := e.Payload, *e.SourceLoanNumber
if e.Type == "payment.collected" { // MONEY: always booked, never gated by ordering
ledger = append(ledger, e)
}
loan := loans[loanNo]
if loan == nil || loan.CaseID != p.CaseID { // an event from a case we do not track yet
if loan != nil && e.OccurredAt.Before(loan.CaseSince) { // straggler from an earlier, closed case
return
}
loan = &loanState{CaseID: p.CaseID, CaseSince: e.OccurredAt} // re-push after close = new case → adopt it (fresh watermark)
}
key := loanNo + ":" + p.CaseID
if *e.Sequence <= watermarks[key] { // older than what we applied → case-state unchanged
return
}
watermarks[key] = *e.Sequence // gaps (1 → 4) are normal — never wait for them
// CASE-STATE: absolute values from the payload, SET never +=.
switch e.Type {
case "case.received":
loan.Status = "pending"
case "case.assigned", "visit.completed":
loan.Status = "in_progress"
case "payment.collected": // status follows the case.closed that ends a full clearance
loan.TotalCollected, loan.Outstanding = p.TotalCollected, p.OutstandingAfter
case "case.closed":
loan.Status, loan.TotalCollected, loan.CloseReason = p.Status, p.TotalCollected, p.Reason
}
loans[loanNo] = loan
// On a gap you MAY reconcile with GET /api/v1/partner/cases/{loan} — after applying, never instead of it.
}
func webhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // exact bytes — never re-serialise a parsed struct
ts := cmp.Or(r.Header.Get("X-Fieldproof-Timestamp"), r.Header.Get("X-Quikkred-Timestamp"))
sig := cmp.Or(r.Header.Get("X-Fieldproof-Signature"), r.Header.Get("X-Quikkred-Signature"))
if err != nil || !verifyWebhook(rawBody, ts, sig) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var e event
if err := json.Unmarshal(rawBody, &e); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if e.Type != "ping" && e.SourceLoanNumber != nil && e.Sequence != nil { // ping: sourceLoanNumber / sequence are null — verify, ack, apply nothing
applyEvent(e)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"received":true}`)) // 2xx within 10 s
}
// ---------- 5. Run ----------
func main() {
if _, err := pushOverdueLoans([]map[string]any{{
"sourceLoanNumber": "LN-2026-000123",
"borrower": map[string]any{"name": "Ravi Sharma", "phone": "+919876543210", "address": map[string]any{"line1": "12 MG Road", "city": "Bengaluru", "state": "Karnataka", "pincode": "560001"}},
"loan": map[string]any{"outstandingAmount": 12500, "dpd": 45, "emiAmount": 2500, "nextDueDate": "2026-09-05"},
}}, "demo-2026-08-18-001"); err != nil {
log.Println(err)
}
// From your gateway's webhook handler, after a capture:
// confirmPayment("LN-2026-000123", Confirm{Amount: 12500, Reference: "pay_29QQoUBi66xm2f", Mode: "upi", LinkID: "plink_Ab12Cd34"})
http.HandleFunc("POST "+callbackPath, webhook)
log.Fatal(http.ListenAndServe(":8080", nil))
}
// 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).
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
var Base = Environment.GetEnvironmentVariable("FP_BASE") ?? "https://alpha-gig.fluxusforge.in"; // production: https://gig.fluxusforge.in
var KeyId = Environment.GetEnvironmentVariable("FP_KEY_ID")!; // pk_yourorg_test_…
var Secret = Environment.GetEnvironmentVariable("FP_SECRET")!; // shown once at key issuance; also verifies webhooks
var Secrets = new[] { Secret, Environment.GetEnvironmentVariable("FP_SECRET_PREVIOUS") }
.Where(s => !string.IsNullOrEmpty(s)).Select(s => s!).ToArray(); // inbound: current first, previous kept during rotation
const string CallbackPath = "/webhooks/fieldproof"; // pathname of the callback URL you registered (no query string)
var http = new HttpClient();
var jsonOpts = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
// ---------- 1. Signed client ----------
string HmacHex(string secret, params byte[][] parts)
{
using var mac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret));
foreach (var p in parts) mac.AppendData(p);
return Convert.ToHexString(mac.GetHashAndReset()).ToLowerInvariant(); // lowercase hex
}
async Task<(int Status, JsonElement Json)> SignedRequest(string method, string path, string query = "", object? body = null, Dictionary<string, string>? extra = null)
{
var rawBody = body is null ? "" : JsonSerializer.Serialize(body, jsonOpts); // serialise ONCE — sign and send these exact bytes
var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); // unix milliseconds
var sig = HmacHex(Secret, Encoding.UTF8.GetBytes($"{method}\n{path}\n{ts}\n{rawBody}")); // PATH only — never the query string
using var req = new HttpRequestMessage(new HttpMethod(method), Base + path + query);
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {KeyId}");
req.Headers.Add("X-Fieldproof-Timestamp", ts);
req.Headers.Add("X-Fieldproof-Signature", sig);
foreach (var (k, v) in extra ?? new()) req.Headers.Add(k, v);
if (body is not null) req.Content = new StringContent(rawBody, Encoding.UTF8, "application/json"); // exactly the signed bytes
using var res = await http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return ((int)res.StatusCode, doc.RootElement.Clone());
}
// ---------- 2. Push overdue loans ----------
async Task<JsonElement> PushOverdueLoans(object[] rows, string idempotencyKey)
{
var (status, json) = await SignedRequest("POST", "/api/v1/partner/cases", body: new { rows },
extra: new() { ["Idempotency-Key"] = idempotencyKey }); // reuse the SAME key when retrying after a timeout
if (status != 200) throw new Exception($"push {status} {json.GetProperty("error")}"); // 429 carries Retry-After
Console.WriteLine($"push {json.GetProperty("batchId")} {json.GetProperty("summary")} {json.GetProperty("errors")}"); // rows fail individually
return json;
}
// ---------- 3. Confirm a gateway capture (org_gateway mode only) ----------
// Call from your gateway's webhook handler / poller. reference = gateway payment id = idempotency key.
async Task<JsonElement> ConfirmPayment(string loanNo, long amount, string reference, string? paidAt = null, string mode = "link", string? linkId = null)
{
var path = $"/api/v1/partner/cases/{Uri.EscapeDataString(loanNo)}/payments/confirm"; // percent-encoded, exactly as sent
var (status, json) = await SignedRequest("POST", path, body: new { amount, reference, paidAt, mode, linkId });
if (status == 200) return json; // { success, caseId, amount, outstandingAfter, status }
json.TryGetProperty("error", out var err);
var code = err.ValueKind == JsonValueKind.Object ? err.GetProperty("code").GetString() : null;
if (status == 409 && code == "DUPLICATE_CONFIRMATION") return err; // already booked → treat as success
if (status == 409) // CASE_ALREADY_COLLECTED | AMOUNT_EXCEEDS_OUTSTANDING | CASE_CLOSED
{
Console.Error.WriteLine($"money not owed — refund/adjust with the borrower: {code} {loanNo} {reference}");
return err;
}
throw new Exception($"confirm {status} {code}"); // 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").
var gate = new object();
var seen = new HashSet<string>(); // dedupe on eventId (durable inbox with a UNIQUE key in production)
var watermarks = new Dictionary<string, long>(); // "loan:caseId" → highest applied sequence (per CASE, restarts at 1)
var loans = new Dictionary<string, LoanState>(); // loan → state
var ledger = new List<JsonElement>(); // every payment.collected, idempotent on eventId / reference
bool VerifyWebhook(byte[] rawBody, string? ts, string? sig)
{
if (ts is null || sig is null || !long.TryParse(ts, out var tsMs)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - tsMs) > 5 * 60 * 1000) return false; // ±5 min replay window
var prefix = Encoding.UTF8.GetBytes($"POST\n{CallbackPath}\n{ts}\n");
var got = Encoding.UTF8.GetBytes(sig.ToLowerInvariant());
return Secrets.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(HmacHex(s, prefix, rawBody)), got)); // constant time
}
void ApplyEvent(JsonElement e)
{
lock (gate)
{
if (!seen.Add(e.GetProperty("eventId").GetString()!)) return; // at-least-once delivery → dedupe
var type = e.GetProperty("type").GetString()!;
var loanNo = e.GetProperty("sourceLoanNumber").GetString()!;
var p = e.GetProperty("payload");
var caseId = p.GetProperty("caseId").GetString()!;
var occurredAt = e.GetProperty("occurredAt").GetDateTimeOffset();
if (type == "payment.collected") ledger.Add(e); // MONEY: always booked, never gated by ordering
loans.TryGetValue(loanNo, out var loan);
if (loan is null || loan.CaseId != caseId) // an event from a case we do not track yet
{
if (loan is not null && occurredAt < loan.CaseSince) return; // straggler from an earlier, closed case
loan = new LoanState { CaseId = caseId, CaseSince = occurredAt }; // re-push after close = new case → adopt it (fresh watermark)
}
var key = $"{loanNo}:{caseId}";
var seq = e.GetProperty("sequence").GetInt64();
if (seq <= watermarks.GetValueOrDefault(key)) return; // older than what we applied → case-state unchanged
watermarks[key] = seq; // gaps (1 → 4) are normal — never wait for them
switch (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": // status follows the case.closed that ends a full clearance
loan.TotalCollected = p.GetProperty("totalCollected").GetInt64(); loan.Outstanding = p.GetProperty("outstandingAfter").GetInt64(); break;
case "case.closed":
loan.Status = p.GetProperty("status").GetString(); loan.TotalCollected = p.GetProperty("totalCollected").GetInt64(); loan.CloseReason = p.GetProperty("reason").GetString(); break;
}
loans[loanNo] = loan;
// On a gap you MAY reconcile with GET /api/v1/partner/cases/{loan} — after applying, never instead of it.
}
}
var app = WebApplication.CreateBuilder(args).Build();
app.MapPost(CallbackPath, async (HttpRequest request) =>
{
using var ms = new MemoryStream(); // take HttpRequest, not a bound model: read the exact bytes
await request.Body.CopyToAsync(ms);
var rawBody = ms.ToArray();
string? H(params string[] names) => names.Select(n => request.Headers[n].ToString()).FirstOrDefault(v => v != "");
if (!VerifyWebhook(rawBody, H("X-Fieldproof-Timestamp", "X-Quikkred-Timestamp"), H("X-Fieldproof-Signature", "X-Quikkred-Signature")))
return Results.Unauthorized();
using var doc = JsonDocument.Parse(rawBody);
if (doc.RootElement.GetProperty("type").GetString() != "ping") ApplyEvent(doc.RootElement.Clone()); // ping: sourceLoanNumber / sequence are null — verify, ack, apply nothing
return Results.Ok(new { received = true }); // 2xx within 10 s
});
// ---------- 5. Run ----------
_ = Task.Run(async () =>
{
try
{
await PushOverdueLoans(new object[] { new {
sourceLoanNumber = "LN-2026-000123",
borrower = new { name = "Ravi Sharma", phone = "+919876543210", address = new { line1 = "12 MG Road", city = "Bengaluru", state = "Karnataka", pincode = "560001" } },
loan = new { outstandingAmount = 12500, dpd = 45, emiAmount = 2500, nextDueDate = "2026-09-05" },
} }, "demo-2026-08-18-001");
// From your gateway's webhook handler, after a capture:
// await ConfirmPayment("LN-2026-000123", 12500, "pay_29QQoUBi66xm2f", mode: "upi", linkId: "plink_Ab12Cd34");
}
catch (Exception ex) { Console.Error.WriteLine(ex.Message); }
});
app.Run(); // receiver on the ASP.NET Core port
class LoanState { public string CaseId = ""; public DateTimeOffset CaseSince; public string? Status, CloseReason; public long TotalCollected, Outstanding; }
<?php
// One file: signed client + push job (CLI) + webhook receiver (web) + org_gateway confirm.
// Env: FP_KEY_ID, FP_SECRET, FP_SECRET_PREVIOUS (during a rotation), FP_BASE (optional).
declare(strict_types=1);
const CALLBACK_PATH = '/webhooks/fieldproof'; // pathname of the callback URL you registered (no query string)
const STATE_FILE = __DIR__ . '/fieldproof-state.json'; // demo store — replace with your database (see "Updating your LMS")
$base = getenv('FP_BASE') ?: 'https://alpha-gig.fluxusforge.in'; // production: https://gig.fluxusforge.in
$keyId = getenv('FP_KEY_ID'); // pk_yourorg_test_…
$secret = getenv('FP_SECRET'); // shown once at key issuance; also verifies webhooks
$secrets = array_values(array_filter([$secret, getenv('FP_SECRET_PREVIOUS') ?: null])); // inbound: current first, previous kept during rotation
// ---------- 1. Signed client ----------
function signedRequest(string $method, string $path, string $query = '', ?array $body = null, array $extra = []): array {
global $base, $keyId, $secret;
$rawBody = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); // serialise ONCE
$ts = (string) (int) round(microtime(true) * 1000); // unix milliseconds
$sig = hash_hmac('sha256', "{$method}\n{$path}\n{$ts}\n{$rawBody}", $secret); // PATH only — never the query string; lowercase hex
$headers = ["Authorization: Bearer {$keyId}", 'Content-Type: application/json',
"X-Fieldproof-Timestamp: {$ts}", "X-Fieldproof-Signature: {$sig}"];
foreach ($extra as $k => $v) $headers[] = "{$k}: {$v}";
$ch = curl_init($base . $path . $query);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60] + ($body === null ? [] : [CURLOPT_POSTFIELDS => $rawBody])); // exactly the signed bytes
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, json_decode((string) $raw, true) ?? []];
}
// ---------- 2. Push overdue loans ----------
function pushOverdueLoans(array $rows, string $idempotencyKey): array {
[$status, $res] = signedRequest('POST', '/api/v1/partner/cases', '', ['rows' => $rows],
['Idempotency-Key' => $idempotencyKey]); // reuse the SAME key when retrying after a timeout
if ($status !== 200) throw new RuntimeException("push {$status} " . json_encode($res['error'] ?? null)); // 429 carries Retry-After
echo 'push ', $res['batchId'], ' ', json_encode($res['summary']), ' ', json_encode($res['errors']), PHP_EOL; // rows fail individually
return $res;
}
// ---------- 3. Confirm a gateway capture (org_gateway mode only) ----------
// Call from your gateway's webhook handler / poller. reference = gateway payment id = idempotency key.
function confirmPayment(string $loanNo, int $amount, string $reference, ?string $paidAt = null, string $mode = 'link', ?string $linkId = null): array {
$path = '/api/v1/partner/cases/' . rawurlencode($loanNo) . '/payments/confirm'; // percent-encoded, exactly as sent
$body = array_filter(['amount' => $amount, 'reference' => $reference, 'paidAt' => $paidAt, 'mode' => $mode, 'linkId' => $linkId],
fn ($v) => $v !== null);
[$status, $res] = signedRequest('POST', $path, '', $body);
$code = $res['error']['code'] ?? null;
if ($status === 200) return $res; // { success, caseId, amount, outstandingAfter, status }
if ($status === 409 && $code === 'DUPLICATE_CONFIRMATION') return $res['error']; // already booked → treat as success
if ($status === 409) { // CASE_ALREADY_COLLECTED | AMOUNT_EXCEEDS_OUTSTANDING | CASE_CLOSED
error_log("money not owed — refund/adjust with the borrower: {$code} {$loanNo} {$reference}");
return $res['error'];
}
throw new RuntimeException("confirm {$status} {$code}"); // 5xx / 429: retry later with the SAME reference; other 4xx: fix, then re-run
}
// ---------- 4. Webhook receiver ----------
function verifyWebhook(string $rawBody, string $ts, string $sig): bool {
global $secrets;
if (!ctype_digit($ts) || $sig === '' || abs((int) round(microtime(true) * 1000) - (int) $ts) > 5 * 60 * 1000) return false; // ±5 min replay window
$canonical = "POST\n" . CALLBACK_PATH . "\n{$ts}\n{$rawBody}";
foreach ($secrets as $s) {
if (hash_equals(hash_hmac('sha256', $canonical, $s), strtolower($sig))) return true; // lowercase hex, constant time
}
return false;
}
function withState(callable $fn): void { // demo store under an exclusive lock; production: your DB with UNIQUE (event_id)
$fh = fopen(STATE_FILE, 'c+'); flock($fh, LOCK_EX);
$state = json_decode((string) stream_get_contents($fh), true) ?: ['seen' => [], 'watermarks' => [], 'loans' => [], 'ledger' => []];
$fn($state);
ftruncate($fh, 0); rewind($fh); fwrite($fh, json_encode($state)); flock($fh, LOCK_UN); fclose($fh);
}
function applyEvent(array $e): void {
withState(function (array &$s) use ($e): void {
if (isset($s['seen'][$e['eventId']])) return; // at-least-once delivery → dedupe
$s['seen'][$e['eventId']] = true;
$p = $e['payload']; $loanNo = $e['sourceLoanNumber'];
if ($e['type'] === 'payment.collected') { // MONEY: always booked, never gated by ordering
$s['ledger'][] = ['eventId' => $e['eventId'], 'loan' => $loanNo, 'caseId' => $p['caseId'], 'amount' => $p['amount'],
'mode' => $p['mode'], 'reference' => $p['reference'], 'collectedAt' => $p['collectedAt']];
}
$loan = $s['loans'][$loanNo] ?? [];
if (($loan['caseId'] ?? null) !== $p['caseId']) { // an event from a case we do not track yet
if (isset($loan['caseSince']) && strtotime($e['occurredAt']) < strtotime($loan['caseSince'])) return; // straggler from an earlier, closed case
$loan = ['caseId' => $p['caseId'], 'caseSince' => $e['occurredAt']]; // re-push after close = new case → adopt it (fresh watermark)
}
$key = "{$loanNo}:{$p['caseId']}";
if ($e['sequence'] <= ($s['watermarks'][$key] ?? 0)) return; // older than what we applied → case-state unchanged
$s['watermarks'][$key] = $e['sequence']; // gaps (1 → 4) are normal — never wait for them
switch ($e['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;
}
$s['loans'][$loanNo] = $loan;
// On a gap you MAY reconcile with GET /api/v1/partner/cases/{loan} — after applying, never instead of it.
});
}
// ---------- 5. Run ----------
if (PHP_SAPI === 'cli') { // `php fieldproof.php` → push the demo batch
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');
// From your gateway's webhook handler, after a capture:
// confirmPayment('LN-2026-000123', 12500, 'pay_29QQoUBi66xm2f', null, 'upi', 'plink_Ab12Cd34');
exit;
}
// Web request at CALLBACK_PATH → receiver
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$rawBody = file_get_contents('php://input'); // exact bytes — never rebuild from $_POST or json_decode()
$ts = $_SERVER['HTTP_X_FIELDPROOF_TIMESTAMP'] ?? $_SERVER['HTTP_X_QUIKKRED_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_FIELDPROOF_SIGNATURE'] ?? $_SERVER['HTTP_X_QUIKKRED_SIGNATURE'] ?? '';
if (!verifyWebhook($rawBody, $ts, $sig)) { http_response_code(401); exit; }
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
if ($event['type'] !== 'ping') applyEvent($event); // ping: sourceLoanNumber / sequence are null — verify, ack, apply nothing
header('Content-Type: application/json');
echo json_encode(['received' => true]); // 2xx within 10 s
Prove it works
- Start the skeleton with your sandbox key. The console prints the push
result:
batchId,summary(created/updatedSnapshot/unchanged/errored…) and any per-rowerrors[]. - 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— apingarrives, verifies, and is acknowledged without touching your stores.422 CALLBACK_NOT_CONFIGUREDmeans no callback is registered for this environment yet. - Re-run the push with the same
Idempotency-Key: the response is the original body withX-Idempotent-Replay: true. Note that replays and dry-runs still count toward the 10 batches per hour limit (60 requests per minute overall). - Mode B organisations: call
confirmPaymentfor the pushed loan with a freshreference. On the sandbox this really books the payment and emitspayment.collected(andcase.closedon a full clearance) to your receiver — the fastest way to watch the whole loop end to end. Call it again with the samereferenceand you get409 DUPLICATE_CONFIRMATION, which the skeleton treats as success. - Anything not delivered shows in
GET /api/v1/partner/webhooks/deliveries?status=exhausted; re-send withPOST /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 oneventIdandreference) — Updating your LMS. - Add the poll API as your recovery lane after
an outage; it feeds the same
applyEventhandler. - Mode B: host the mint-link endpoint and wire
confirmPaymentinto 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_BASEtohttps://gig.fluxusforge.inwith your LIVE key. Full list on the go-live checklist.
Questions or a stuck integration: [email protected].