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-drive | The Fieldproof team drives on request |
|---|---|
GET /health | Assigning 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}/replay | Mode 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):
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
| # | Step | You do | Expected result |
|---|---|---|---|
| 1 | Auth works | GET /health | 200 { ok:true, lenderCode:"<yourorg>", env:"test", serverTime } |
| 2 | Auth fails loudly | Send one request with a wrong signature, then use POST /debug/echo-signature | 401 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) |
| 3 | Validation | POST /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 |
| 4 | Commit | POST /cases with the 2 good rows + Idempotency-Key | 200, summary.created:2, idempotentReplay:false; your callback receives one case.received per row, each with sequence:1 and a caseId |
| 5 | Idempotency | Re-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 |
| 6 | Update in place | Re-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) |
| 7 | Webhook self-test | POST /webhooks/test | 200 { 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 |
| 8 | Dedupe | GET /webhooks/deliveries?status=delivered, then POST /webhooks/deliveries/{eventId}/replay on a case.received | 200 { status:"pending" }; the same eventId arrives again; your receiver logs it as a duplicate and does not re-apply |
| 9 | Poll feed | GET /cases/updates?limit=100, then follow nextCursor | Events oldest-first, all types regardless of subscription; the same envelopes you got by webhook; hasMore:false at the end |
| 10 | Assignment + 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 |
| 11 | Mode B: mint (team-driven) | Ask us to have the agent request a payment link on an assigned case | Your 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 } } |
| 12 | Mode B: confirm | POST /cases/{loan}/payments/confirm partial → same reference again → remaining amount → one more | partial: 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) |
| 13 | Mode A: proof verification (team-driven) | Ask us to upload and verify a proof on an assigned case | payment.collected with the operator-confirmed amount (totalCollected / outstandingAfter absolute), then case.closed (reason:"full_settlement") on full clearance |
| 14 | Recall | POST /cases/{loan}/recall twice | first: 200 { alreadyClosed:false, status:"closed_unrecovered" } + case.closed (reason:"recalled"); second: 200 { alreadyClosed:true } |
| 15 | Re-push after close | Push the recalled loan again, in one batch together with a brand-new loan | 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 |
| 16 | fullSnapshot | Push one batch with fullSnapshot:true containing every active sandbox loan except the re-pushed one | summary.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
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
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
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.
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
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).
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:
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.