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, productionhttps://gig.fluxusforge.in(the legacy*.quikkred.inhosts 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.
| Header | Value |
|---|---|
Authorization | Bearer <apiKeyId> — pk_<org>_test_<hex> or pk_<org>_live_<hex> |
X-Fieldproof-Timestamp | Unix milliseconds; rejected outside ±5 minutes of server time (401 TIMESTAMP_SKEW) |
X-Fieldproof-Signature | Lowercase hex HMAC over METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + RAW_BODY |
PATHis the request path exactly as transmitted (percent-encoded, no query string);RAW_BODYis 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:
- You request a rotation ([email protected]).
- 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.
- You deploy the new secret and confirm.
- 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.
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
| Protection | Behaviour |
|---|---|
| Signature-failure lockout | 10 invalid signatures within 300 s lock the key for 900 s. Every call then returns 429 AUTH_LOCKED with Retry-After = seconds remaining. |
| Environment locking | Test keys are rejected on production and live keys on the sandbox (401 UNAUTHORIZED). |
| Replay window | Requests with a timestamp outside ±5 minutes are rejected (401 TIMESTAMP_SKEW). |
| Rate limiting | 60 requests/min per key; 10 POST /cases batches/hour per key (429 RATE_LIMITED + Retry-After). |
| Sandbox-only tooling | POST /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 lastX-Forwarded-Forhop; a request from outside the allowlist is401 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
Authorizationvalue (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/confirmrejectsmode: "cash"(422 VALIDATION_FAILED). payment.collectedis 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
notesonvisit.completed.) - In Mode B the mint-link request we send you carries
borrowerNameandborrowerPhone(10-digit ornull) so your gateway can pre-fill the payment page — protect that endpoint with theAuthorizationvalue 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: truepush andGET /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 asexhaustedand 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/401rates 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.