Skip to main content

Sandbox testing

The sandbox at https://alpha-gig.fluxusforge.in serves the same API surface as production and accepts test keys only (environments). Everything you build is provable there — but note what the sandbox does not have:

:::warning No simulated field agents The sandbox does not auto-assign cases or generate visits. Anything a field partner or Fieldproof ops would do in production — assignment, visit outcomes, Mode A proof verification — is driven by the Fieldproof team on request. Ask your integration contact ([email protected]) to work a case when you reach that row of the matrix below. :::

What you can drive yourself vs. what we drive for you​

You can self-driveThe Fieldproof team drives on request
GET /healthAssigning a case to a partner → case.assigned
POST /cases and POST /cases?dryRun=1 (push, update, fullSnapshot)Recording a visit outcome → visit.completed
POST /webhooks/test (signed ping to your callback)Mode A: verifying an uploaded payment proof → payment.collected (operator-confirmed amount) and case.closed on full clearance
GET /webhooks/deliveries + POST /webhooks/deliveries/{eventId}/replayMode B: having the agent request a link so your mint endpoint is called
GET /cases/updates (poll feed) and GET /cases/{loan}Changing your webhook URL / subscriptions / payment artefacts
POST /cases/{loan}/recall → case.closed (reason: "recalled")Key rotation and retiring an old secret
Mode B: POST /cases/{loan}/payments/confirm → emits payment.collected and, on full clearance, case.closed
POST /debug/echo-signature (sandbox only)

Setup​

You need your sandbox key, and — for webhook rows — a callback that satisfies the callback rules: HTTPS on a public IP, no query string, answers 2xx within 10 s. For a development machine, any HTTPS tunnel that gives you a public URL works; register that URL with your integration contact. If you have no callback yet, every row below still works via the poll API.

All snippets use this signed client (Node 18+, ES module):

fp-client.mjs
import crypto from "node:crypto";

export const BASE = "https://alpha-gig.fluxusforge.in"; // sandbox — test keys only
const KEY_ID = process.env.FIELDPROOF_KEY_ID; // pk_yourorg_test_...
const SECRET = process.env.FIELDPROOF_SECRET; // one-time secret from onboarding

/**
* Signed request. `pathWithQuery` is the exact path you will send, with any
* path parameters already percent-encoded (encodeURIComponent), plus an
* optional query string. The signature covers:
* METHOD \n PATH (without query) \n unix-ms TIMESTAMP \n exact RAW BODY
* and is lowercase hex. GET requests sign an empty body.
*/
export async function fp(method, pathWithQuery, bodyObj, extraHeaders = {}) {
const body = bodyObj === undefined ? "" : JSON.stringify(bodyObj);
const path = pathWithQuery.split("?")[0];
const ts = Date.now().toString();
const canonical = `${method}\n${path}\n${ts}\n${body}`;
const sig = crypto.createHmac("sha256", SECRET).update(canonical, "utf8").digest("hex");

const res = await fetch(BASE + pathWithQuery, {
method,
headers: {
Authorization: `Bearer ${KEY_ID}`,
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
...extraHeaders,
},
...(bodyObj === undefined ? {} : { body }),
});
return {
status: res.status,
requestId: res.headers.get("x-request-id"),
replay: res.headers.get("x-idempotent-replay") === "true",
json: await res.json(),
};
}

// Path helper — loan numbers may contain "/" and must be percent-encoded
export const casePath = (loan, suffix = "") =>
`/api/v1/partner/cases/${encodeURIComponent(loan)}${suffix}`;

:::note Mind the batch budget POST /cases is limited to 10 batches per hour per key, and dry-runs and idempotent replays count. The matrix below spends 7 of them; do not loop pushes in a tight retry. :::

Test matrix — run in this order​

#StepYou doExpected result
1Auth worksGET /health200 { ok:true, lenderCode:"<yourorg>", env:"test", serverTime }
2Auth fails loudlySend one request with a wrong signature, then use POST /debug/echo-signature401 SIGNATURE_INVALID; the echo shows signatureMatches:false and the server's canonicalString to diff against yours. Do this once — 10 failures in 300 s lock the key for 900 s (429 AUTH_LOCKED)
3ValidationPOST /cases?dryRun=1 with 2 good rows + 2 bad rows (bad pincode, rounded coordinates)200, dryRun:true, summary.errored:2, errors[0].index:2 and errors[1].index:3, each with a reason; warnings:[]; nothing written, no events
4CommitPOST /cases with the 2 good rows + Idempotency-Key200, summary.created:2, idempotentReplay:false; your callback receives one case.received per row, each with sequence:1 and a caseId
5IdempotencyRe-send (a) with the same Idempotency-Key, (b) identical body without the key(a) 200, original body verbatim, header X-Idempotent-Replay: true; (b) 200, idempotentReplay:true, same summary (both loans still have live cases). No new events either way
6Update in placeRe-send one row with a changed loan.outstandingAmount, then GET /cases/{loan}summary.updatedSnapshot:1; the case shows the new outstandingAmount, same caseId. No case.received (the case already exists)
7Webhook self-testPOST /webhooks/test200 { delivered:true, statusCode:<yours>, latencyMs }; your receiver got a signed ping with sourceLoanNumber:null, sequence:null and verified the signature. 422 CALLBACK_NOT_CONFIGURED if no callback is registered
8DedupeGET /webhooks/deliveries?status=delivered, then POST /webhooks/deliveries/{eventId}/replay on a case.received200 { status:"pending" }; the same eventId arrives again; your receiver logs it as a duplicate and does not re-apply
9Poll feedGET /cases/updates?limit=100, then follow nextCursorEvents oldest-first, all types regardless of subscription; the same envelopes you got by webhook; hasMore:false at the end
10Assignment + visit (team-driven)Ask us to assign one case and record a visit (e.g. a promise to pay — outcome:"delivered_acknowledged" with ptpDate set)case.assigned / visit.completed only if you subscribed; default subscribers see a sequence gap (e.g. next event at sequence:4) — your receiver must apply it, not wait
11Mode B: mint (team-driven)Ask us to have the agent request a payment link on an assigned caseYour mint endpoint receives POST with the Authorization value you registered and { sourceLoanNumber, caseId, visitId, amount, currency:"INR", borrowerName, borrowerPhone, description }; you answer 200 { success:true, link:{ linkId, url } }
12Mode B: confirmPOST /cases/{loan}/payments/confirm partial → same reference again → remaining amount → one morepartial: 200 status:"in_progress" + payment.collected · repeat: 409 DUPLICATE_CONFIRMATION (treat as success) · remaining: 200 status:"collected" + payment.collected then case.closed (reason:"full_settlement") · after that: 409 CASE_ALREADY_COLLECTED (refund path)
13Mode A: proof verification (team-driven)Ask us to upload and verify a proof on an assigned casepayment.collected with the operator-confirmed amount (totalCollected / outstandingAfter absolute), then case.closed (reason:"full_settlement") on full clearance
14RecallPOST /cases/{loan}/recall twicefirst: 200 { alreadyClosed:false, status:"closed_unrecovered" } + case.closed (reason:"recalled"); second: 200 { alreadyClosed:true }
15Re-push after closePush the recalled loan again, in one batch together with a brand-new loansummary.created:2; the recalled loan gets a new caseId and a case.received with sequence:1 again — your watermark keyed on (sourceLoanNumber, caseId) accepts it
16fullSnapshotPush one batch with fullSnapshot:true containing every active sandbox loan except the re-pushed onesummary.flaggedNeedsReview:1 (you get the count only); the omitted active case is flagged for ops review, not closed

Rows 3, 4, 5a, 5b, 6, 15 and 16 are the 7 batches.

Rows 3–6: push, dry-run, idempotency, update​

push.mjs
import { fp } from "./fp-client.mjs";

// Test rows share one building (same pincode → the same pin is allowed).
// Coordinates as strings: a JSON number drops trailing zeros.
const MG_ROAD = { city: "Bengaluru", state: "Karnataka", pincode: "560001",
latitude: "12.975326", longitude: "77.606262" };

const rows = [
{
sourceLoanNumber: "SBX-0001",
borrower: { name: "Asha Test", phone: "9876543210",
address: { line1: "Flat 1A, 12 MG Road", ...MG_ROAD } },
loan: { outstandingAmount: 12500, dpd: 45, emiAmount: 2500, nextDueDate: "2026-08-05" },
},
{
sourceLoanNumber: "SBX-0002",
borrower: { name: "Ravi Test", phone: "+919876543211",
address: { line1: "Flat 2B, 12 MG Road", ...MG_ROAD } },
loan: { outstandingAmount: 8000, dpd: "61-90" }, // "lo-hi" range → upper bound
},
{
sourceLoanNumber: "SBX-BAD-PIN",
borrower: { name: "Bad Row", phone: "9876543212", address: { pincode: "56" } }, // pincode must be 6 digits
loan: { outstandingAmount: 100 },
},
{
sourceLoanNumber: "SBX-BAD-GPS",
borrower: { name: "Bad Pin", phone: "9876543214",
address: { line1: "Flat 3C, 12 MG Road", ...MG_ROAD,
latitude: "12.97", longitude: "77.6" } }, // rounded: needs ≥ 4 decimals
loan: { outstandingAmount: 100 },
},
];

// Row 3 — dry-run: validates only, writes nothing, emits nothing
const dry = await fp("POST", "/api/v1/partner/cases?dryRun=1", { rows });
console.log(dry.status, dry.json.summary.errored, dry.json.errors, dry.json.warnings);
// 200 2 [
// { index: 2, sourceLoanNumber: 'SBX-BAD-PIN',
// reason: 'Row 2 (SBX-BAD-PIN): missing or malformed borrower.address.pincode (must be 6 digits)' },
// { index: 3, sourceLoanNumber: 'SBX-BAD-GPS',
// reason: 'Row 3 (SBX-BAD-GPS): borrower.address.latitude/longitude need at least 4 decimal places (≈11 m) — got 12.97, 77.6. Send the coordinates of the door, not a rounded or city-level point' }
// ] []

// Row 4 — commit the good rows with an Idempotency-Key
const good = rows.filter((r) => !r.sourceLoanNumber.startsWith("SBX-BAD"));
const key = `sbx-batch-${Date.now()}`;
const c1 = await fp("POST", "/api/v1/partner/cases", { rows: good }, { "Idempotency-Key": key });
console.log(c1.status, c1.json.summary, c1.json.idempotentReplay);
// 200 { created: 2, ... } false → one case.received per row (sequence 1)

// Row 5a — same key: original body verbatim + X-Idempotent-Replay: true
const c2 = await fp("POST", "/api/v1/partner/cases", { rows: good }, { "Idempotency-Key": key });
console.log(c2.status, c2.replay); // 200 true

// Row 5b — same content, no key: content-hash no-op (both loans still have live cases)
const c3 = await fp("POST", "/api/v1/partner/cases", { rows: good });
console.log(c3.json.idempotentReplay); // true — no events emitted

// Row 6 — re-send one row with a new outstanding: updates the ACTIVE case in place
good[0].loan.outstandingAmount = 10000; // keep dpd/lateCharges in the row: omitted values reset to 0
const c4 = await fp("POST", "/api/v1/partner/cases", { rows: [good[0]] });
console.log(c4.json.summary.updatedSnapshot); // 1
get-case.mjs
import { fp, casePath } from "./fp-client.mjs";

const g = await fp("GET", casePath("SBX-0001"));
console.log(g.status, g.json.case.caseId, g.json.case.status, g.json.case.outstandingAmount);
// 200 CC-... pending 10000

Every 4xx from an API route comes back as { "success": false, "error": { "code": "...", "message": "..." } } with an X-Request-Id header — log the request id. (A mistyped path gets a plain 404 without either; check the URL.)

Rows 7–9: webhook self-test, replay, poll​

webhooks.mjs
import { fp } from "./fp-client.mjs";

// Row 7 — synchronous signed ping to your callback
const t = await fp("POST", "/api/v1/partner/webhooks/test", {});
console.log(t.status, t.json);
// 200 { success:true, delivered:true, statusCode:200, latencyMs:..., error:..., bodyExcerpt:... }

// Row 8 — replay a delivered event; your receiver must report a duplicate
const d = await fp("GET", "/api/v1/partner/webhooks/deliveries?limit=50&status=delivered");
const evt = d.json.deliveries.find((x) => x.type === "case.received");
const r = await fp("POST", `/api/v1/partner/webhooks/deliveries/${evt.eventId}/replay`, {});
console.log(r.status, r.json.status); // 200 pending

// Row 9 — poll feed: same envelopes, oldest-first, all event types
let cursor = null;
for (;;) {
const q = cursor ? `cursor=${encodeURIComponent(cursor)}&limit=100` : "limit=100";
const p = await fp("GET", `/api/v1/partner/cases/updates?${q}`);
for (const e of p.json.events) {
console.log(e.sequence, e.type, e.sourceLoanNumber, e.eventId);
}
cursor = p.json.nextCursor; // persist only AFTER processing the page
if (!p.json.hasMore || !cursor) break;
}

Your receiver's checks for rows 7 and 8: verify X-Fieldproof-Signature with your secret over POST\n<callback path>\n<X-Fieldproof-Timestamp>\n<raw body>, tolerate the ping envelope's null sourceLoanNumber / sequence, and key your dedupe on eventId (webhook receiver contract).

Row 12: Mode B confirm sequence​

Only for organisations configured as org_gateway; a Mode A organisation gets 403 PAYMENT_MODE_MISMATCH on this endpoint.

confirm.mjs
import { fp, casePath } from "./fp-client.mjs";

const LOAN = "SBX-0002"; // outstanding 8000 from the push above
const confirm = (body) => fp("POST", casePath(LOAN, "/payments/confirm"), body);

// Partial payment → case stays open (in_progress)
const p1 = await confirm({ amount: 3000, reference: "pay_sbx_0001", mode: "upi", paidAt: new Date().toISOString() });
console.log(p1.status, p1.json);
// 200 { success:true, caseId:"CC-...", amount:3000, outstandingAfter:5000, status:"in_progress" }
// webhook: payment.collected { amount:3000, mode:"upi", reference:"pay_sbx_0001", totalCollected:3000, outstandingAfter:5000 }

// Same reference again → already applied; treat as success (only caseId is returned)
const dup = await confirm({ amount: 3000, reference: "pay_sbx_0001", mode: "upi" });
console.log(dup.status, dup.json.error.code, dup.json.error.caseId); // 409 DUPLICATE_CONFIRMATION CC-...

// Remaining amount (≥ 99% of remaining outstanding) → collected
const p2 = await confirm({ amount: 5000, reference: "pay_sbx_0002", mode: "upi" });
console.log(p2.status, p2.json.status, p2.json.outstandingAfter); // 200 collected 0
// webhooks: payment.collected, THEN case.closed { status:"collected", reason:"full_settlement", totalCollected:8000 }

// A further payment on a collected case is money not owed → you refund the borrower
const late = await confirm({ amount: 500, reference: "pay_sbx_0003", mode: "upi" });
console.log(late.status, late.json.error.code); // 409 CASE_ALREADY_COLLECTED

// Also worth one call each:
// amount > 101% of remaining → 409 AMOUNT_EXCEEDS_OUTSTANDING (refund / adjust)
// a recalled loan → 409 CASE_CLOSED (refund)
// mode: "cash" → 422 VALIDATION_FAILED (cash is never accepted)
// unknown sourceLoanNumber → 404 CASE_NOT_FOUND

reference is your gateway's payment id and is the idempotency key of the confirmation. If a confirm ever returns a 5xx, retry with the same reference — the retry resumes and completes.

Rows 14–16: recall, re-push, fullSnapshot​

recall.mjs
import { fp, casePath } from "./fp-client.mjs";

const r1 = await fp("POST", casePath("SBX-0001", "/recall"), { reason: "Borrower settled with us directly" });
console.log(r1.status, r1.json);
// 200 { success:true, alreadyClosed:false, caseId:"CC-...", status:"closed_unrecovered" }
// webhook: case.closed { status:"closed_unrecovered", reason:"recalled" }

const r2 = await fp("POST", casePath("SBX-0001", "/recall"), {});
console.log(r2.json.alreadyClosed); // true — idempotent

Send recall a JSON body: {} or { "reason": "…" } with Content-Type: application/json. An empty body sent with that header is treated as {}. A POST with no body and no Content-Type is rejected with 422 VALIDATION_FAILED.

Now push SBX-0001 again (row 15). Because the previous case is closed, a fresh case opens: new caseId, case.received with sequence: 1. A watermark keyed on sourceLoanNumber alone would wrongly drop that event as "old" — key it on (sourceLoanNumber, caseId) or reset it whenever you see case.received (updating your LMS).

repush-and-snapshot.mjs
import { fp } from "./fp-client.mjs";

const MG_ROAD = { city: "Bengaluru", state: "Karnataka", pincode: "560001",
latitude: "12.975326", longitude: "77.606262" };
const sbx1 = {
sourceLoanNumber: "SBX-0001",
borrower: { name: "Asha Test", phone: "9876543210", address: { line1: "Flat 1A, 12 MG Road", ...MG_ROAD } },
loan: { outstandingAmount: 10000, dpd: 45 },
};
const sbx3 = {
sourceLoanNumber: "SBX-0003",
borrower: { name: "Meera Test", phone: "9876543213", address: { line1: "Flat 4D, 12 MG Road", ...MG_ROAD } },
loan: { outstandingAmount: 5000, dpd: 30 },
};

// Row 15 — SBX-0001's case is closed, so it gets a fresh case. (Even a batch
// byte-identical to an earlier one is processed when one of its loans no longer
// has a live case; it is a no-op only while every loan in it is still live.)
const re = await fp("POST", "/api/v1/partner/cases", { rows: [sbx1, sbx3] });
console.log(re.json.summary.created); // 2 → case.received for both, sequence 1, new caseId for SBX-0001

// Row 16 — "this is my COMPLETE open book": SBX-0001 is active but absent → flagged for ops review.
// List EVERY other active sandbox loan here (e.g. SBX-0002, unless row 12 collected it) — anything
// active that is missing from this one batch is flagged too.
const openBook = [sbx3];
const snap = await fp("POST", "/api/v1/partner/cases", { fullSnapshot: true, rows: openBook });
console.log(snap.json.summary.flaggedNeedsReview); // 1 (only SBX-0001)

fullSnapshot: true must always be one single batch — a chunked snapshot would flag every active case missing from each chunk. Use it daily or weekly, never for incremental updates.

Using the signature debugger​

POST /debug/echo-signature exists only on the sandbox with an existing, active test key (a live key or the production host → 403 SANDBOX_ONLY). It deliberately accepts a wrong or missing signature — it needs only the timestamp header, and any body must be JSON — and returns what the server computed so you can diff it against your own canonical string. It never verifies the signature, so it does not count toward the lockout and keeps working while your key is locked:

echo-signature.mjs
import crypto from "node:crypto";

const BASE = "https://alpha-gig.fluxusforge.in";
const path = "/api/v1/partner/debug/echo-signature";
const body = JSON.stringify({ probe: "hello" }); // any JSON body
const ts = Date.now().toString(); // unix milliseconds
const canonical = `POST\n${path}\n${ts}\n${body}`;
const sig = crypto.createHmac("sha256", process.env.FIELDPROOF_SECRET).update(canonical, "utf8").digest("hex");

const res = await fetch(BASE + path, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FIELDPROOF_KEY_ID}`,
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
},
body,
});
const out = await res.json();
// { success:true, canonicalString, canonicalParts:{ method, path, timestamp, bodyBytes, bodySha256 },
// expectedSignature, receivedSignature, signatureMatches, timestampWithinSkew, note }
console.log("matches:", out.signatureMatches, "skew ok:", out.timestampWithinSkew);
if (out.canonicalString !== canonical) {
console.log("server canonical string:\n" + JSON.stringify(out.canonicalString));
console.log("mine:\n" + JSON.stringify(canonical));
}

Typical mismatches it exposes: a query string left in the path, a non-percent-encoded path parameter, a timestamp in seconds instead of milliseconds, a re-serialised body that differs from the bytes sent, or a signature computed over the wrong string. Full guide: debugging signatures.

When you are done​

Every row green, including the team-driven ones, is the entry condition for the go-live checklist. The reference implementation runs this whole matrix from a dashboard if you would rather watch it than script it.