Skip to main content

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​

  1. A mint-link endpoint that Fieldproof calls (section 1)
  2. 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.closed events 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​

  1. 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).
  2. You answer within 10 seconds with { linkId, url }. The borrower pays on that link — on your gateway, into your account.
  3. Your gateway captures. This is visible only to you. Nothing reaches Fieldproof from your gateway.
  4. You call POST /api/v1/partner/cases/{sourceLoanNumber}/payments/confirm with reference = your gateway's payment id.
  5. Fieldproof writes the ledger row, advances the case, credits the partner's earning on a full clearance, and emits payment.collected (plus case.closed on full clearance) to your webhook and the poll feed.

Any https URL on a public IP. Fieldproof calls it during the visit and waits at most 10 seconds. This is what arrives, verbatim:

What Fieldproof sends to your mint endpoint
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"
}
FieldTypeMeaning
sourceLoanNumberstringYour loan number — the key you pushed the case under
caseIdstringFieldproof's case id (the same one that appears in every event)
visitIdstringThe visit during which the link was requested
amountintegerWhole INR rupees to collect on this link — never paise
currencystringAlways "INR"
borrowerNamestringFor your link's customer details
borrowerPhonestring or null10 digits, or null if not on file
descriptionstringHuman-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:

What you return
{ "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.

Request
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"
}
FieldRequiredRule
amountyesNumber, whole INR rupees, greater than 0, at most 1e9
referenceyes1–128 chars — your gateway's payment id. This is the idempotency key for (loan, reference)
paidAtnoISO datetime of the capture
modenoOne 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
linkIdno≤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.

200 — booked
{ "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 emits payment.collected then case.closed with status: "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 emits payment.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​

ResponseMeaningYour action
409 DUPLICATE_CONFIRMATIONThis (loan, reference) was already applied. Body carries only error.caseId, not the original bookingTreat as success. Do not refund, do not retry
409 CASE_ALREADY_COLLECTEDThe case is already fully collected — this reference is a second payment for money that was not owedRefund the borrower. Do not retry
409 AMOUNT_EXCEEDS_OUTSTANDINGAmount is more than 101% of the remaining outstandingRefund or adjust with the borrower. Do not retry as-is
409 CASE_CLOSEDThe case was recalled or written off before this paymentRefund the borrower. Do not retry
404 CASE_NOT_FOUNDNo case for this loan number in your organisationCheck the loan was pushed and the number matches exactly
403 PAYMENT_MODE_MISMATCHYour organisation is not in Mode BContact [email protected]
422 VALIDATION_FAILEDBad body (amount, reference, mode: "cash", …)Fix and resend
5xxFieldproof failed mid-wayRetry 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.

server.js — Node 18+ (global fetch), Express
// 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);

:::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 https on a public IP, answering in well under 10 seconds; the registered Authorization value 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/confirm with reference = the gateway payment id — from your capture webhook, backed by a poller.
  • DUPLICATE_CONFIRMATION handled as success; CASE_ALREADY_COLLECTED, AMOUNT_EXCEEDS_OUTSTANDING and CASE_CLOSED routed to a refund queue, never retried; 5xx retried.
  • Production mint URL and Authorization value registered again before the live key is used.