Skip to main content

Security & compliance commitments

Written for your security questionnaire. Everything on this page describes how the Partner API actually behaves today; where a control is your responsibility rather than ours, it says so.

Transport​

  • Both API hosts are HTTPS-only: sandbox https://alpha-gig.fluxusforge.in, production https://gig.fluxusforge.in (the legacy *.quikkred.in hosts remain valid with the same keys).
  • Webhooks are delivered only to https:// callback URLs on a public IP. Private and loopback addresses are refused, redirects are not followed, at most 64 KB of your response is read, and the connection is cut after 10 seconds. Do not put a query string in your callback URL — the signature covers the URL path only.

Request authentication (HMAC)​

Every request is signed; there are no unauthenticated endpoints.

HeaderValue
AuthorizationBearer <apiKeyId> — pk_<org>_test_<hex> or pk_<org>_live_<hex>
X-Fieldproof-TimestampUnix milliseconds; rejected outside ±5 minutes of server time (401 TIMESTAMP_SKEW)
X-Fieldproof-SignatureLowercase hex HMAC over METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + RAW_BODY
  • PATH is the request path exactly as transmitted (percent-encoded, no query string); RAW_BODY is the exact bytes sent (empty string on GET). Full recipe: signing requests.
  • Keys are environment-locked: a test key on production or a live key on the sandbox is 401 UNAUTHORIZED, so a mis-pointed deployment fails loudly and immediately.
  • The key id identifies; only the signature proves. A leaked key id without its secret cannot make a valid call.
  • Authentication runs before schema validation — an unauthenticated caller never sees your request shapes echoed back in validation errors.

Credential handling on your side​

  • The HMAC secret is shown exactly once, at issuance. Store it in a secrets manager; never in source, config repos, tickets, logs or anything shipped to a browser or mobile app. Signing happens on your servers only.
  • The same secret verifies inbound webhooks, so your webhook receiver and your API client read from the same secret store.
  • Sandbox and production credentials are separate and nothing carries over between them (keys, webhook URL, payment artefacts, IP allowlist are all configured again for production) — a sandbox leak can never touch production traffic.

Rotation and revocation — how it really works​

Rotation is zero-downtime for your requests, but not automatically for your webhook receiver:

  1. You request a rotation ([email protected]).
  2. We issue a new secret. From that moment, webhooks are signed with the new secret; your API requests are accepted with the old or the new secret.
  3. You deploy the new secret and confirm.
  4. We retire the old secret.

:::warning Deploy multi-secret verification before you ask for a rotation Because outbound webhooks switch to the new secret immediately, a receiver that checks against a single secret will start rejecting deliveries the moment step 2 happens (they retry on the ladder and are replayable, so nothing is lost — but you would be paged for it). Verify against every secret you have not yet retired, as in the sample below. :::

Revocation takes effect within 60 seconds (key-cache TTL). Your integration stops until a replacement key is issued — prefer rotation unless the secret is definitely exposed.

Webhook verification that survives rotation (Node)
const crypto = require("crypto");

// Every secret you have not retired yet, newest first. A rotation switches
// our webhook signatures to the new secret immediately.
const SECRETS = [process.env.FIELDPROOF_SECRET_NEW, process.env.FIELDPROOF_SECRET_OLD]
.filter(Boolean);

// The pathname of the callback URL you registered — no host, no query string.
const CALLBACK_PATH = "/webhooks/fieldproof";

/**
* @param {Record<string,string|undefined>} headers lower-cased header map
* @param {Buffer} rawBody the body bytes as received, unparsed
*/
function verifyWebhook(headers, rawBody) {
const ts = headers["x-fieldproof-timestamp"] || headers["x-quikkred-timestamp"];
const sig = (headers["x-fieldproof-signature"] || headers["x-quikkred-signature"] || "").toLowerCase();
if (!ts || !sig) return false;
if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false; // stale or replayed

const canonical = Buffer.concat([
Buffer.from(`POST\n${CALLBACK_PATH}\n${ts}\n`, "utf8"),
rawBody,
]);
const received = Buffer.from(sig, "utf8");

return SECRETS.some((secret) => {
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(canonical).digest("hex"),
"utf8",
);
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
});
}

module.exports = { verifyWebhook };

Feed it the raw request body (for example express.raw({ type: "application/json" })) — a re-serialised JSON object will not match.

Automatic protections​

ProtectionBehaviour
Signature-failure lockout10 invalid signatures within 300 s lock the key for 900 s. Every call then returns 429 AUTH_LOCKED with Retry-After = seconds remaining.
Environment lockingTest keys are rejected on production and live keys on the sandbox (401 UNAUTHORIZED).
Replay windowRequests with a timestamp outside ±5 minutes are rejected (401 TIMESTAMP_SKEW).
Rate limiting60 requests/min per key; 10 POST /cases batches/hour per key (429 RATE_LIMITED + Retry-After).
Sandbox-only toolingPOST /debug/echo-signature (which deliberately accepts a wrong signature so you can inspect the canonical string) refuses live keys and the production host (403 SANDBOX_ONLY).

IP allowlist (yours) and egress IPs (ours)​

  • Your keys can optionally be restricted to your egress addresses — plain IPv4 addresses or CIDR ranges — set at onboarding and configured separately for sandbox and production. The client IP is taken from CF-Connecting-IP, falling back to the last X-Forwarded-For hop; a request from outside the allowlist is 401 UNAUTHORIZED.
  • We do not publish static egress IPs for our webhook deliveries, pull fetches or mint-link calls. Authenticate inbound traffic with the HMAC signature (webhooks) or your registered Authorization value (mint URL) rather than by source address. If your network policy requires source IPs, contact [email protected] before go-live.

Tenant isolation​

Every read and write is scoped to the organisation that owns the key. GET /cases/{sourceLoanNumber} for a loan that belongs to another organisation returns 404 CASE_NOT_FOUND — the same answer as for a loan that does not exist — so a key cannot be used to probe another organisation's book. Webhook and poll events carry only your own lenderCode and loans.

Money​

  • We never hold or route borrower funds. In Mode A the borrower pays into your account via the static UPI QR / UPI ID / bank details you provide; in Mode B the borrower pays a link minted by your gateway. See how money flows.
  • Cash is never accepted for your cases: the partner app blocks it, and payments/confirm rejects mode: "cash" (422 VALIDATION_FAILED).
  • payment.collected is emitted for booked money only — the operator-verified amount in Mode A (rejected proofs emit nothing), and the amount you confirmed in Mode B.
  • The regulatory disclosure text you provide is shown to the borrower on the agent's payment and ID screens. The platform does not issue receipts on your behalf — borrower receipts remain your system's responsibility.

Data minimisation​

  • Send only what the field visit needs. Required per row: sourceLoanNumber, borrower.name, borrower.phone, borrower.address.pincode, loan.outstandingAmount. Everything else is optional; unknown fields are ignored, so extra columns buy nothing but still leave your network — strip them before you push.
  • Event payloads carry loan identifiers, amounts, statuses and visit outcomes — not borrower name, phone or address. (The only free text is the partner's notes on visit.completed.)
  • In Mode B the mint-link request we send you carries borrowerName and borrowerPhone (10-digit or null) so your gateway can pre-fill the payment page — protect that endpoint with the Authorization value you registered.
  • Use synthetic borrower data on the sandbox; it is a separate environment with its own key, but treat it as non-production.

Durability, honestly​

  • Events are written durably immediately after the case change — not in the same database transaction. A crash in the gap between the two writes could, very rarely, lose an event. Your daily fullSnapshot: true push and GET /cases/{loan} reconciliation cover that gap; see reliability.
  • Webhook deliveries retry on a ladder (0s → 30s → 2m → 10m → 1h → 6h → 24h, seven attempts ≈ 31 hours), then park as exhausted and stay replayable for 30 days. The poll API is an independent second lane to the same events.

Monitoring is yours​

The platform does not send alerts to your technical contact — not for a key lockout, exhausted deliveries, or a suspended callback. Our internal ops are alerted; you should watch your own side:

  • GET /api/v1/partner/health — credential + environment smoke test.
  • GET /api/v1/partner/webhooks/deliveries?status=exhausted — deliveries that ran out of retries (delivery log).
  • Your own alerting on 429 AUTH_LOCKED / 401 rates from your client.

Contact​

One address for everything — integration questions, key rotation and revocation, egress-IP requirements, and security disclosures: [email protected]. Include the X-Request-Id of any call you are asking about.