Mode B — payment links from your own gateway
Fully automatic, and money still never touches Fieldproof: when a field partner needs a payment link at the borrower's door, Fieldproof asks your endpoint to mint one on your gateway. The borrower pays you directly. When your gateway captures the payment, you tell Fieldproof with one signed call and the case updates immediately.
You build
- A mint-link endpoint that Fieldproof calls (section 1)
- A confirm call to Fieldproof when your gateway captures (section 2)
We provide
- The link request from the partner app during the visit — an issued, unpaid link is reused for the same visit + amount rather than minted again
- Ledger row, case update and the partner's earning on your confirmation
- Idempotency on your confirmations (a retry can never double-book)
payment.collected/case.closedevents back to you
Mode B is selected at onboarding and configured by Fieldproof ops in the CRM
Integrations tab: your mint URL and the Authorization value it must carry.
The payments/confirm endpoint only works for organisations in this mode —
a Mode A organisation calling it gets 403 PAYMENT_MODE_MISMATCH.
:::warning Sandbox and production are configured separately
Nothing carries over from sandbox at go-live. You enter the production
mint URL and Authorization value again (alongside your production webhook
URL), then a live key is issued. Plan for two registrations.
:::
End to end
- The partner reaches the payment step for a case. Fieldproof
POSTs your mint endpoint (or reuses the unpaid link it already holds for that visit + amount). - You answer within 10 seconds with
{ linkId, url }. The borrower pays on that link — on your gateway, into your account. - Your gateway captures. This is visible only to you. Nothing reaches Fieldproof from your gateway.
- You call
POST /api/v1/partner/cases/{sourceLoanNumber}/payments/confirmwithreference= your gateway's payment id. - Fieldproof writes the ledger row, advances the case, credits the partner's
earning on a full clearance, and emits
payment.collected(pluscase.closedon full clearance) to your webhook and the poll feed.
1. The mint-link endpoint you host
Any https URL on a public IP. Fieldproof calls it during the visit and waits
at most 10 seconds. This is what arrives, verbatim:
POST /fieldproof/mint-link HTTP/1.1
Authorization: Bearer 3f9c0b7e… ← the exact static value you registered; NOT HMAC-signed
Content-Type: application/json
{
"sourceLoanNumber": "LN-2026-0001",
"caseId": "CC-2026-2388ED42",
"visitId": "665f1a2b3c4d5e6f7a8b9c0d",
"amount": 5000,
"currency": "INR",
"borrowerName": "Ravi Sharma",
"borrowerPhone": "9876543210",
"description": "Loan repayment for LN-2026-0001"
}
| Field | Type | Meaning |
|---|---|---|
sourceLoanNumber | string | Your loan number — the key you pushed the case under |
caseId | string | Fieldproof's case id (the same one that appears in every event) |
visitId | string | The visit during which the link was requested |
amount | integer | Whole INR rupees to collect on this link — never paise |
currency | string | Always "INR" |
borrowerName | string | For your link's customer details |
borrowerPhone | string or null | 10 digits, or null if not on file |
description | string | Human-readable label you may pass through to your gateway |
The Authorization header is a value you choose. It is sent back to you
exactly as registered — a static string, not an HMAC signature. Include your
own scheme prefix in the registered value (Bearer 3f9c0b7e…, ApiKey …)
so your framework's auth middleware can parse it, compare it in constant time,
and treat it as a secret. Any request without that exact value must be
rejected — nothing else authenticates the caller.
Respond 200 with exactly this shape:
{ "success": true, "link": { "linkId": "plink_Ab12Cd34", "url": "https://pay.yourlender.example/l/Ab12Cd34" } }
linkId is your identifier for the link — it is handed back to you in
payments/confirm as linkId so you can tie a capture to the exact visit.
:::warning There is no fallback for a failed mint
Anything other than a 200 carrying success: true and a link — a
non-200 status, a timeout past 10 seconds, a malformed body — is shown to the
partner as "link unavailable". Mode B organisations have no QR/bank block
in the app to fall back to; the partner can only retry later. Keep the endpoint
fast and boring: no synchronous risk checks, no cold-start-heavy stacks in the
request path.
:::
Idempotency expectations for your endpoint
Fieldproof already reuses an issued, unpaid link for the same visit + amount instead of minting again, but resumed visits and retries mean your side must be idempotent too. The rules a reference integration converged on after seeing two payable links for one visit both get paid:
- Same loan + same amount → return the same live link. Never a second payable link for money that already has one.
- Different amount requested (partial → full switch) → cancel the live link at your gateway first, then mint the new one. Only one link stays payable per loan.
- Nothing outstanding → refuse to mint (return a non-200). A closed or fully-paid loan must not get a payable link at all — that is how borrowers end up paying money they do not owe.
2. The confirm call you make
Sign it like every other Partner API call (signing requests):
Authorization: Bearer <apiKeyId>, X-Fieldproof-Timestamp in unix
milliseconds, X-Fieldproof-Signature = lowercase-hex HMAC-SHA256 over
POST\n<path>\n<timestamp>\n<raw body>. The <path> is the request path
as transmitted, percent-encoded — a loan number containing / becomes
%2F both in the URL and in the canonical string — with no query string.
POST /api/v1/partner/cases/LN-2026-0001/payments/confirm
Authorization: Bearer pk_yourorg_test_5f3a…
Content-Type: application/json
X-Fieldproof-Timestamp: 1755500000000
X-Fieldproof-Signature: 9c2e…
{
"amount": 5000,
"reference": "pay_Nq7xVb2Rt9",
"paidAt": "2026-08-18T09:30:00Z",
"mode": "link",
"linkId": "plink_Ab12Cd34"
}
| Field | Required | Rule |
|---|---|---|
amount | yes | Number, whole INR rupees, greater than 0, at most 1e9 |
reference | yes | 1–128 chars — your gateway's payment id. This is the idempotency key for (loan, reference) |
paidAt | no | ISO datetime of the capture |
mode | no | One of upi, card, netbanking, wallet, online, link, neft, nach, cheque, razorpay. Default link. cash is rejected with 422 — cash is never accepted on external-organisation cases |
linkId | no | ≤128 chars — the linkId you returned from mint, ties the capture to the visit |
Anything malformed is a 422 VALIDATION_FAILED with error.details[]
naming the field.
{ "success": true, "caseId": "CC-2026-2388ED42", "amount": 5000, "outstandingAfter": 7500, "status": "in_progress" }
What the amount does to the case:
- Full clearance —
amount≥ 99% of the remaining outstanding (a 1% rounding tolerance): the case completes with"status": "collected", the partner who performed the visit is credited their earning, and Fieldproof emitspayment.collectedthencase.closedwithstatus: "collected", reason: "full_settlement". - Partial — anything below that: the amount is booked, the case stays
open (
"status": "in_progress"), it detaches from the current partner and re-enters the marketplace after a cool-down (48 hours by default) so the balance can be collected on a later visit. Fieldproof emitspayment.collected. - More than 101% of the remaining outstanding is refused with
409 AMOUNT_EXCEEDS_OUTSTANDING— see below.
Confirm promptly: the partner at the door is watching the case update, and on a full clearance their earning is created from your confirmation.
Every non-200 and what to do
| Response | Meaning | Your action |
|---|---|---|
409 DUPLICATE_CONFIRMATION | This (loan, reference) was already applied. Body carries only error.caseId, not the original booking | Treat as success. Do not refund, do not retry |
409 CASE_ALREADY_COLLECTED | The case is already fully collected — this reference is a second payment for money that was not owed | Refund the borrower. Do not retry |
409 AMOUNT_EXCEEDS_OUTSTANDING | Amount is more than 101% of the remaining outstanding | Refund or adjust with the borrower. Do not retry as-is |
409 CASE_CLOSED | The case was recalled or written off before this payment | Refund the borrower. Do not retry |
404 CASE_NOT_FOUND | No case for this loan number in your organisation | Check the loan was pushed and the number matches exactly |
403 PAYMENT_MODE_MISMATCH | Your organisation is not in Mode B | Contact [email protected] |
422 VALIDATION_FAILED | Bad body (amount, reference, mode: "cash", …) | Fix and resend |
5xx | Fieldproof failed mid-way | Retry safely. If the ledger row was already written, the retry resumes and completes the booking |
The reference integration's rule of thumb: 200 and DUPLICATE_CONFIRMATION
mark the capture confirmed; the other three 409s mark it "refund needed" and
are never retried; everything else is retried on the next run.
:::warning Your gateway's webhooks never reach Fieldproof
Fieldproof does not integrate with your gateway and cannot see its captures.
Until you call payments/confirm, the case does not know it was paid — the
partner still sees it open. Trigger the confirm from your gateway's capture
webhook, and run a poller over your issued links as a safety net (that is what
a reference integration does), so a missed gateway webhook cannot leave a paid
case open.
:::
After a successful confirm, Fieldproof emits payment.collected back to you
with reference = your reference and mode = the mode you sent (default
link). Apply it with the standard handler in
Updating your LMS — its reference
pre-check keeps a payment your gateway callback already booked from being
counted twice.
:::tip Sandbox
Confirm is fully self-driven on the sandbox: push a case, mint against your
sandbox mint URL, call payments/confirm with a test key, and watch
payment.collected (and case.closed on full clearance) arrive at your
sandbox webhook or in GET /cases/updates.
:::
3. Complete samples — mint endpoint + confirm call
Each tab is one runnable service: the mint endpoint Fieldproof calls, the
confirmPayment function, and a gateway-webhook route that maps your gateway's
capture event onto a confirm. Replace the clearly-marked "your systems" stubs
(LMS balance lookup, gateway create/cancel, link storage) with your own; the
signing, encoding and response handling are complete as written.
- Node / Express
- Python / Flask
- Java / Spring Boot
- Go
- C# / .NET
- PHP
// Mode B in one file: the mint endpoint Fieldproof calls, the confirm call you
// make, and a gateway-webhook handler that ties them together.
const express = require("express");
const crypto = require("crypto");
// ── Configuration ────────────────────────────────────────────────────────────
const FP_BASE = process.env.FP_BASE || "https://alpha-gig.fluxusforge.in"; // https://gig.fluxusforge.in in production
const FP_KEY_ID = process.env.FP_KEY_ID; // pk_<org>_test_… (sandbox) / pk_<org>_live_… (production)
const FP_SECRET = process.env.FP_SECRET; // the secret shown once when the key was issued
const MINT_AUTH = process.env.FP_MINT_AUTH; // the EXACT Authorization value you registered, e.g. "Bearer 3f9c…"
// ── Your systems — replace these three with your LMS and gateway SDK ─────────
const lms = {
async outstanding(loanNumber) { throw new Error("TODO: return the remaining INR for this loan (0 if closed)"); },
};
const gateway = {
async createLink({ amount, description, customerName, customerPhone, notes }) {
throw new Error("TODO: create a payment link at your gateway and return { linkId, url }");
},
async cancelLink(linkId) { throw new Error("TODO: cancel an unpaid link at your gateway"); },
};
const links = new Map(); // linkId → { linkId, url, loanNumber, caseId, visitId, amount, status } — use your database
const app = express();
app.use(express.json());
function mintAuthOk(header) {
if (!MINT_AUTH || !header) return false;
const given = Buffer.from(String(header)), expected = Buffer.from(MINT_AUTH);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
// ── 1. The mint endpoint Fieldproof calls (register this URL + MINT_AUTH with ops) ──
app.post("/fieldproof/mint-link", async (req, res) => {
if (!mintAuthOk(req.get("authorization"))) {
return res.status(401).json({ success: false, message: "unauthorized" });
}
const { sourceLoanNumber, caseId, visitId, amount, borrowerName, borrowerPhone, description } = req.body || {};
const amt = Math.round(Number(amount));
if (!sourceLoanNumber || !(amt > 0)) {
return res.status(400).json({ success: false, message: "sourceLoanNumber and a positive amount are required" });
}
const outstanding = await lms.outstanding(sourceLoanNumber);
if (!(outstanding > 0)) { // nothing owed → refuse (any non-200 shows "link unavailable" to the agent)
return res.status(409).json({ success: false, message: "loan has no outstanding balance" });
}
const live = [...links.values()].filter((l) => l.loanNumber === sourceLoanNumber && l.status === "issued");
const same = live.find((l) => l.amount === amt);
if (same) { // same loan + amount → the SAME live link, never a second one
return res.json({ success: true, link: { linkId: same.linkId, url: same.url } });
}
for (const stale of live) { // a different amount was requested → retire the old link first
await gateway.cancelLink(stale.linkId);
stale.status = "cancelled";
}
const created = await gateway.createLink({
amount: amt,
description: description || `Repayment for loan ${sourceLoanNumber}`,
customerName: borrowerName || "Borrower",
customerPhone: borrowerPhone || undefined, // 10 digits or null
notes: { source: "fieldproof", loanNumber: sourceLoanNumber, caseId, visitId },
});
links.set(created.linkId, { linkId: created.linkId, url: created.url, loanNumber: sourceLoanNumber,
caseId, visitId, amount: amt, status: "issued" });
return res.json({ success: true, link: { linkId: created.linkId, url: created.url } });
});
// ── 2. The confirm call you make when your gateway captures a payment ─────────
async function confirmPayment(loanNumber, { amount, reference, linkId, mode = "link", paidAt }) {
// PATH is percent-encoded exactly as transmitted ("LN/001" → "LN%2F001"), no query string
const path = `/api/v1/partner/cases/${encodeURIComponent(loanNumber)}/payments/confirm`;
const body = JSON.stringify({ amount, reference, mode, ...(linkId && { linkId }), ...(paidAt && { paidAt }) });
const ts = Date.now().toString(); // unix MILLISECONDS
const sig = crypto.createHmac("sha256", FP_SECRET)
.update(`POST\n${path}\n${ts}\n${body}`).digest("hex"); // lowercase hex
const res = await fetch(FP_BASE + path, {
method: "POST",
headers: {
"Authorization": `Bearer ${FP_KEY_ID}`,
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
},
body, // the exact string that was signed
});
const data = await res.json().catch(() => ({}));
const err = data.error || {};
if (res.ok) return { outcome: "booked", ...data }; // { caseId, amount, outstandingAfter, status }
if (res.status === 409 && err.code === "DUPLICATE_CONFIRMATION") {
return { outcome: "booked", duplicate: true, caseId: err.caseId }; // already applied → success
}
if (res.status === 409) return { outcome: "refund", code: err.code, message: err.message }; // CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED
if (res.status >= 500) return { outcome: "retry", status: res.status }; // safe to retry — a resumed confirm completes
return { outcome: "investigate", status: res.status, code: err.code, message: err.message }; // 404 CASE_NOT_FOUND, 401/403/422
}
// ── 3. Your gateway's capture webhook → confirm to Fieldproof ─────────────────
// (The payload shape is your gateway's; verify ITS signature first, then map it to these four values.)
app.post("/gateway/webhook", async (req, res) => {
const { linkId, paymentId, amountRupees, capturedAt } = req.body || {}; // ← adapt to your gateway's event
const link = links.get(linkId);
if (!link) return res.sendStatus(200); // not one of our Fieldproof links
const r = await confirmPayment(link.loanNumber, {
amount: amountRupees, reference: paymentId, linkId, mode: "link", paidAt: capturedAt,
});
if (r.outcome === "booked") link.status = "paid";
if (r.outcome === "refund") console.error(`REFUND ${paymentId} to borrower: ${r.code} — ${r.message}`);
if (r.outcome === "retry") return res.sendStatus(500); // let the gateway redeliver, or enqueue for a poller
if (r.outcome === "investigate") console.error(`confirm failed ${r.status} ${r.code}: ${r.message}`);
return res.sendStatus(200);
});
app.listen(process.env.PORT || 8080);
# Mode B in one file: the mint endpoint Fieldproof calls, the confirm call you
# make, and a gateway-webhook handler that ties them together.
from __future__ import annotations
import hashlib, hmac, json, os, time
from urllib.parse import quote
import requests
from flask import Flask, jsonify, request
# ── Configuration ────────────────────────────────────────────────────────────
FP_BASE = os.environ.get("FP_BASE", "https://alpha-gig.fluxusforge.in") # https://gig.fluxusforge.in in production
FP_KEY_ID = os.environ["FP_KEY_ID"] # pk_<org>_test_… (sandbox) / pk_<org>_live_… (production)
FP_SECRET = os.environ["FP_SECRET"].encode() # the secret shown once when the key was issued
MINT_AUTH = os.environ["FP_MINT_AUTH"].encode() # the EXACT Authorization value you registered, e.g. "Bearer 3f9c…"
# ── Your systems — replace these three with your LMS and gateway SDK ─────────
def lms_outstanding(loan_number: str) -> int:
raise NotImplementedError("return the remaining INR for this loan (0 if closed)")
def gateway_create_link(amount: int, description: str, customer_name: str,
customer_phone: str | None, notes: dict) -> dict:
raise NotImplementedError("create a payment link at your gateway; return {'linkId': ..., 'url': ...}")
def gateway_cancel_link(link_id: str) -> None:
raise NotImplementedError("cancel an unpaid link at your gateway")
LINKS: dict[str, dict] = {} # linkId → {linkId, url, loanNumber, caseId, visitId, amount, status} — use your database
app = Flask(__name__)
def mint_auth_ok(header: str | None) -> bool:
return bool(header) and hmac.compare_digest(header.encode(), MINT_AUTH)
# ── 1. The mint endpoint Fieldproof calls (register this URL + MINT_AUTH with ops) ──
@app.post("/fieldproof/mint-link")
def mint_link():
if not mint_auth_ok(request.headers.get("Authorization")):
return jsonify(success=False, message="unauthorized"), 401
body = request.get_json(silent=True) or {}
loan = body.get("sourceLoanNumber")
amount = body.get("amount")
if not loan or not isinstance(amount, (int, float)) or amount <= 0:
return jsonify(success=False, message="sourceLoanNumber and a positive amount are required"), 400
amount = int(round(amount))
if lms_outstanding(loan) <= 0: # nothing owed → refuse (any non-200 shows "link unavailable" to the agent)
return jsonify(success=False, message="loan has no outstanding balance"), 409
live = [l for l in LINKS.values() if l["loanNumber"] == loan and l["status"] == "issued"]
same = next((l for l in live if l["amount"] == amount), None)
if same: # same loan + amount → the SAME live link, never a second one
return jsonify(success=True, link={"linkId": same["linkId"], "url": same["url"]})
for stale in live: # a different amount was requested → retire the old link first
gateway_cancel_link(stale["linkId"])
stale["status"] = "cancelled"
created = gateway_create_link(
amount,
body.get("description") or f"Repayment for loan {loan}",
body.get("borrowerName") or "Borrower",
body.get("borrowerPhone"), # 10 digits or None
{"source": "fieldproof", "loanNumber": loan, "caseId": body.get("caseId"), "visitId": body.get("visitId")},
)
LINKS[created["linkId"]] = {"linkId": created["linkId"], "url": created["url"], "loanNumber": loan,
"caseId": body.get("caseId"), "visitId": body.get("visitId"),
"amount": amount, "status": "issued"}
return jsonify(success=True, link={"linkId": created["linkId"], "url": created["url"]})
# ── 2. The confirm call you make when your gateway captures a payment ─────────
def confirm_payment(loan_number: str, amount: int, reference: str, link_id: str | None = None,
mode: str = "link", paid_at: str | None = None) -> dict:
# PATH is percent-encoded exactly as transmitted ("LN/001" → "LN%2F001"), no query string
path = "/api/v1/partner/cases/" + quote(loan_number, safe="") + "/payments/confirm"
payload = {"amount": amount, "reference": reference, "mode": mode}
if link_id:
payload["linkId"] = link_id
if paid_at:
payload["paidAt"] = paid_at
raw = json.dumps(payload, separators=(",", ":")) # serialise ONCE; sign and send these exact bytes
ts = str(int(time.time() * 1000)) # unix MILLISECONDS
sig = hmac.new(FP_SECRET, f"POST\n{path}\n{ts}\n{raw}".encode(), hashlib.sha256).hexdigest() # lowercase hex
r = requests.post(FP_BASE + path, data=raw.encode(), timeout=15, headers={
"Authorization": f"Bearer {FP_KEY_ID}",
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
})
try:
data = r.json()
except ValueError:
data = {}
err = data.get("error") or {}
code = err.get("code")
if r.status_code == 200:
return {"outcome": "booked", **data} # caseId, amount, outstandingAfter, status
if r.status_code == 409 and code == "DUPLICATE_CONFIRMATION":
return {"outcome": "booked", "duplicate": True, "caseId": err.get("caseId")} # already applied → success
if r.status_code == 409:
return {"outcome": "refund", "code": code, "message": err.get("message")} # CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED
if r.status_code >= 500:
return {"outcome": "retry", "status": r.status_code} # safe to retry — a resumed confirm completes
return {"outcome": "investigate", "status": r.status_code, "code": code, "message": err.get("message")} # 404 CASE_NOT_FOUND, 401/403/422
# ── 3. Your gateway's capture webhook → confirm to Fieldproof ─────────────────
# (The payload shape is your gateway's; verify ITS signature first, then map it to these four values.)
@app.post("/gateway/webhook")
def gateway_webhook():
cap = request.get_json(silent=True) or {} # ← adapt to your gateway's event
link = LINKS.get(cap.get("linkId", ""))
if not link:
return jsonify(ignored=True) # not one of our Fieldproof links
r = confirm_payment(link["loanNumber"], int(cap["amountRupees"]), cap["paymentId"],
link_id=link["linkId"], mode="link", paid_at=cap.get("capturedAt"))
if r["outcome"] == "booked":
link["status"] = "paid"
elif r["outcome"] == "refund":
app.logger.error("REFUND %s to borrower: %s — %s", cap["paymentId"], r["code"], r["message"])
elif r["outcome"] == "retry":
return jsonify(retry=True), 500 # let the gateway redeliver, or enqueue for a poller
else:
app.logger.error("confirm failed %s %s: %s", r["status"], r["code"], r["message"])
return jsonify(ok=True)
if __name__ == "__main__":
app.run(port=8080)
// java.net.http + Jackson — both already on the classpath of spring-boot-starter-web.
package com.example.fieldproof;
import java.io.IOException;
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.GeneralSecurityException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
@Component
public class FieldproofClient {
private static final String BASE = System.getenv().getOrDefault("FP_BASE", "https://alpha-gig.fluxusforge.in"); // https://gig.fluxusforge.in in production
private static final String KEY_ID = System.getenv("FP_KEY_ID"); // pk_<org>_test_… (sandbox) / pk_<org>_live_… (production)
private static final String SECRET = System.getenv("FP_SECRET"); // the secret shown once when the key was issued
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
private final ObjectMapper json = new ObjectMapper();
/** outcome ∈ booked | refund | retry | investigate; data = the 200 body, or the error object otherwise. */
public record Result(String outcome, int status, String code, String message, JsonNode data) {}
public Result confirmPayment(String loanNumber, long amount, String reference,
String linkId, String mode, String paidAt) throws IOException, InterruptedException {
// PATH is percent-encoded exactly as transmitted ("LN/001" → "LN%2F001"), no query string
String path = "/api/v1/partner/cases/"
+ URLEncoder.encode(loanNumber, StandardCharsets.UTF_8).replace("+", "%20")
+ "/payments/confirm";
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("amount", amount);
payload.put("reference", reference);
payload.put("mode", mode == null ? "link" : mode);
if (linkId != null) payload.put("linkId", linkId);
if (paidAt != null) payload.put("paidAt", paidAt);
String raw = json.writeValueAsString(payload); // serialise ONCE; sign and send these exact bytes
String ts = String.valueOf(System.currentTimeMillis()); // unix MILLISECONDS
String sig = hmacHex("POST\n" + path + "\n" + ts + "\n" + raw);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofSeconds(15))
.header("Authorization", "Bearer " + KEY_ID)
.header("Content-Type", "application/json")
.header("X-Fieldproof-Timestamp", ts)
.header("X-Fieldproof-Signature", sig)
.POST(HttpRequest.BodyPublishers.ofString(raw, StandardCharsets.UTF_8))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode data;
try {
data = json.readTree(res.body());
} catch (JsonProcessingException e) {
data = null;
}
if (data == null || data.isMissingNode()) data = json.createObjectNode();
JsonNode err = data.path("error");
String code = err.path("code").asText(null);
String message = err.path("message").asText(null);
int status = res.statusCode();
if (status == 200) return new Result("booked", status, null, null, data); // caseId, amount, outstandingAfter, status
if (status == 409 && "DUPLICATE_CONFIRMATION".equals(code))
return new Result("booked", status, code, message, err); // already applied → success (only caseId is returned)
if (status == 409) return new Result("refund", status, code, message, err); // CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED
if (status >= 500) return new Result("retry", status, code, message, err); // safe to retry — a resumed confirm completes
return new Result("investigate", status, code, message, err); // 404 CASE_NOT_FOUND, 401/403/422
}
private static String hmacHex(String canonical) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); // lowercase hex
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
}
package com.example.fieldproof;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
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;
@RestController
public class FieldproofController {
private static final String MINT_AUTH = System.getenv("FP_MINT_AUTH"); // the EXACT Authorization value you registered, e.g. "Bearer 3f9c…"
private final FieldproofClient fieldproof;
public FieldproofController(FieldproofClient fieldproof) { this.fieldproof = fieldproof; }
/** The body Fieldproof sends — these are all of its fields (Spring Boot's default ObjectMapper ignores unknown ones). */
public record MintRequest(String sourceLoanNumber, String caseId, String visitId, Long amount, String currency,
String borrowerName, String borrowerPhone, String description) {}
public record LinkRecord(String linkId, String url, String loanNumber, String caseId, String visitId, long amount, String status) {}
public record MintedLink(String linkId, String url) {}
/** Your gateway's capture event — adapt the field names to your gateway. */
public record GatewayCapture(String linkId, String paymentId, long amountRupees, String capturedAt) {}
private static final Map<String, LinkRecord> LINKS = new ConcurrentHashMap<>(); // linkId → link — use your database
// ── Your systems — replace these three with your LMS and gateway SDK ─────
long lmsOutstanding(String loanNumber) { throw new UnsupportedOperationException("TODO: return the remaining INR for this loan (0 if closed)"); }
MintedLink gatewayCreateLink(long amount, String description, String customerName, String customerPhone, Map<String, String> notes) {
throw new UnsupportedOperationException("TODO: create a payment link at your gateway and return its id + url");
}
void gatewayCancelLink(String linkId) { throw new UnsupportedOperationException("TODO: cancel an unpaid link at your gateway"); }
private static boolean mintAuthOk(String header) {
if (MINT_AUTH == null || header == null) return false;
return MessageDigest.isEqual(header.getBytes(StandardCharsets.UTF_8), MINT_AUTH.getBytes(StandardCharsets.UTF_8));
}
private static Map<String, Object> failBody(String message) { return Map.of("success", false, "message", message); }
private static Map<String, Object> okBody(String linkId, String url) {
return Map.of("success", true, "link", Map.of("linkId", linkId, "url", url));
}
// ── 1. The mint endpoint Fieldproof calls (register this URL + FP_MINT_AUTH with ops) ──
@PostMapping("/fieldproof/mint-link")
public ResponseEntity<Map<String, Object>> mintLink(@RequestHeader(value = "Authorization", required = false) String auth,
@RequestBody MintRequest req) {
if (!mintAuthOk(auth)) return ResponseEntity.status(401).body(failBody("unauthorized"));
if (req.sourceLoanNumber() == null || req.amount() == null || req.amount() <= 0)
return ResponseEntity.status(400).body(failBody("sourceLoanNumber and a positive amount are required"));
String loan = req.sourceLoanNumber();
long amount = req.amount();
if (lmsOutstanding(loan) <= 0) // nothing owed → refuse (any non-200 shows "link unavailable" to the agent)
return ResponseEntity.status(409).body(failBody("loan has no outstanding balance"));
List<LinkRecord> live = LINKS.values().stream()
.filter(l -> l.loanNumber().equals(loan) && l.status().equals("issued")).toList();
Optional<LinkRecord> same = live.stream().filter(l -> l.amount() == amount).findFirst();
if (same.isPresent()) // same loan + amount → the SAME live link, never a second one
return ResponseEntity.ok(okBody(same.get().linkId(), same.get().url()));
for (LinkRecord stale : live) { // a different amount was requested → retire the old link first
gatewayCancelLink(stale.linkId());
LINKS.put(stale.linkId(), new LinkRecord(stale.linkId(), stale.url(), stale.loanNumber(), stale.caseId(), stale.visitId(), stale.amount(), "cancelled"));
}
Map<String, String> notes = new HashMap<>();
notes.put("source", "fieldproof");
notes.put("loanNumber", loan);
if (req.caseId() != null) notes.put("caseId", req.caseId());
if (req.visitId() != null) notes.put("visitId", req.visitId());
MintedLink created = gatewayCreateLink(amount,
req.description() != null ? req.description() : "Repayment for loan " + loan,
req.borrowerName() != null ? req.borrowerName() : "Borrower",
req.borrowerPhone(), // 10 digits or null
notes);
LINKS.put(created.linkId(), new LinkRecord(created.linkId(), created.url(), loan, req.caseId(), req.visitId(), amount, "issued"));
return ResponseEntity.ok(okBody(created.linkId(), created.url()));
}
// ── 3. Your gateway's capture webhook → confirm to Fieldproof ─────────────
// (Verify your gateway's own signature first, then map its payload to GatewayCapture.)
@PostMapping("/gateway/webhook")
public ResponseEntity<?> gatewayWebhook(@RequestBody GatewayCapture cap) throws Exception {
LinkRecord link = LINKS.get(cap.linkId());
if (link == null) return ResponseEntity.ok(Map.of("ignored", true)); // not one of our Fieldproof links
FieldproofClient.Result r = fieldproof.confirmPayment(link.loanNumber(), cap.amountRupees(), cap.paymentId(),
link.linkId(), "link", cap.capturedAt());
switch (r.outcome()) {
case "booked" -> LINKS.put(link.linkId(), new LinkRecord(link.linkId(), link.url(), link.loanNumber(), link.caseId(), link.visitId(), link.amount(), "paid"));
case "refund" -> System.err.printf("REFUND %s to borrower: %s — %s%n", cap.paymentId(), r.code(), r.message());
case "retry" -> { return ResponseEntity.status(500).body(Map.of("retry", true)); } // let the gateway redeliver, or enqueue for a poller
default -> System.err.printf("confirm failed %d %s: %s%n", r.status(), r.code(), r.message());
}
return ResponseEntity.ok(Map.of("ok", true));
}
}
// Mode B in one file: the mint endpoint Fieldproof calls, the confirm call you
// make, and a gateway-webhook handler that ties them together.
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
)
// ── Configuration ────────────────────────────────────────────────────────────
var (
fpBase = envOr("FP_BASE", "https://alpha-gig.fluxusforge.in") // https://gig.fluxusforge.in in production
fpKeyID = os.Getenv("FP_KEY_ID") // pk_<org>_test_… (sandbox) / pk_<org>_live_… (production)
fpSecret = os.Getenv("FP_SECRET") // the secret shown once when the key was issued
mintAuth = os.Getenv("FP_MINT_AUTH") // the EXACT Authorization value you registered, e.g. "Bearer 3f9c…"
)
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
// ── Your systems — replace these three with your LMS and gateway SDK ─────────
func lmsOutstanding(loanNumber string) (int64, error) {
return 0, errors.New("TODO: return the remaining INR for this loan (0 if closed)")
}
func gatewayCreateLink(amount int64, description, customerName, customerPhone string, notes map[string]string) (linkID, linkURL string, err error) {
return "", "", errors.New("TODO: create a payment link at your gateway and return its id + url")
}
func gatewayCancelLink(linkID string) error {
return errors.New("TODO: cancel an unpaid link at your gateway")
}
type link struct {
LinkID, URL, LoanNumber, CaseID, VisitID string
Amount int64
Status string // issued | paid | cancelled
}
var (
linksMu sync.Mutex
links = map[string]*link{} // linkId → link — use your database
)
// The body Fieldproof sends (unknown fields are ignored)
type mintRequest struct {
SourceLoanNumber string `json:"sourceLoanNumber"`
CaseID string `json:"caseId"`
VisitID string `json:"visitId"`
Amount float64 `json:"amount"` // whole INR rupees
Currency string `json:"currency"`
BorrowerName string `json:"borrowerName"`
BorrowerPhone *string `json:"borrowerPhone"` // 10 digits or null
Description string `json:"description"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// ── 1. The mint endpoint Fieldproof calls (register this URL + FP_MINT_AUTH with ops) ──
func mintLink(w http.ResponseWriter, r *http.Request) {
given := r.Header.Get("Authorization")
if mintAuth == "" || subtle.ConstantTimeCompare([]byte(given), []byte(mintAuth)) != 1 {
writeJSON(w, 401, map[string]any{"success": false, "message": "unauthorized"})
return
}
var req mintRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil || req.SourceLoanNumber == "" || !(req.Amount > 0) {
writeJSON(w, 400, map[string]any{"success": false, "message": "sourceLoanNumber and a positive amount are required"})
return
}
amount := int64(math.Round(req.Amount))
outstanding, err := lmsOutstanding(req.SourceLoanNumber)
if err != nil {
writeJSON(w, 500, map[string]any{"success": false, "message": err.Error()})
return
}
if outstanding <= 0 { // nothing owed → refuse (any non-200 shows "link unavailable" to the agent)
writeJSON(w, 409, map[string]any{"success": false, "message": "loan has no outstanding balance"})
return
}
linksMu.Lock()
defer linksMu.Unlock()
var stale []*link
for _, l := range links {
if l.LoanNumber != req.SourceLoanNumber || l.Status != "issued" {
continue
}
if l.Amount == amount { // same loan + amount → the SAME live link, never a second one
writeJSON(w, 200, map[string]any{"success": true, "link": map[string]string{"linkId": l.LinkID, "url": l.URL}})
return
}
stale = append(stale, l)
}
for _, s := range stale { // a different amount was requested → retire the old link first
if err := gatewayCancelLink(s.LinkID); err == nil {
s.Status = "cancelled"
}
}
phone := ""
if req.BorrowerPhone != nil {
phone = *req.BorrowerPhone
}
desc := req.Description
if desc == "" {
desc = "Repayment for loan " + req.SourceLoanNumber
}
name := req.BorrowerName
if name == "" {
name = "Borrower"
}
id, u, err := gatewayCreateLink(amount, desc, name, phone,
map[string]string{"source": "fieldproof", "loanNumber": req.SourceLoanNumber, "caseId": req.CaseID, "visitId": req.VisitID})
if err != nil {
writeJSON(w, 502, map[string]any{"success": false, "message": err.Error()})
return
}
links[id] = &link{LinkID: id, URL: u, LoanNumber: req.SourceLoanNumber, CaseID: req.CaseID, VisitID: req.VisitID, Amount: amount, Status: "issued"}
writeJSON(w, 200, map[string]any{"success": true, "link": map[string]string{"linkId": id, "url": u}})
}
// ── 2. The confirm call you make when your gateway captures a payment ─────────
type confirmResult struct {
Outcome string // booked | refund | retry | investigate
Status int
Code string
Message string
Body json.RawMessage
}
func confirmPayment(loanNumber string, amount int64, reference, linkID, mode, paidAt string) (confirmResult, error) {
// PATH is percent-encoded exactly as transmitted ("LN/001" → "LN%2F001"), no query string
path := "/api/v1/partner/cases/" + url.PathEscape(loanNumber) + "/payments/confirm"
if mode == "" {
mode = "link"
}
payload := map[string]any{"amount": amount, "reference": reference, "mode": mode}
if linkID != "" {
payload["linkId"] = linkID
}
if paidAt != "" {
payload["paidAt"] = paidAt
}
raw, err := json.Marshal(payload) // serialise ONCE; sign and send these exact bytes
if err != nil {
return confirmResult{}, err
}
ts := strconv.FormatInt(time.Now().UnixMilli(), 10) // unix MILLISECONDS
mac := hmac.New(sha256.New, []byte(fpSecret))
mac.Write([]byte("POST\n" + path + "\n" + ts + "\n"))
mac.Write(raw)
sig := hex.EncodeToString(mac.Sum(nil)) // lowercase hex
req, err := http.NewRequest(http.MethodPost, fpBase+path, bytes.NewReader(raw))
if err != nil {
return confirmResult{}, err
}
req.Header.Set("Authorization", "Bearer "+fpKeyID)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fieldproof-Timestamp", ts)
req.Header.Set("X-Fieldproof-Signature", sig)
res, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return confirmResult{Outcome: "retry"}, err // network error → retry later
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
var env struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
CaseID string `json:"caseId"`
} `json:"error"`
}
_ = json.Unmarshal(body, &env)
out := confirmResult{Status: res.StatusCode, Code: env.Error.Code, Message: env.Error.Message, Body: body}
switch {
case res.StatusCode == 200:
out.Outcome = "booked" // body: caseId, amount, outstandingAfter, status
case res.StatusCode == 409 && env.Error.Code == "DUPLICATE_CONFIRMATION":
out.Outcome = "booked" // already applied → success (only caseId is returned)
case res.StatusCode == 409:
out.Outcome = "refund" // CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED
case res.StatusCode >= 500:
out.Outcome = "retry" // safe to retry — a resumed confirm completes
default:
out.Outcome = "investigate" // 404 CASE_NOT_FOUND, 401/403/422
}
return out, nil
}
// ── 3. Your gateway's capture webhook → confirm to Fieldproof ─────────────────
// (The payload shape is your gateway's; verify ITS signature first, then map it to these four values.)
func gatewayWebhook(w http.ResponseWriter, r *http.Request) {
var capture struct {
LinkID string `json:"linkId"`
PaymentID string `json:"paymentId"`
AmountRupees int64 `json:"amountRupees"`
CapturedAt string `json:"capturedAt"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&capture); err != nil {
w.WriteHeader(400)
return
}
linksMu.Lock()
l, ok := links[capture.LinkID]
linksMu.Unlock()
if !ok { // not one of our Fieldproof links
w.WriteHeader(200)
return
}
res, err := confirmPayment(l.LoanNumber, capture.AmountRupees, capture.PaymentID, l.LinkID, "link", capture.CapturedAt)
switch {
case err != nil || res.Outcome == "retry":
w.WriteHeader(500) // let the gateway redeliver, or enqueue for a poller
return
case res.Outcome == "booked":
linksMu.Lock()
l.Status = "paid"
linksMu.Unlock()
case res.Outcome == "refund":
log.Printf("REFUND %s to borrower: %s — %s", capture.PaymentID, res.Code, res.Message)
default:
log.Printf("confirm failed %d %s: %s", res.Status, res.Code, res.Message)
}
w.WriteHeader(200)
}
func main() {
http.HandleFunc("POST /fieldproof/mint-link", mintLink)
http.HandleFunc("POST /gateway/webhook", gatewayWebhook)
log.Fatal(http.ListenAndServe(":8080", nil))
}
// Mode B in one file: the mint endpoint Fieldproof calls, the confirm call you
// make, and a gateway-webhook handler that ties them together.
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ── Configuration ────────────────────────────────────────────────────────────
var fpBase = Environment.GetEnvironmentVariable("FP_BASE") ?? "https://alpha-gig.fluxusforge.in"; // https://gig.fluxusforge.in in production
var fpKeyId = Environment.GetEnvironmentVariable("FP_KEY_ID")!; // pk_<org>_test_… (sandbox) / pk_<org>_live_… (production)
var fpSecret = Environment.GetEnvironmentVariable("FP_SECRET")!; // the secret shown once when the key was issued
var mintAuth = Environment.GetEnvironmentVariable("FP_MINT_AUTH")!; // the EXACT Authorization value you registered, e.g. "Bearer 3f9c…"
var links = new ConcurrentDictionary<string, LinkRecord>(); // linkId → link — use your database
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
// ── Your systems — replace these three with your LMS and gateway SDK ─────────
Task<long> LmsOutstanding(string loanNumber) =>
throw new NotImplementedException("TODO: return the remaining INR for this loan (0 if closed)");
Task<(string linkId, string url)> GatewayCreateLink(long amount, string description, string customerName, string? customerPhone, Dictionary<string, string?> notes) =>
throw new NotImplementedException("TODO: create a payment link at your gateway and return its id + url");
Task GatewayCancelLink(string linkId) =>
throw new NotImplementedException("TODO: cancel an unpaid link at your gateway");
static bool FixedTimeEquals(string? given, string expected) =>
given is not null && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(given), Encoding.UTF8.GetBytes(expected));
var app = WebApplication.CreateBuilder(args).Build();
// ── 1. The mint endpoint Fieldproof calls (register this URL + FP_MINT_AUTH with ops) ──
app.MapPost("/fieldproof/mint-link", async (HttpRequest request, MintRequest body) =>
{
if (!FixedTimeEquals(request.Headers.Authorization.ToString(), mintAuth))
return Results.Json(new { success = false, message = "unauthorized" }, statusCode: 401);
if (string.IsNullOrEmpty(body.SourceLoanNumber) || body.Amount <= 0)
return Results.Json(new { success = false, message = "sourceLoanNumber and a positive amount are required" }, statusCode: 400);
var loan = body.SourceLoanNumber;
if (await LmsOutstanding(loan) <= 0) // nothing owed → refuse (any non-200 shows "link unavailable" to the agent)
return Results.Json(new { success = false, message = "loan has no outstanding balance" }, statusCode: 409);
var live = links.Values.Where(l => l.LoanNumber == loan && l.Status == "issued").ToList();
var same = live.FirstOrDefault(l => l.Amount == body.Amount);
if (same is not null) // same loan + amount → the SAME live link, never a second one
return Results.Ok(new { success = true, link = new { linkId = same.LinkId, url = same.Url } });
foreach (var stale in live) // a different amount was requested → retire the old link first
{
await GatewayCancelLink(stale.LinkId);
links[stale.LinkId] = stale with { Status = "cancelled" };
}
var (linkId, url) = await GatewayCreateLink(body.Amount,
body.Description ?? $"Repayment for loan {loan}",
body.BorrowerName ?? "Borrower",
body.BorrowerPhone, // 10 digits or null
new Dictionary<string, string?> { ["source"] = "fieldproof", ["loanNumber"] = loan, ["caseId"] = body.CaseId, ["visitId"] = body.VisitId });
links[linkId] = new LinkRecord(linkId, url, loan, body.CaseId, body.VisitId, body.Amount, "issued");
return Results.Ok(new { success = true, link = new { linkId, url } });
});
// ── 2. The confirm call you make when your gateway captures a payment ─────────
async Task<ConfirmResult> ConfirmPayment(string loanNumber, long amount, string reference,
string? linkId = null, string mode = "link", string? paidAt = null)
{
// PATH is percent-encoded exactly as transmitted ("LN/001" → "LN%2F001"), no query string
var path = "/api/v1/partner/cases/" + Uri.EscapeDataString(loanNumber) + "/payments/confirm";
var payload = new JsonObject { ["amount"] = amount, ["reference"] = reference, ["mode"] = mode };
if (linkId is not null) payload["linkId"] = linkId;
if (paidAt is not null) payload["paidAt"] = paidAt;
var raw = payload.ToJsonString(); // serialise ONCE; sign and send these exact bytes
var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); // unix MILLISECONDS
var sig = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(fpSecret),
Encoding.UTF8.GetBytes($"POST\n{path}\n{ts}\n{raw}"))).ToLowerInvariant(); // lowercase hex
using var msg = new HttpRequestMessage(HttpMethod.Post, fpBase + path);
msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", fpKeyId);
msg.Headers.Add("X-Fieldproof-Timestamp", ts);
msg.Headers.Add("X-Fieldproof-Signature", sig);
msg.Content = new StringContent(raw, Encoding.UTF8);
msg.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var res = await http.SendAsync(msg);
var text = await res.Content.ReadAsStringAsync();
JsonNode? data = null;
try { data = JsonNode.Parse(text); } catch (JsonException) { }
var err = data?["error"];
var code = err?["code"]?.GetValue<string>();
var message = err?["message"]?.GetValue<string>();
var status = (int)res.StatusCode;
if (status == 200) return new ConfirmResult("booked", status, null, null, data); // caseId, amount, outstandingAfter, status
if (status == 409 && code == "DUPLICATE_CONFIRMATION")
return new ConfirmResult("booked", status, code, message, err); // already applied → success (only caseId is returned)
if (status == 409) return new ConfirmResult("refund", status, code, message, err); // CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED
if (status >= 500) return new ConfirmResult("retry", status, code, message, err); // safe to retry — a resumed confirm completes
return new ConfirmResult("investigate", status, code, message, err); // 404 CASE_NOT_FOUND, 401/403/422
}
// ── 3. Your gateway's capture webhook → confirm to Fieldproof ─────────────────
// (The payload shape is your gateway's; verify ITS signature first, then map it to GatewayCapture.)
app.MapPost("/gateway/webhook", async (GatewayCapture cap) =>
{
if (!links.TryGetValue(cap.LinkId, out var link)) return Results.Ok(new { ignored = true }); // not one of our Fieldproof links
var r = await ConfirmPayment(link.LoanNumber, cap.AmountRupees, cap.PaymentId, link.LinkId, "link", cap.CapturedAt);
switch (r.Outcome)
{
case "booked": links[link.LinkId] = link with { Status = "paid" }; break;
case "refund": Console.Error.WriteLine($"REFUND {cap.PaymentId} to borrower: {r.Code} — {r.Message}"); break;
case "retry": return Results.StatusCode(500); // let the gateway redeliver, or enqueue for a poller
default: Console.Error.WriteLine($"confirm failed {r.Status} {r.Code}: {r.Message}"); break;
}
return Results.Ok(new { ok = true });
});
app.Run();
// The body Fieldproof sends (unknown fields are ignored)
record MintRequest(string SourceLoanNumber, string? CaseId, string? VisitId, long Amount, string? Currency,
string? BorrowerName, string? BorrowerPhone, string? Description);
record LinkRecord(string LinkId, string Url, string LoanNumber, string? CaseId, string? VisitId, long Amount, string Status);
/// <summary>Your gateway's capture event — adapt the field names to your gateway.</summary>
record GatewayCapture(string LinkId, string PaymentId, long AmountRupees, string? CapturedAt);
/// <summary>Outcome ∈ booked | refund | retry | investigate; Data = the 200 body, or the error object otherwise.</summary>
record ConfirmResult(string Outcome, int Status, string? Code, string? Message, JsonNode? Data);
<?php
// Mode B in one file: the mint endpoint Fieldproof calls, the confirm call you
// make, and a gateway-webhook handler that ties them together.
// Route both paths below to this file. Apache/FPM: make sure the Authorization
// header reaches PHP (e.g. `SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1`, or CGIPassAuth On).
declare(strict_types=1);
// ── Configuration ────────────────────────────────────────────────────────────
const FP_BASE = 'https://alpha-gig.fluxusforge.in'; // https://gig.fluxusforge.in in production
$FP_KEY_ID = (string) getenv('FP_KEY_ID'); // pk_<org>_test_… (sandbox) / pk_<org>_live_… (production)
$FP_SECRET = (string) getenv('FP_SECRET'); // the secret shown once when the key was issued
$MINT_AUTH = (string) getenv('FP_MINT_AUTH'); // the EXACT Authorization value you registered, e.g. "Bearer 3f9c…"
// ── Your systems — replace these with your LMS, gateway SDK and database ─────
function lms_outstanding(string $loan): int { throw new RuntimeException('TODO: return the remaining INR for this loan (0 if closed)'); }
/** @return array{0:string,1:string} [linkId, url] */
function gateway_create_link(int $amount, string $description, string $customerName, ?string $customerPhone, array $notes): array {
throw new RuntimeException('TODO: create a payment link at your gateway and return [linkId, url]');
}
function gateway_cancel_link(string $linkId): void { throw new RuntimeException('TODO: cancel an unpaid link at your gateway'); }
/** Rows: ['linkId','url','loanNumber','caseId','visitId','amount','status'] — status ∈ issued|paid|cancelled */
function links_live_for(string $loan): array { throw new RuntimeException('TODO: SELECT … WHERE loanNumber = ? AND status = "issued"'); }
function links_find(string $linkId): ?array { throw new RuntimeException('TODO: SELECT … WHERE linkId = ?'); }
function links_save(array $row): void { throw new RuntimeException('TODO: INSERT'); }
function links_set_status(string $linkId, string $status): void { throw new RuntimeException('TODO: UPDATE'); }
function json_out(int $status, array $body): never {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body);
exit;
}
// ── 1. The mint endpoint Fieldproof calls (register this URL + FP_MINT_AUTH with ops) ──
function handle_mint_link(string $mintAuth): never {
$given = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($mintAuth === '' || !hash_equals($mintAuth, $given)) {
json_out(401, ['success' => false, 'message' => 'unauthorized']);
}
$body = json_decode((string) file_get_contents('php://input'), true) ?: [];
$loan = $body['sourceLoanNumber'] ?? null;
$amount = $body['amount'] ?? null;
if (!is_string($loan) || $loan === '' || !is_numeric($amount) || $amount <= 0) {
json_out(400, ['success' => false, 'message' => 'sourceLoanNumber and a positive amount are required']);
}
$amount = (int) round((float) $amount);
if (lms_outstanding($loan) <= 0) { // nothing owed → refuse (any non-200 shows "link unavailable" to the agent)
json_out(409, ['success' => false, 'message' => 'loan has no outstanding balance']);
}
$live = links_live_for($loan);
foreach ($live as $l) { // same loan + amount → the SAME live link, never a second one
if ((int) $l['amount'] === $amount) {
json_out(200, ['success' => true, 'link' => ['linkId' => $l['linkId'], 'url' => $l['url']]]);
}
}
foreach ($live as $l) { // a different amount was requested → retire the old link first
gateway_cancel_link($l['linkId']);
links_set_status($l['linkId'], 'cancelled');
}
[$linkId, $url] = gateway_create_link(
$amount,
$body['description'] ?? "Repayment for loan $loan",
$body['borrowerName'] ?? 'Borrower',
$body['borrowerPhone'] ?? null, // 10 digits or null
['source' => 'fieldproof', 'loanNumber' => $loan, 'caseId' => $body['caseId'] ?? null, 'visitId' => $body['visitId'] ?? null],
);
links_save(['linkId' => $linkId, 'url' => $url, 'loanNumber' => $loan, 'caseId' => $body['caseId'] ?? null,
'visitId' => $body['visitId'] ?? null, 'amount' => $amount, 'status' => 'issued']);
json_out(200, ['success' => true, 'link' => ['linkId' => $linkId, 'url' => $url]]);
}
// ── 2. The confirm call you make when your gateway captures a payment ─────────
/** @return array outcome ∈ booked | refund | retry | investigate */
function fp_confirm_payment(string $keyId, string $secret, string $loan, int $amount, string $reference,
?string $linkId = null, string $mode = 'link', ?string $paidAt = null): array {
// PATH is percent-encoded exactly as transmitted ("LN/001" → "LN%2F001"), no query string
$path = '/api/v1/partner/cases/' . rawurlencode($loan) . '/payments/confirm';
$payload = ['amount' => $amount, 'reference' => $reference, 'mode' => $mode];
if ($linkId !== null) $payload['linkId'] = $linkId;
if ($paidAt !== null) $payload['paidAt'] = $paidAt;
$raw = json_encode($payload, JSON_THROW_ON_ERROR); // serialise ONCE; sign and send these exact bytes
$ts = (string) (int) floor(microtime(true) * 1000); // unix MILLISECONDS
$sig = hash_hmac('sha256', "POST\n$path\n$ts\n$raw", $secret); // lowercase hex
$ch = curl_init(FP_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $raw,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $keyId",
'Content-Type: application/json',
"X-Fieldproof-Timestamp: $ts",
"X-Fieldproof-Signature: $sig",
],
]);
$text = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($text === false) return ['outcome' => 'retry', 'status' => 0]; // network error → retry later
$data = json_decode((string) $text, true) ?: [];
$err = $data['error'] ?? [];
$code = $err['code'] ?? null;
if ($status === 200) return ['outcome' => 'booked'] + $data; // caseId, amount, outstandingAfter, status
if ($status === 409 && $code === 'DUPLICATE_CONFIRMATION') {
return ['outcome' => 'booked', 'duplicate' => true, 'caseId' => $err['caseId'] ?? null]; // already applied → success
}
if ($status === 409) return ['outcome' => 'refund', 'code' => $code, 'message' => $err['message'] ?? null]; // CASE_ALREADY_COLLECTED / AMOUNT_EXCEEDS_OUTSTANDING / CASE_CLOSED
if ($status >= 500) return ['outcome' => 'retry', 'status' => $status]; // safe to retry — a resumed confirm completes
return ['outcome' => 'investigate', 'status' => $status, 'code' => $code, 'message' => $err['message'] ?? null]; // 404 CASE_NOT_FOUND, 401/403/422
}
// ── 3. Your gateway's capture webhook → confirm to Fieldproof ─────────────────
// (The payload shape is your gateway's; verify ITS signature first, then map it to these four values.)
function handle_gateway_webhook(string $keyId, string $secret): never {
$cap = json_decode((string) file_get_contents('php://input'), true) ?: []; // ← adapt to your gateway's event
$link = links_find((string) ($cap['linkId'] ?? ''));
if (!$link) json_out(200, ['ignored' => true]); // not one of our Fieldproof links
$r = fp_confirm_payment($keyId, $secret, $link['loanNumber'], (int) $cap['amountRupees'], (string) $cap['paymentId'],
$link['linkId'], 'link', $cap['capturedAt'] ?? null);
if ($r['outcome'] === 'booked') links_set_status($link['linkId'], 'paid');
if ($r['outcome'] === 'refund') error_log("REFUND {$cap['paymentId']} to borrower: {$r['code']} — {$r['message']}");
if ($r['outcome'] === 'retry') json_out(500, ['retry' => true]); // let the gateway redeliver, or enqueue for a poller
if ($r['outcome'] === 'investigate') error_log("confirm failed {$r['status']} {$r['code']}: {$r['message']}");
json_out(200, ['ok' => true]);
}
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && $uri === '/fieldproof/mint-link') handle_mint_link($MINT_AUTH);
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && $uri === '/gateway/webhook') handle_gateway_webhook($FP_KEY_ID, $FP_SECRET);
http_response_code(404);
:::tip Prove the signature before you prove the flow
On the sandbox, POST /api/v1/partner/debug/echo-signature returns the
server-side canonical string for whatever you send — if your first
payments/confirm gets 401 SIGNATURE_INVALID, point the same signing code at
it and diff. See debugging signatures.
:::
Go-live checklist for Mode B
- Mint endpoint reachable over
httpson a public IP, answering in well under 10 seconds; the registeredAuthorizationvalue compared in constant time. - Same loan + amount returns the same live link; a new amount cancels the old link; a loan with nothing outstanding gets a non-200.
- Every gateway capture becomes exactly one
payments/confirmwithreference= the gateway payment id — from your capture webhook, backed by a poller. DUPLICATE_CONFIRMATIONhandled as success;CASE_ALREADY_COLLECTED,AMOUNT_EXCEEDS_OUTSTANDINGandCASE_CLOSEDrouted to a refund queue, never retried;5xxretried.- Production mint URL and
Authorizationvalue registered again before the live key is used.