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 + 1 bad row200, dryRun:true, summary.errored:1, errors[0].index:2 with a reason; 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. 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. ptp)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 loan, so the batch content differs from anything committed before)summary.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";

const rows = [
{
sourceLoanNumber: "SBX-0001",
borrower: { name: "Asha Test", phone: "9876543210",
address: { line1: "12 MG Road", city: "Bengaluru", state: "Karnataka", pincode: "560001" } },
loan: { outstandingAmount: 12500, dpd: 45, emiAmount: 2500, nextDueDate: "2026-08-05" },
},
{
sourceLoanNumber: "SBX-0002",
borrower: { name: "Ravi Test", phone: "+919876543211", address: { pincode: "560001" } },
loan: { outstandingAmount: 8000, dpd: "61-90" }, // "lo-hi" range → upper bound
},
{
sourceLoanNumber: "SBX-BAD",
borrower: { name: "Bad Row", phone: "9876543212", address: { pincode: "56" } }, // pincode must be 6 digits
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);
// 200 1 [ { index: 2, sourceLoanNumber: 'SBX-BAD', reason: '...' } ]

// Row 4 — commit the good rows with an Idempotency-Key
const good = rows.filter((r) => r.sourceLoanNumber !== "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
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 comes back as { "success": false, "error": { "code": "...", "message": "..." } } with an X-Request-Id header — log the request id.

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

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 sbx1 = {
sourceLoanNumber: "SBX-0001",
borrower: { name: "Asha Test", phone: "9876543210", address: { pincode: "560001" } },
loan: { outstandingAmount: 10000, dpd: 45 },
};
const sbx3 = {
sourceLoanNumber: "SBX-0003",
borrower: { name: "Meera Test", phone: "9876543213", address: { pincode: "560001" } },
loan: { outstandingAmount: 5000, dpd: 30 },
};

// Row 15 — a batch identical to one already committed is a content-hash no-op,
// so re-push together with a new loan (or with changed values)
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 a test key (anything else → 403 SANDBOX_ONLY). It deliberately accepts a wrong or missing signature — it needs only the timestamp header — and returns what the server computed so you can diff it against your own canonical string:

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.