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
| Part | Format | Where it goes |
|---|---|---|
| Key ID | pk_<org>_test_<hex> (sandbox) or pk_<org>_live_<hex> (production) | Authorization: Bearer … on every request |
| HMAC secret | Opaque string, shown once at issuance | Your 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 ) )
| Component | Rule | Common mistake |
|---|---|---|
METHOD | Uppercase (GET, POST) | lowercase |
PATH | The 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 |
TIMESTAMP | The 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_BODY | The exact bytes you transmit; the empty string for GET | re-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
401always 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.
| Request | RAW_BODY | Expected 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.
- Node.js
- Python
- PHP
- Java
- Go
- C#
- cURL
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); });
}
import hashlib
import hmac
import json
import os
import time
from urllib.parse import quote
import requests
BASE = os.environ.get("FP_BASE_URL", "https://alpha-gig.fluxusforge.in") # sandbox; production: https://gig.fluxusforge.in
KEY_ID = os.environ["FP_KEY_ID"] # pk_<org>_test_<hex>
SECRET = os.environ["FP_SECRET"] # shown once at issuance — keep it in your secrets manager
def signed_headers(method: str, path: str, raw_body: bytes = b"") -> dict:
"""Auth headers for ONE request.
method — uppercase HTTP method
path — request path exactly as sent, percent-encoded, WITHOUT the query string
raw_body — the exact bytes you will transmit (b"" for GET)
"""
timestamp = str(int(time.time() * 1000)) # unix milliseconds
canonical = f"{method}\n{path}\n{timestamp}\n".encode("utf-8") + raw_body
signature = hmac.new(SECRET.encode("utf-8"), canonical, hashlib.sha256).hexdigest() # lowercase hex
return {
"Authorization": f"Bearer {KEY_ID}",
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": timestamp,
"X-Fieldproof-Signature": signature,
}
def fieldproof(method: str, path_with_query: str, body=None):
"""Signed call. The query string (?dryRun=1) is stripped for signing but kept
in the URL. Path parameters must already be percent-encoded (quote(x, safe=""))."""
path = path_with_query.split("?", 1)[0]
raw_body = b"" if body is None else json.dumps(body, separators=(",", ":")).encode("utf-8") # serialise ONCE
resp = requests.request(
method,
BASE + path_with_query,
headers=signed_headers(method, path, raw_body),
data=raw_body or None, # send the SAME bytes you signed — never use json=
timeout=30,
)
return resp.status_code, resp.headers.get("X-Request-Id"), resp.json()
if __name__ == "__main__":
# 1. Auth smoke test
print(fieldproof("GET", "/api/v1/partner/health"))
# → (200, '<x-request-id>', {'ok': True, 'lenderCode': 'YOURORG', 'env': 'test', 'serverTime': '…'})
# 2. Push one case (dry run: validates, writes nothing, emits nothing)
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},
}]
}
print(fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch))
# 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
print(fieldproof("GET", "/api/v1/partner/cases/" + quote("LN/001", safe="")))
<?php
$BASE = getenv('FP_BASE_URL') ?: 'https://alpha-gig.fluxusforge.in'; // sandbox; production: https://gig.fluxusforge.in
$KEY_ID = getenv('FP_KEY_ID'); // pk_<org>_test_<hex>
$SECRET = getenv('FP_SECRET'); // shown once at issuance — keep it in your secrets manager
/**
* Auth headers for ONE request.
* $method — uppercase HTTP method
* $path — request path exactly as sent, percent-encoded, WITHOUT the query string
* $rawBody — the exact string you will transmit ('' for GET)
*/
function signedHeaders(string $method, string $path, string $rawBody = ''): array {
global $KEY_ID, $SECRET;
$timestamp = (string) (int) round(microtime(true) * 1000); // unix milliseconds
$canonical = "$method\n$path\n$timestamp\n$rawBody";
$signature = hash_hmac('sha256', $canonical, $SECRET); // lowercase hex
return [
"Authorization: Bearer $KEY_ID",
"Content-Type: application/json",
"X-Fieldproof-Timestamp: $timestamp",
"X-Fieldproof-Signature: $signature",
];
}
/**
* Signed call. The query string (?dryRun=1) is stripped for signing but kept in
* the URL. Path parameters must already be percent-encoded (rawurlencode()).
* Returns [status, requestId, decodedJson].
*/
function fieldproof(string $method, string $pathWithQuery, ?array $body = null): array {
global $BASE;
$path = explode('?', $pathWithQuery, 2)[0];
$rawBody = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // serialise ONCE
$ch = curl_init($BASE . $pathWithQuery);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => signedHeaders($method, $path, $rawBody),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 30,
]);
if ($rawBody !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $rawBody); // send the SAME string you signed
}
$raw = curl_exec($ch);
if ($raw === false) { throw new RuntimeException(curl_error($ch)); }
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$headers = substr($raw, 0, $headerSize);
$requestId = preg_match('/^x-request-id:\s*(\S+)/im', $headers, $m) ? $m[1] : null;
return [$status, $requestId, json_decode(substr($raw, $headerSize), true)];
}
// 1. Auth smoke test
[$status, $requestId, $health] = fieldproof('GET', '/api/v1/partner/health');
echo "$status ", json_encode($health), "\n";
// → 200 {"ok":true,"lenderCode":"YOURORG","env":"test","serverTime":"…"}
// 2. Push one case (dry run: validates, writes nothing, emits nothing)
$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],
]],
];
[$status, $requestId, $result] = fieldproof('POST', '/api/v1/partner/cases?dryRun=1', $batch);
echo "$status ", json_encode($result), "\n";
// 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
[$status, $requestId, $case] = fieldproof('GET', '/api/v1/partner/cases/' . rawurlencode('LN/001'));
echo "$status ", json_encode($case), "\n";
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class FieldproofClient {
static final String BASE = System.getenv().getOrDefault("FP_BASE_URL", "https://alpha-gig.fluxusforge.in"); // sandbox; production: https://gig.fluxusforge.in
static final String KEY_ID = System.getenv("FP_KEY_ID"); // pk_<org>_test_<hex>
static final String SECRET = System.getenv("FP_SECRET"); // shown once at issuance — keep it in your secrets manager
static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
/**
* Auth headers for ONE request.
* method — uppercase HTTP method
* path — request path exactly as sent, percent-encoded, WITHOUT the query string
* rawBody — the exact string you will transmit ("" for GET)
*/
static Map<String, String> signedHeaders(String method, String path, String rawBody) throws Exception {
String timestamp = Long.toString(System.currentTimeMillis()); // unix milliseconds
String canonical = method + "\n" + path + "\n" + timestamp + "\n" + rawBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); // lowercase hex
Map<String, String> h = new LinkedHashMap<>();
h.put("Authorization", "Bearer " + KEY_ID);
h.put("Content-Type", "application/json");
h.put("X-Fieldproof-Timestamp", timestamp);
h.put("X-Fieldproof-Signature", signature);
return h;
}
/**
* Signed call. The query string (?dryRun=1) is stripped for signing but kept in the URL.
* Path parameters must already be percent-encoded (see pathParam()).
*/
static HttpResponse<String> fieldproof(String method, String pathWithQuery, String rawBody) throws Exception {
String path = pathWithQuery.split("\\?", 2)[0];
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + pathWithQuery))
.timeout(Duration.ofSeconds(30))
.method(method, rawBody.isEmpty()
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8)); // send the SAME string you signed
signedHeaders(method, path, rawBody).forEach(b::header);
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
/** Percent-encode one path segment: "LN/001" → "LN%2F001". */
static String pathParam(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
public static void main(String[] args) throws Exception {
// 1. Auth smoke test
HttpResponse<String> health = fieldproof("GET", "/api/v1/partner/health", "");
System.out.println(health.statusCode() + " " + health.headers().firstValue("x-request-id").orElse("") + " " + health.body());
// → 200 <x-request-id> {"ok":true,"lenderCode":"YOURORG","env":"test","serverTime":"…"}
// 2. Push one case (dry run: validates, writes nothing, emits nothing).
// Build the JSON with your library of choice; whatever string you produce is what gets signed AND sent.
String 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}}]}""";
HttpResponse<String> push = fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch);
System.out.println(push.statusCode() + " " + push.body());
// 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
HttpResponse<String> one = fieldproof("GET", "/api/v1/partner/cases/" + pathParam("LN/001"), "");
System.out.println(one.statusCode() + " " + one.body());
}
}
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
var (
baseURL = envOr("FP_BASE_URL", "https://alpha-gig.fluxusforge.in") // sandbox; production: https://gig.fluxusforge.in
keyID = os.Getenv("FP_KEY_ID") // pk_<org>_test_<hex>
secret = os.Getenv("FP_SECRET") // shown once at issuance
)
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// signedHeaders builds the auth headers for ONE request.
//
// method — uppercase HTTP method
// path — request path exactly as sent, percent-encoded, WITHOUT the query string
// rawBody — the exact bytes you will transmit (nil for GET)
func signedHeaders(method, path string, rawBody []byte) http.Header {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) // unix milliseconds
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(method + "\n" + path + "\n" + timestamp + "\n"))
mac.Write(rawBody)
signature := hex.EncodeToString(mac.Sum(nil)) // lowercase hex
h := http.Header{}
h.Set("Authorization", "Bearer "+keyID)
h.Set("Content-Type", "application/json")
h.Set("X-Fieldproof-Timestamp", timestamp)
h.Set("X-Fieldproof-Signature", signature)
return h
}
// fieldproof makes a signed call. The query string (?dryRun=1) is stripped for
// signing but kept in the URL. Path parameters must already be percent-encoded
// (url.PathEscape). Returns status, X-Request-Id, response body.
func fieldproof(method, pathWithQuery string, body interface{}) (int, string, []byte, error) {
path := strings.SplitN(pathWithQuery, "?", 2)[0]
var rawBody []byte
if body != nil {
b, err := json.Marshal(body) // serialise ONCE
if err != nil {
return 0, "", nil, err
}
rawBody = b
}
req, err := http.NewRequest(method, baseURL+pathWithQuery, bytes.NewReader(rawBody)) // send the SAME bytes you signed
if err != nil {
return 0, "", nil, err
}
req.Header = signedHeaders(method, path, rawBody)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, "", nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
return resp.StatusCode, resp.Header.Get("X-Request-Id"), out, err
}
func main() {
// 1. Auth smoke test
status, reqID, out, err := fieldproof("GET", "/api/v1/partner/health", nil)
if err != nil {
panic(err)
}
fmt.Println(status, reqID, string(out))
// → 200 <x-request-id> {"ok":true,"lenderCode":"YOURORG","env":"test","serverTime":"…"}
// 2. Push one case (dry run: validates, writes nothing, emits nothing)
batch := map[string]interface{}{
"rows": []map[string]interface{}{{
"sourceLoanNumber": "LN-2026-0001",
"borrower": map[string]interface{}{
"name": "Test Borrower",
"phone": "9876543210",
"address": map[string]interface{}{"line1": "12 MG Road", "city": "Bengaluru", "pincode": "560001"},
},
"loan": map[string]interface{}{"outstandingAmount": 12500, "dpd": 45},
}},
}
status, reqID, out, err = fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch)
if err != nil {
panic(err)
}
fmt.Println(status, reqID, string(out))
// 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
status, reqID, out, err = fieldproof("GET", "/api/v1/partner/cases/"+url.PathEscape("LN/001"), nil)
if err != nil {
panic(err)
}
fmt.Println(status, reqID, string(out))
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class FieldproofClient
{
static readonly string BaseUrl = Environment.GetEnvironmentVariable("FP_BASE_URL") ?? "https://alpha-gig.fluxusforge.in"; // sandbox; production: https://gig.fluxusforge.in
static readonly string KeyId = Environment.GetEnvironmentVariable("FP_KEY_ID")!; // pk_<org>_test_<hex>
static readonly string Secret = Environment.GetEnvironmentVariable("FP_SECRET")!; // shown once at issuance — keep it in your secrets manager
static readonly HttpClient Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
/// Auth headers for ONE request.
/// method — uppercase HTTP method
/// path — request path exactly as sent, percent-encoded, WITHOUT the query string
/// rawBody — the exact string you will transmit ("" for GET)
static Dictionary<string, string> SignedHeaders(string method, string path, string rawBody = "")
{
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); // unix milliseconds
string canonical = $"{method}\n{path}\n{timestamp}\n{rawBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
string signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); // lowercase hex
return new Dictionary<string, string>
{
["Authorization"] = $"Bearer {KeyId}",
["X-Fieldproof-Timestamp"] = timestamp,
["X-Fieldproof-Signature"] = signature,
// Content-Type is set on the HttpContent below (it is a content header in .NET)
};
}
/// Signed call. The query string (?dryRun=1) is stripped for signing but kept in the URL.
/// Path parameters must already be percent-encoded (Uri.EscapeDataString).
static async Task<(int Status, string? RequestId, string Body)> Fieldproof(string method, string pathWithQuery, object? body = null)
{
string path = pathWithQuery.Split('?', 2)[0];
string rawBody = body is null ? "" : JsonSerializer.Serialize(body); // serialise ONCE
var req = new HttpRequestMessage(new HttpMethod(method), BaseUrl + pathWithQuery);
foreach (var (name, value) in SignedHeaders(method, path, rawBody))
req.Headers.TryAddWithoutValidation(name, value);
if (rawBody.Length > 0)
{
req.Content = new StringContent(rawBody, Encoding.UTF8); // send the SAME string you signed
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
var res = await Http.SendAsync(req);
res.Headers.TryGetValues("X-Request-Id", out var ids);
return ((int)res.StatusCode, ids is null ? null : string.Join(",", ids), await res.Content.ReadAsStringAsync());
}
static async Task Main()
{
// 1. Auth smoke test
var health = await Fieldproof("GET", "/api/v1/partner/health");
Console.WriteLine($"{health.Status} {health.RequestId} {health.Body}");
// → 200 <x-request-id> {"ok":true,"lenderCode":"YOURORG","env":"test","serverTime":"…"}
// 2. Push one case (dry run: validates, writes nothing, emits nothing)
var batch = new
{
rows = new[]
{
new
{
sourceLoanNumber = "LN-2026-0001",
borrower = new
{
name = "Test Borrower",
phone = "9876543210",
address = new { line1 = "12 MG Road", city = "Bengaluru", pincode = "560001" },
},
loan = new { outstandingAmount = 12500, dpd = 45 },
},
},
};
var push = await Fieldproof("POST", "/api/v1/partner/cases?dryRun=1", batch);
Console.WriteLine($"{push.Status} {push.Body}");
// 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
var one = await Fieldproof("GET", "/api/v1/partner/cases/" + Uri.EscapeDataString("LN/001"));
Console.WriteLine($"{one.Status} {one.Body}");
}
}
#!/usr/bin/env bash
set -eo pipefail
BASE="${FP_BASE_URL:-https://alpha-gig.fluxusforge.in}" # sandbox; production: https://gig.fluxusforge.in
KEY_ID="${FP_KEY_ID:?export FP_KEY_ID first}" # pk_<org>_test_<hex>
SECRET="${FP_SECRET:?export FP_SECRET first}" # shown once at issuance
# fp_call METHOD PATH_WITH_QUERY [RAW_BODY]
# PATH_WITH_QUERY — path as sent (percent-encoded), optionally with ?query
# RAW_BODY — exact JSON string to send (omit for GET)
fp_call() {
local method="$1" path_with_query="$2" raw_body="${3:-}"
local path="${path_with_query%%\?*}" # sign the path WITHOUT the query string
local ts=$(( $(date +%s) * 1000 )) # unix milliseconds (whole-second precision is fine)
local sig
sig=$(printf '%s\n%s\n%s\n%s' "$method" "$path" "$ts" "$raw_body" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= *//') # lowercase hex
curl -sS -X "$method" "$BASE$path_with_query" \
-H "Authorization: Bearer $KEY_ID" \
-H "Content-Type: application/json" \
-H "X-Fieldproof-Timestamp: $ts" \
-H "X-Fieldproof-Signature: $sig" \
${raw_body:+--data-binary "$raw_body"} # send the SAME string you signed
echo
}
# urlencode STRING — percent-encode ONE path segment ("LN/001" → "LN%2F001")
urlencode() {
local LC_ALL=C s="$1" out="" i c
for (( i = 0; i < ${#s}; i++ )); do
c="${s:i:1}"
case "$c" in
[a-zA-Z0-9.~_-]) out+="$c" ;;
*) out+=$(printf '%%%02X' "$(( $(printf '%d' "'$c") & 255 ))") ;;
esac
done
printf '%s' "$out"
}
# 1. Auth smoke test
fp_call GET /api/v1/partner/health
# → {"ok":true,"lenderCode":"YOURORG","env":"test","serverTime":"…"}
# 2. Push one case (dry run: validates, writes nothing, emits nothing)
BODY='{"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}}]}'
fp_call POST "/api/v1/partner/cases?dryRun=1" "$BODY"
# 3. A path parameter that needs encoding: loan "LN/001" → /cases/LN%2F001
fp_call GET "/api/v1/partner/cases/$(urlencode 'LN/001')"
Notes for the shell version: use printf '%s' (never echo, which appends a
newline and may interpret escapes); the sed strips the (stdin)= prefix
that OpenSSL prints (LibreSSL on macOS prints bare hex — the same command
works on both); and -hmac "$SECRET" exposes the secret in the process list —
fine for a one-off from your terminal, not for a shared host.
:::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].
| Status | error.code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | Missing 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 |
| 401 | SIGNATURE_INVALID | Signature does not match — debug it |
| 401 | TIMESTAMP_SKEW | X-Fieldproof-Timestamp more than 5 minutes from server time |
| 403 | LENDER_INACTIVE | Your organisation is suspended |
| 403 | SANDBOX_ONLY | A sandbox-only endpoint (/debug/echo-signature) called with a live key or on production |
| 429 | AUTH_LOCKED | 10 signature failures within 5 minutes → key locked for 15 minutes; Retry-After gives the seconds remaining |
| 429 | RATE_LIMITED | 60 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.