Skip to main content

Signing requests

Every call to the Partner API carries a Bearer key ID (identifies you) and an HMAC-SHA256 signature (proves you hold the secret — which is never transmitted). The same scheme, in reverse, signs the webhooks we send you.

Your credential​

PartFormatWhere it goes
Key IDpk_<org>_test_<hex> (sandbox) or pk_<org>_live_<hex> (production)Authorization: Bearer … on every request
HMAC secretOpaque string, shown once at issuanceYour secrets manager only. Signs your requests and verifies the webhooks we send you

Keys are environment-locked: test keys work only against the sandbox https://alpha-gig.fluxusforge.in, live keys only against production https://gig.fluxusforge.in. Using a key on the wrong host is a 401 UNAUTHORIZED, not a signature error. (The legacy hosts alpha-gig.quikkred.in / gig.quikkred.in remain valid with the same keys.)

Headers​

Authorization: Bearer <apiKeyId>
Content-Type: application/json
X-Fieldproof-Timestamp: <unix milliseconds>
X-Fieldproof-Signature: <lowercase hex>

:::note Legacy header names X-Quikkred-Timestamp / X-Quikkred-Signature (the pre-rebrand names) are accepted as aliases — integrations built against the older docs keep working unchanged. If both families are sent, the X-Fieldproof-* values win. Webhook deliveries from us carry both families with identical values. :::

The canonical string​

signature = hex( HMAC-SHA256( METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + RAW_BODY, hmacSecret ) )
ComponentRuleCommon mistake
METHODUppercase (GET, POST)lowercase
PATHThe request path exactly as sent: percent-encoded as transmitted, without the query string. /api/v1/partner/cases, never /api/v1/partner/cases?dryRun=1. A loan number LN/001 is sent — and signed — as /api/v1/partner/cases/LN%2F001.including ?dryRun=1; signing the decoded path; a proxy re-encoding the path
TIMESTAMPThe exact value of the X-Fieldproof-Timestamp header — unix time in milliseconds (13 digits today)seconds instead of ms; signing one value and sending another
RAW_BODYThe exact bytes you transmit; the empty string for GETre-serialising JSON after signing (key order or whitespace changes the bytes)

Three more rules:

  • Timestamp window — must be within ±5 minutes of server time, otherwise 401 TIMESTAMP_SKEW. Sync your clock; do not cache a timestamp across requests.
  • Hex is compared case-insensitively — the server lowercases what you send before comparing. Emit lowercase anyway.
  • Order of checks — (1) the Authorization, timestamp and signature headers must be present (401 otherwise); (2) a body whose Content-Type is not application/json gets 415 UNSUPPORTED_MEDIA_TYPE — only JSON bodies are signed over their raw bytes; (3) the body is parsed (malformed JSON → 422, too large → 413); (4) the key and signature are verified; (5) the request schema is validated. So a 413, 415 or malformed-JSON 422 can come back before your signature is checked, but a schema error never precedes authentication — a 401 always means credentials, never payload.

Test vectors​

Check your implementation offline before you touch the network. All three use the secret example-secret and the timestamp 1755500000000.

RequestRAW_BODYExpected signature
GET /api/v1/partner/health(empty)cba0c996c6a927f7f26539e54ab30f54cf8ce715d422c6ec1b73756cd0d6e0d1
GET /api/v1/partner/cases/LN%2F001 (loan LN/001)(empty)8a4a47041f0c3d94bdea1548f1b65b5621fadfad62e4fbb1cb6fdab7c9eaa0af
POST /api/v1/partner/cases{"rows":[{"sourceLoanNumber":"LN-2026-0001","borrower":{"name":"Test Borrower","phone":"9876543210","address":{"line1":"12 MG Road","city":"Bengaluru","state":"Karnataka","pincode":"560001","latitude":"12.975326","longitude":"77.606262"}},"loan":{"outstandingAmount":12500,"dpd":45}}]} (285 bytes)be172428c9da5963f8d325aa211d3327edaf80202ceb7a82c2466347fcf20cb6

The POST body is a valid one-row batch under the data contract (address, state, pincode and the borrower's coordinates, sent as strings). It is byte-for-byte what the Node and Python helpers below serialise in step 2 of their examples, and the literal string the Java and cURL samples send.

Signed-request helpers​

Each sample below is complete and runnable: a reusable function that returns the signed headers for a (method, path, rawBody) triple, plus a client wrapper and an example that calls GET /health and POST /cases?dryRun=1 with a body. They read FP_BASE_URL (defaults to the sandbox), FP_KEY_ID and FP_SECRET from the environment.

The wrappers accept a path that may carry a query string; they strip it for signing and keep it in the URL. Path parameters must be percent-encoded before you pass them in — the helper signs whatever you give it, which is exactly what goes on the wire.

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 };

// ── Example ────────────────────────────────────────────────────────────────
if (require.main === module) {
(async () => {
// 1. Auth smoke test
console.log(await fieldproof("GET", "/api/v1/partner/health"));
// → { status: 200, requestId: "<x-request-id>", json: { ok: true, lenderCode: "YOURORG", env: "test", serverTime: "…" } }

// 2. Push one case (dry run: 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", state: "Karnataka", pincode: "560001",
latitude: "12.975326", longitude: "77.606262", // the borrower's door; strings keep every decimal
},
},
loan: { outstandingAmount: 12500, dpd: 45 },
}],
};
console.log(await fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch));

// 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
console.log(await fieldproof("GET", `/api/v1/partner/cases/${encodeURIComponent("LN/001")}`));
})().catch((e) => { console.error(e); process.exit(1); });
}

:::warning Serialise once, sign, send as-is The single most common failure is signing one byte sequence and transmitting another: an HTTP library that re-encodes your object, a "pretty-print" filter, a gateway that normalises whitespace or re-encodes the path. Every helper above builds the raw string first, signs it, and hands that same string to the HTTP client. Keep that property when you adapt them. :::

When auth fails​

Every response from a Partner API route carries an X-Request-Id header and every error uses the envelope { "success": false, "error": { "code", "message" } } (a path that is not an API route gets a plain 404 without either — see errors). Quote the request ID when you write to [email protected].

Statuserror.codeMeaning
401UNAUTHORIZEDMissing headers, unknown / revoked / expired key, key used on the wrong host (test key on production or vice versa), or source IP not on your allowlist
401SIGNATURE_INVALIDSignature does not match — debug it
401TIMESTAMP_SKEWX-Fieldproof-Timestamp more than 5 minutes from server time
403LENDER_INACTIVEYour organisation is suspended
403SANDBOX_ONLYA sandbox-only endpoint (/debug/echo-signature) called with a live key or on production
415UNSUPPORTED_MEDIA_TYPEA body sent with a Content-Type other than application/json. Returned before the signature is checked
429AUTH_LOCKED10 signature failures within 5 minutes → key locked for 15 minutes; Retry-After gives the seconds remaining
429RATE_LIMITED60 requests/min per key exceeded; Retry-After header and error.retryAfter (seconds) set

Schema validation runs only after the signature passes, so a 401 never contains schema details — fix credentials before you look at your payload. (Body parsing runs earlier: malformed JSON (422), an oversized body (413) or a non-JSON Content-Type (415) can be reported before the signature is checked.)

Verifying OUR webhooks​

Identical scheme, in reverse: PATH is your callback URL's path (the pathname only — do not put a query string in your callback URL), RAW_BODY is the raw request body exactly as received, the timestamp is our X-Fieldproof-Timestamp header, and the secret is the same HMAC secret your key uses (if you hold two active keys for one environment, webhooks are signed with the secret of the key issued first). Compare with a constant-time function, and be ready to verify against more than one secret during a rotation. See webhooks for full receiver code.

Test it immediately​

GET /api/v1/partner/health is the auth smoke test — a correct signature returns your organisation code (this endpoint is flat; it does not use a data envelope):

{ "ok": true, "lenderCode": "YOURORG", "env": "test", "serverTime": "2026-08-18T09:31:00.000Z" }

Failing? The signature debugger shows you the server-side canonical string to diff against yours.