Skip to main content

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.

fieldproof.js (Node 18+, no dependencies)
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​

quickstart.js
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 gotFix
401 UNAUTHORIZEDHeaders missing, key ID wrong or revoked, or a live key on the sandbox (test keys only here). Check the error.message.
401 SIGNATURE_INVALIDPoint the same code at the signature debugger — it echoes the server-side canonical string.
401 TIMESTAMP_SKEWYour clock is more than 5 minutes off, or you sent seconds instead of milliseconds.
429 AUTH_LOCKED10 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);
200 — dry run
{
"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:

What your callback receives
{
"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:

quickstart.js (complete)
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​