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 { "success": true, "link": { "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 YOURORG — LN-2026-0001 (collected by Quikkred)"
}
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. For a full-balance link it is our copy of the outstanding; if that copy is 0, the request still arrives, with amount: 1 — your outstanding check (below) must refuse it
currencystringAlways "INR"
borrowerNamestringFor your link's customer details
borrowerPhonestring or nullA 10-digit mobile, or null if none on file or it is not a valid mobile number
descriptionstringA human-readable label you may pass through to your gateway. Do not parse it — the wording can change

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 with any 2xx (we send 200 in our samples) and this body:

What you return
{ "success": true, "link": { "linkId": "plink_Ab12Cd34", "url": "https://pay.yourlender.example/l/Ab12Cd34" } }

link.url is required. linkId is your identifier for the link — send it: it is what you pass back in payments/confirm as linkId to tie a capture to the exact visit. (If you omit it we generate an internal one, which you never see.) Other fields in your body are ignored.

:::warning There is no fallback for a failed mint Anything other than a 2xx carrying success: true and a link.url — any other status (a 3xx redirect included: we never follow redirects), a timeout past 10 seconds, or a malformed body — including one cut off because it ran past 64 KB (we stop reading at about 64 KB, so keep the body well under that) — 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-2xx). 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. Check your own balance, not our amount: when our copy of the outstanding is 0, a full-balance request arrives with amount: 1.

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. Rounded to whole rupees — a value that rounds to 0 is a 422
referenceyes1–128 chars — your gateway's payment id. This is the idempotency key for (loan, reference)
paidAtnoISO datetime of the capture. Becomes the event's collectedAt; omitted → the time we received your confirm
modenoSend one of upi, card, netbanking, wallet, online, link, neft, nach, cheque, razorpay (lower case, ≤ 20 chars). Default link. Any other value up to 20 chars — including a different case, e.g. "UPI" — is not rejected: it is booked and echoed on the event as link. cash (any case) 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

Unknown body fields are silently dropped, not rejected. Schema problems (missing or mistyped amount / reference, over-length strings) are a 422 VALIDATION_FAILED with error.details[] naming the field; the checks made after the schema — mode: "cash", an unparseable paidAt, an amount that rounds to 0 — are a 422 VALIDATION_FAILED with a message only (no details).

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 closed unrecovered (recalled, 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]
401 (UNAUTHORIZED, SIGNATURE_INVALID, TIMESTAMP_SKEW)Key, signature or clock problemFix signing / clock, then resend — see debugging signatures
422 VALIDATION_FAILEDBad body (amount, reference, mode: "cash", paidAt, …)Fix and resend
429 RATE_LIMITEDOver 60 requests/min for this keyRetry after the Retry-After header (seconds; also error.retryAfter)
429 AUTH_LOCKEDKey locked after repeated bad signaturesFix signing first; resend after Retry-After
5xx / network errorFieldproof failed mid-way, or no responseRetry safely, with the same reference. 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; 5xx, 429 and network errors are retried (honouring Retry-After); any other 4xx goes to a person — fix the cause before resending, never retry it blindly.

:::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, mode = the mode you sent (default link; an unrecognised value becomes link) and collectedAt = your paidAt. 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-2xx 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

let res;
try {
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
});
} catch (e) {
return { outcome: "retry", status: 0, message: e.message }; // network error → retry with the SAME reference
}
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 || res.status === 429) { // 5xx: a resumed confirm completes; 429: wait Retry-After
return { outcome: "retry", status: res.status, retryAfter: res.headers.get("retry-after") };
}
return { outcome: "investigate", status: res.status, code: err.code, message: err.message }; // 404 CASE_NOT_FOUND, 401/403/422 — fix, don't retry blindly
}

// ── 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 (by your own books) gets a non-2xx; your response carries link.linkId.
  • 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, 429 (after Retry-After) and network errors retried with the same reference; other 4xx investigated, not retried.
  • Production mint URL and Authorization value registered again before the live key is used.