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.
  • Auth runs before validation — an unauthenticated request never sees a schema error, so 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":{"pincode":"560001"}},"loan":{"outstandingAmount":12500}}]} (169 bytes)17fcb3fe88fbf59b4cd77aa82c9281d214c72d33b1c9ae1234cbc2d31d8313d1

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", pincode: "560001" },
},
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 carries an X-Request-Id header and every error uses the envelope { "success": false, "error": { "code", "message" } }. 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
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 set

Because auth runs first, a 401 never contains schema details — fix credentials before you look at your payload.

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. 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.