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.
- Order of checks — (1) the
Authorization, timestamp and signature headers must be present (401otherwise); (2) a body whoseContent-Typeis notapplication/jsongets415 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 a413,415or malformed-JSON422can come back before your signature is checked, but a schema error never precedes authentication — a401always 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":{"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.
- 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", 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); });
}
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", "state": "Karnataka", "pincode": "560001",
"latitude": "12.975326", "longitude": "77.606262"},
},
"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', 'state' => 'Karnataka', 'pincode' => '560001',
'latitude' => '12.975326', 'longitude' => '77.606262'],
],
'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","state":"Karnataka","pincode":"560001","latitude":"12.975326","longitude":"77.606262"}},"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", "state": "Karnataka", "pincode": "560001",
"latitude": "12.975326", "longitude": "77.606262",
},
},
"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", state = "Karnataka", pincode = "560001",
latitude = "12.975326", longitude = "77.606262" },
},
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","state":"Karnataka","pincode":"560001","latitude":"12.975326","longitude":"77.606262"}},"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 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].
| 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 |
| 415 | UNSUPPORTED_MEDIA_TYPE | A body sent with a Content-Type other than application/json. Returned before the signature is checked |
| 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 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.