Quickstart
Three signed calls take about five minutes and prove the whole loop: auth works → your payload validates → we can reach your webhook. Committing the first real case and watching its event arrive is the last step.
You need:
- Your sandbox credentials from onboarding:
a key ID
pk_<org>_test_…and the HMAC secret shown once at issuance. - Node 18+ (the samples use the built-in
fetch; the same helper exists in six other languages). - For step 3, a callback URL registered by the Fieldproof team for your
sandbox (
https, public IP). If you do not have one yet, skip step 3 and use the poll API in step 4.
The sandbox base URL is https://alpha-gig.fluxusforge.in; it accepts test
keys only.
0. Save the signing helper
Every request is HMAC-signed with an X-Fieldproof-Timestamp (unix
milliseconds) and an X-Fieldproof-Signature (lowercase hex) over
METHOD\nPATH\nTIMESTAMP\nRAW_BODY — full spec.
Save this as fieldproof.js; the rest of the quickstart imports it.
const crypto = require("node:crypto");
const BASE = process.env.FP_BASE_URL || "https://alpha-gig.fluxusforge.in"; // sandbox; production: https://gig.fluxusforge.in
const KEY_ID = process.env.FP_KEY_ID; // pk_<org>_test_<hex>
const SECRET = process.env.FP_SECRET; // shown once at issuance — keep it in your secrets manager
/**
* Build the auth headers for ONE request.
* method — uppercase HTTP method ("GET", "POST")
* path — the request path exactly as sent, percent-encoded, WITHOUT the query string
* rawBody — the exact string you will transmit ("" for GET)
*/
function signedHeaders(method, path, rawBody = "") {
const timestamp = Date.now().toString(); // unix milliseconds
const canonical = `${method}\n${path}\n${timestamp}\n${rawBody}`;
const signature = crypto.createHmac("sha256", SECRET)
.update(canonical, "utf8")
.digest("hex"); // lowercase hex
return {
"Authorization": `Bearer ${KEY_ID}`,
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": timestamp,
"X-Fieldproof-Signature": signature,
};
}
/**
* Signed call. `pathWithQuery` may include a query string (?dryRun=1) —
* it is stripped for signing but kept in the URL. Path parameters must
* already be percent-encoded (encodeURIComponent).
*/
async function fieldproof(method, pathWithQuery, bodyObj) {
const path = pathWithQuery.split("?")[0];
const rawBody = bodyObj === undefined ? "" : JSON.stringify(bodyObj); // serialise ONCE
const res = await fetch(BASE + pathWithQuery, {
method,
headers: signedHeaders(method, path, rawBody),
body: rawBody === "" ? undefined : rawBody, // send the SAME string you signed
});
return { status: res.status, requestId: res.headers.get("x-request-id"), json: await res.json() };
}
module.exports = { fieldproof, signedHeaders };
export FP_KEY_ID=pk_yourorg_test_… # from onboarding
export FP_SECRET=… # shown once at issuance
1. Verify your credentials — GET /health
const { fieldproof } = require("./fieldproof");
async function main() {
const health = await fieldproof("GET", "/api/v1/partner/health");
console.log(health.status, health.json);
// → 200 { ok: true, lenderCode: "YOURORG", env: "test", serverTime: "2026-08-18T09:31:00.000Z" }
// steps 2–4 go here
}
main().catch((e) => { console.error(e); process.exit(1); });
Run it with node quickstart.js. The snippets in steps 2–4 belong inside
main(), after the health check — the complete file
is at the bottom of this page.
/health is flat — no data envelope — and lenderCode is your organisation
code, so a 200 proves the key, secret, host and clock are all right. If not:
| You got | Fix |
|---|---|
401 UNAUTHORIZED | Headers missing, key ID wrong or revoked, or a live key on the sandbox (test keys only here). Check the error.message. |
401 SIGNATURE_INVALID | Point the same code at the signature debugger — it echoes the server-side canonical string. |
401 TIMESTAMP_SKEW | Your clock is more than 5 minutes off, or you sent seconds instead of milliseconds. |
429 AUTH_LOCKED | 10 bad signatures in 5 minutes locked the key for 15 minutes. Wait Retry-After seconds; debug with the echo endpoint, not retries. |
2. Push a case — dry run first
POST /api/v1/partner/cases?dryRun=1 runs full validation, writes nothing
and emits nothing, and returns the same response shape a commit does, so you
see what would happen before it happens. Five fields are required per row:
sourceLoanNumber, borrower.name, borrower.phone,
borrower.address.pincode, loan.outstandingAmount (whole rupees). Everything
else is optional (data contract).
const batch = {
rows: [{
sourceLoanNumber: "LN-2026-0001", // your stable loan ID, unique in your org
borrower: {
name: "Test Borrower",
phone: "9876543210", // Indian mobile; +91 / 91 / 0 prefixes are stripped
address: { line1: "12 MG Road", city: "Bengaluru", pincode: "560001" },
},
loan: { outstandingAmount: 12500, dpd: 45, emiAmount: 2500 }, // INR, whole rupees
}],
};
const dry = await fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch);
console.log(dry.status, dry.json);
{
"success": true,
"dryRun": true,
"batchId": "66c2f0a1e4b0c8d9f0a1b2c3",
"idempotentReplay": false,
"summary": { "totalRowsInCsv": 1, "validRows": 1, "created": 1, "updatedSnapshot": 0, "unchanged": 0, "flaggedNeedsReview": 0, "errored": 0 },
"errors": []
}
Rows fail individually: a bad row lands in errors[] as
{ index, sourceLoanNumber, reason } and the rest of the batch is unaffected
(errors is capped at 50 entries; summary.errored is the true count). Only an
envelope-level problem — malformed JSON, rows missing, more than 20,000 rows
— fails the whole request (422 VALIDATION_FAILED / 413 TOO_MANY_ROWS).
Iterate on the dry run until errored is 0.
:::note Dry runs count
POST /cases is limited to 10 batches per hour per key, and dry runs and
idempotent replays count toward that. Validate a representative batch, not
every row one at a time.
:::
3. Prove we can reach you — POST /webhooks/test
const ping = await fieldproof("POST", "/api/v1/partner/webhooks/test", {});
console.log(ping.status, ping.json);
// → 200 { success: true, delivered: true, statusCode: 200, latencyMs: 412, error: null, bodyExcerpt: "{\"received\":true}" }
This fires a signed ping event synchronously at your registered callback
and reports what happened. delivered: false with a statusCode means your
endpoint answered non-2xx; with an error string it means we could not
connect (private IP, redirect, timeout over 10 s, TLS). 422 CALLBACK_NOT_CONFIGURED means no active callback URL is registered for your
sandbox yet.
Your receiver must verify the signature exactly as it will for real events —
HMAC-SHA256("POST\n<your callback path>\n<X-Fieldproof-Timestamp>\n<raw body>", secret),
callback path without any query string, over the raw bytes — and answer 2xx
within 10 s. The delivery carries X-Fieldproof-Event, X-Fieldproof-Delivery
(the eventId), X-Fieldproof-Timestamp and X-Fieldproof-Signature (plus
the legacy X-Quikkred-* duplicates). The ping envelope looks like a real
event but carries no loan:
{
"eventId": "evt_ping_9f2c81a4d0b1e6a2",
"type": "ping",
"occurredAt": "2026-08-18T09:31:05.000Z",
"version": 1,
"lenderCode": "YOURORG",
"sourceLoanNumber": null,
"sequence": null,
"payload": { "message": "…" }
}
A receiver that requires sourceLoanNumber or sequence will reject the ping
before it verifies anything — tolerate null there. Full receiver contract and
copy-paste receivers: webhooks.
4. Commit the case and watch the event arrive
Drop ?dryRun=1. Optionally send an Idempotency-Key header (any string,
scoped to your org, 24 h) so a network retry returns the original response
verbatim with X-Idempotent-Replay: true.
const commit = await fieldproof("POST", "/api/v1/partner/cases", batch);
console.log(commit.status, commit.json.summary);
// → 200 { totalRowsInCsv: 1, validRows: 1, created: 1, updatedSnapshot: 0, unchanged: 0, flaggedNeedsReview: 0, errored: 0 }
Within seconds your callback receives a signed case.received event
(case.received, payment.collected and case.closed are delivered by
default; other types are opt-in via the Fieldproof team):
{
"eventId": "evt_9f2c81a4d0b1e6a2c3d4e5f6",
"type": "case.received",
"occurredAt": "2026-08-18T09:31:10.000Z",
"version": 1,
"lenderCode": "YOURORG",
"sourceLoanNumber": "LN-2026-0001",
"sequence": 1,
"payload": { "sourceLoanNumber": "LN-2026-0001", "caseId": "CC-2026-3F9A1C7B2E", "status": "pending" }
}
Dedupe on eventId (delivery is at-least-once) and apply events whose
sequence is greater than your watermark for that (sourceLoanNumber, caseId) — gaps are normal and never a reason to wait.
Re-sending the identical batch is a no-op (idempotentReplay: true, same
summary); re-sending a row for an existing active loan updates it in place
(reliability).
No callback yet? The same events are available by polling — every type, regardless of webhook subscription:
const feed = await fieldproof("GET", "/api/v1/partner/cases/updates?limit=50");
console.log(feed.json); // { success: true, events: [ …envelopes, oldest first ], nextCursor: "…", hasMore: false }
const one = await fieldproof("GET", `/api/v1/partner/cases/${encodeURIComponent("LN-2026-0001")}`);
console.log(one.json.case.status); // "pending"
:::tip What happens next in the sandbox
There are no simulated field agents. Assignment, visits and static-QR payment
verification are driven by the Fieldproof team on request ([email protected])
so you can see case.assigned, visit.completed, payment.collected and
case.closed land on your endpoint. Everything else you can drive yourself:
push, dry run, webhooks/test, delivery replay, polling, recall, and — for
the org-gateway payment mode — payments/confirm, which does emit
payment.collected / case.closed.
:::
The complete quickstart.js
Steps 1–4 assembled into one file, ready to run next to fieldproof.js:
const { fieldproof } = require("./fieldproof");
async function main() {
// 1. Verify your credentials
const health = await fieldproof("GET", "/api/v1/partner/health");
console.log("health:", health.status, health.json);
if (health.status !== 200) return; // fix auth first — see the table under step 1
// 2. Dry-run push (validates, writes nothing, emits nothing)
const batch = {
rows: [{
sourceLoanNumber: "LN-2026-0001",
borrower: {
name: "Test Borrower",
phone: "9876543210",
address: { line1: "12 MG Road", city: "Bengaluru", pincode: "560001" },
},
loan: { outstandingAmount: 12500, dpd: 45, emiAmount: 2500 },
}],
};
const dry = await fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch);
console.log("dryRun:", dry.status, dry.json);
if (dry.status !== 200 || dry.json.summary.errored > 0) return; // iterate until errored is 0
// 3. Prove we can reach your callback (skip if none is registered yet → 422 CALLBACK_NOT_CONFIGURED)
const ping = await fieldproof("POST", "/api/v1/partner/webhooks/test", {});
console.log("webhooks/test:", ping.status, ping.json);
// 4. Commit — your callback receives case.received within seconds
const commit = await fieldproof("POST", "/api/v1/partner/cases", batch);
console.log("commit:", commit.status, commit.json.summary);
// No callback? Poll instead — every event type, oldest first
const feed = await fieldproof("GET", "/api/v1/partner/cases/updates?limit=50");
console.log("feed:", feed.json.events.length, "event(s), nextCursor:", feed.json.nextCursor);
const one = await fieldproof("GET", `/api/v1/partner/cases/${encodeURIComponent("LN-2026-0001")}`);
console.log("case:", one.json.case.status); // "pending"
}
main().catch((e) => { console.error(e); process.exit(1); });
Where to go next
- Updating your LMS from our events — turn webhooks into loan records
- Data contract — every field you can send
- Webhooks — build a correct receiver
- Payments — pick how money reaches you
- Go-live checklist — what we verify together