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 + 1 bad row | 200, dryRun:true, summary.errored:1, errors[0].index:2 with a reason; 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. 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. 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 |
| 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, 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 |
| 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";
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
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
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
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 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:
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.