Push API
You build
- A signed HTTPS client (signing)
- A job that pushes new/changed loans (real-time or batched)
We provide
- Validation with per-row errors and warnings, dedupe, partner matching on your coordinates
case.receivedwebhooks + the full event stream- Batch idempotency (content hash) +
Idempotency-Keyreplay safety
Endpoints
All paths are under https://alpha-gig.fluxusforge.in/api/v1/partner (sandbox,
TEST keys) or https://gig.fluxusforge.in/api/v1/partner (production, LIVE keys).
| Method | Path | Purpose |
|---|---|---|
POST | /cases | Push a batch of rows. ?dryRun=1 validates without writing. |
GET | /cases/{sourceLoanNumber} | Authoritative live snapshot of one case. |
POST | /cases/{sourceLoanNumber}/recall | Withdraw a case (borrower paid you directly / restructured). Idempotent. |
Machine-readable spec: the API reference (its OpenAPI file
is /openapi/partner-v1.json on this docs site) is the canonical spec — use it
for client generation. The signed GET /api/v1/partner/openapi.json on the API
host is only a machine listing of the routes (generic response schemas, no
error codes, no auth header parameters).
Recommended rhythm
- New overdue loans — push as they cross your DPD threshold (or hourly).
- Changes — re-push the row when outstanding/contact/address/coordinates
change; it updates in place. If the loan's previous case is already closed, the
re-push opens a fresh case (new
caseId,sequenceback to 1) — use this when a borrower re-defaults after an earlier cycle. - Daily reconciliation — one
fullSnapshot: truepush of your complete active book; anything we hold that is missing from it is flagged for our operations review. - Recalls — call recall the moment a borrower settles with you directly, so no partner makes a wasted (and borrower-annoying) visit.
POST /cases — push a batch
Request
POST /api/v1/partner/cases # commit
POST /api/v1/partner/cases?dryRun=1 # validate only — nothing written, no events
Authorization: Bearer <apiKeyId>
Content-Type: application/json
X-Fieldproof-Timestamp: <unix ms>
X-Fieldproof-Signature: <lowercase hex HMAC-SHA256 over "POST\n/api/v1/partner/cases\n<ts>\n<raw body>">
Idempotency-Key: <your unique key for this batch> # optional, see below
The signed PATH is /api/v1/partner/cases — never include ?dryRun=1
in the canonical string (signing rules).
Body:
{
"fullSnapshot": false,
"rows": [ { "sourceLoanNumber": "…", "borrower": { "…": "…" }, "loan": { "…": "…" } } ]
}
| Property | Type | Rules |
|---|---|---|
rows | Row[] | Required, at least 1 row. Hard cap 20,000 (413 TOO_MANY_ROWS); keep batches ≤ 5,000 for fast responses. Row schema: data contract. |
fullSnapshot | boolean | Optional, default false. See full snapshots. |
The envelope reads only these two properties. Any other top-level key is
silently dropped, not rejected — so check the spelling of fullSnapshot: a
misspelled key means the batch is treated as a partial push. Send
fullSnapshot as a JSON boolean: the string "true" is coerced to true,
and a value that cannot be read as a boolean is a 422 VALIDATION_FAILED.
Inside a row, unknown fields are ignored
(data contract). Every row must carry the
borrower's full address and the real coordinates of that address
(borrower.address.latitude / longitude, sent as strings) — see
address and coordinates.
Idempotency-Key header
Protects a network retry of the same commit: if your client times out after we already processed the batch, resend with the same key and you get the original result instead of a second import.
- Scope: per organisation. Any string that is unique for the logical batch.
- TTL: 24 hours.
- Only 200 responses are cached. A
4xx/5xxis never replayed — fix and resend with the same key. - Ignored on
?dryRun=1— dry-runs are neither cached nor served from the cache. - No payload comparison. A replay with the same key returns the original
response body verbatim — even if you changed the rows — plus the response
header
X-Idempotent-Replay: true. Detect a replay by that header; theidempotentReplayfield inside the body is whatever the original response said. - Replays count toward the 10 batches/hour limit.
200 response
{
"success": true,
"dryRun": false,
"batchId": "66c1f0a2b3c4d5e6f7a8b9c0",
"idempotentReplay": false,
"summary": {
"totalRowsInCsv": 200,
"validRows": 197,
"created": 150,
"updatedSnapshot": 39,
"unchanged": 8,
"flaggedNeedsReview": 0,
"errored": 3,
"warned": 1
},
"errors": [
{ "index": 17, "sourceLoanNumber": "LN-2026-000140", "reason": "Row 17 (LN-2026-000140): borrower.phone does not normalize to a 10-digit Indian mobile" },
{ "index": 23, "sourceLoanNumber": "LN-2026-000146", "reason": "Row 23 (LN-2026-000146): loan.dpd must be a number or a \"lo-hi\" range (got \"n/a\")" },
{ "index": 31, "sourceLoanNumber": "LN-2026-000154", "reason": "Row 31 (LN-2026-000154): borrower.address.latitude/longitude need at least 4 decimal places (≈11 m) — got 12.97, 77.6. Send the coordinates of the door, not a rounded or city-level point" }
],
"warnings": [
{ "index": 4, "sourceLoanNumber": "LN-2026-000127", "reason": "Row 4 (LN-2026-000127): borrower.address.latitude/longitude are about 63 km from our reference point for pincode 560001 — imported, but please verify they are this borrower's address" }
]
}
| Field | Meaning |
|---|---|
dryRun | true when called with ?dryRun=1. Nothing was written and no event was emitted; the summary shows what a commit of the same batch would do. |
batchId | Our id for this batch (quote it to support). |
idempotentReplay | true when the batch content matched an already-committed batch — nothing was written again and the original summary is returned (see re-push semantics). |
summary.totalRowsInCsv | Rows received (the name is historical — it applies to JSON pushes too). |
summary.validRows | totalRowsInCsv − errored. |
summary.created | New cases opened. |
summary.updatedSnapshot | Existing active cases updated in place. |
summary.unchanged | Rows identical to what we already hold. |
summary.flaggedNeedsReview | Only with fullSnapshot: true: active cases absent from the batch that were flagged for operations review. |
summary.errored | Rows rejected — the true count, even when errors[] is capped. |
summary.warned | Rows that imported with at least one warning — the true count, even when warnings[] is capped. One row can carry several entries in warnings[]. |
errors[] | { index, sourceLoanNumber, reason } per rejected row (index is the row's position in rows, from 0). Capped at 50 entries. |
warnings[] | Same shape as errors[], for rows that were imported but need your attention — a pin far from its pincode, or (during a coordinate grace period) missing address fields or coordinates. Capped at 50 entries. See per-row warnings. |
Rows fail individually — one bad phone number never blocks the batch.
Address and coordinate problems are per-row errors too: a missing line1,
city or state, missing or half coordinates, a value that is not decimal
degrees, fewer than 4 decimal places, 0,0, a pin outside India or with
latitude and longitude swapped, or one pin reused for addresses in different
pincodes. The exact reason texts are listed in the
data contract.
Only the envelope can fail the whole request:
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "request body failed validation",
"details": [ { "field": "rows", "reason": "must NOT have fewer than 1 items" } ]
}
}
{ "success": false, "error": { "code": "TOO_MANY_ROWS", "message": "Batch exceeds EXTERNAL_INGEST_MAX_ROWS (20000). Got 25000." } }
Other envelope failures: 413 PAYLOAD_TOO_LARGE (request body too large),
415 UNSUPPORTED_MEDIA_TYPE (a body whose Content-Type is not
application/json), and 422 VALIDATION_FAILED with the message
malformed request body (send valid JSON) for malformed JSON (it never quotes
your body). Every response carries an X-Request-Id header — quote it to
[email protected]. Auth failures (401/403/429) are listed on the
errors page.
Order of checks: the auth headers must be present (401 otherwise), then a
non-JSON Content-Type gets 415, then the body is parsed (too large → 413,
malformed JSON → 422), then the signature is verified, then the envelope
schema. So an unauthenticated call never sees schema errors, but 413, 415
and a malformed-JSON 422 can come back before the signature is checked.
Rate limits
- 60 requests/minute per API key (all endpoints).
- 10 batches/hour per API key on
POST /cases. Dry-runs and idempotent replays count toward the 10 — a dry-run followed by a commit uses two.
Over either limit → 429 RATE_LIMITED with a Retry-After header. Size your
batches (up to 5,000 rows each) rather than your request rate.
Re-push and update semantics
- Identical batch re-sent — we content-hash every committed batch;
an identical re-send is a no-op that returns
idempotentReplay: truewith the original summary, as long as every loan in it still has a live case. If any loan in the batch no longer does (its case was collected, closed or recalled since), the batch is processed normally and those loans get a fresh case, with a newcase.received. A batch committed under the strict address rules and re-sent unchanged after your organisation is put on a grace period is also processed again. (This is separate from theIdempotency-Keyheader and needs no header at all.) - Row for an existing active case — updates the case in place
(outstanding, DPD, address, coordinates, contact…). Optional fields you omit
are preserved, except
loan.dpdandloan.lateCharges, which reset to 0 when omitted. Nothing can be cleared tonull. New coordinates move the case's pin, except over a pin one of our partners verified on site for an unchanged address (details). - Same
sourceLoanNumbertwice in one batch — the first row is kept; later duplicates are per-row errors (duplicate sourceLoanNumber in file — row N kept, this row skipped). Send each loan once per batch, with its latest state. - Row for a loan whose case is closed (
collectedorclosed_unrecovered) — opens a fresh case: newcaseId, a newcase.received, and the eventsequencerestarts at 1. Key your event watermark on(sourceLoanNumber, caseId)or reset it when you seecase.received— see updating your LMS and the event reference.
fullSnapshot — reconciling your whole book
fullSnapshot: true asserts "this batch is my complete open book". Every
active case we hold for you that is absent from the batch is flagged for
our operations review (reason snapshot_dropped); the response gives you only
the count, in summary.flaggedNeedsReview. A loan whose row is in the
batch counts as present even if that row was rejected for a data error — only
loans genuinely missing from the batch are flagged. Rules:
- It must be one single request — chunking a snapshot would flag every case that lives in the other chunks. That is why the batch cap is 20,000 rows.
- Use it for a daily or weekly reconciliation push, never for incremental pushes.
- To actively withdraw a case, use recall — a snapshot only flags.
Code — dry-run, then commit
Every sample below sends a realistic two-row batch as a dry-run first, then
commits the same bytes with an Idempotency-Key. Set FP_KEY_ID and
FP_SECRET from your sandbox credentials.
- Node.js
- Python
- Java
- Go
- C#
- PHP
- cURL
const crypto = require("crypto");
const BASE = process.env.FP_BASE || "https://alpha-gig.fluxusforge.in"; // production: https://gig.fluxusforge.in
const KEY_ID = process.env.FP_KEY_ID; // pk_yourorg_test_…
const SECRET = process.env.FP_SECRET; // shown once at key issuance
async function signedRequest(method, path, { query = "", body = null, headers = {} } = {}) {
const rawBody = body === null ? "" : JSON.stringify(body); // serialise ONCE — sign and send these exact bytes
const ts = Date.now().toString(); // unix milliseconds
const canonical = `${method}\n${path}\n${ts}\n${rawBody}`; // PATH only — never the query string
const sig = crypto.createHmac("sha256", SECRET).update(canonical, "utf8").digest("hex"); // lowercase hex
const res = await fetch(BASE + path + query, {
method,
headers: {
authorization: `Bearer ${KEY_ID}`,
"content-type": "application/json",
"x-fieldproof-timestamp": ts,
"x-fieldproof-signature": sig,
...headers,
},
...(body === null ? {} : { body: rawBody }),
});
return { status: res.status, replay: res.headers.get("x-idempotent-replay") === "true", json: await res.json() };
}
const batch = {
rows: [
{
sourceLoanNumber: "LN-2026-000123",
borrower: {
name: "Ravi Sharma",
phone: "+919876543210",
language: "hindi",
address: {
line1: "12 MG Road", line2: "Near Trinity Metro", landmark: "Opp. Garuda Mall",
city: "Bengaluru", state: "Karnataka", pincode: "560001",
latitude: "12.975326", longitude: "77.606262", // strings keep every decimal place
},
},
loan: {
outstandingAmount: 12500, dpd: 45, emiAmount: 2500, loanAmount: 50000, disbursedAmount: 48500,
lateCharges: 350, interestRate: 24, tenure: 24, tenureUnit: "months",
nextDueDate: "2026-09-05", disbursedDate: "15-Mar-25", productName: "Personal Loan",
},
},
{
sourceLoanNumber: "LN-2026-000124",
borrower: {
name: "Priya Nair",
phone: "9123456789",
address: {
line1: "Flat 4B, Sea View Apartments", city: "Kochi", state: "Kerala", pincode: "682001",
latitude: "9.965389", longitude: "76.242083",
},
},
loan: { outstandingAmount: 8200, dpd: "61-90", emiAmount: 4100, lateCharges: 0, nextDueDate: "05-09-2026" },
},
],
};
(async () => {
// 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
const dry = await signedRequest("POST", "/api/v1/partner/cases", { query: "?dryRun=1", body: batch });
console.log("dry-run", dry.status, dry.json.summary, dry.json.errors);
// 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
const commit = await signedRequest("POST", "/api/v1/partner/cases", {
body: batch,
headers: { "idempotency-key": "nightly-2026-08-18-001" },
});
console.log("commit", commit.status, "replay:", commit.replay, commit.json.summary, commit.json.errors);
})();
import hashlib, hmac, json, os, time
import requests
BASE = os.environ.get("FP_BASE", "https://alpha-gig.fluxusforge.in") # production: https://gig.fluxusforge.in
KEY_ID = os.environ["FP_KEY_ID"] # pk_yourorg_test_…
SECRET = os.environ["FP_SECRET"] # shown once at key issuance
def signed_request(method, path, query="", body=None, extra_headers=None):
raw_body = "" if body is None else json.dumps(body, separators=(",", ":")) # serialise ONCE
ts = str(int(time.time() * 1000)) # unix milliseconds
canonical = f"{method}\n{path}\n{ts}\n{raw_body}" # PATH only — never the query
sig = hmac.new(SECRET.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest() # lowercase hex
headers = {
"Authorization": f"Bearer {KEY_ID}",
"Content-Type": "application/json",
"X-Fieldproof-Timestamp": ts,
"X-Fieldproof-Signature": sig,
**(extra_headers or {}),
}
resp = requests.request(
method, BASE + path + query, headers=headers,
data=raw_body.encode("utf-8") if body is not None else None, # exactly the signed bytes
timeout=60,
)
return resp.status_code, resp.headers.get("X-Idempotent-Replay") == "true", resp.json()
batch = {
"rows": [
{
"sourceLoanNumber": "LN-2026-000123",
"borrower": {
"name": "Ravi Sharma",
"phone": "+919876543210",
"language": "hindi",
"address": {"line1": "12 MG Road", "line2": "Near Trinity Metro", "landmark": "Opp. Garuda Mall",
"city": "Bengaluru", "state": "Karnataka", "pincode": "560001",
"latitude": "12.975326", "longitude": "77.606262"}, # strings keep every decimal place
},
"loan": {
"outstandingAmount": 12500, "dpd": 45, "emiAmount": 2500, "loanAmount": 50000, "disbursedAmount": 48500,
"lateCharges": 350, "interestRate": 24, "tenure": 24, "tenureUnit": "months",
"nextDueDate": "2026-09-05", "disbursedDate": "15-Mar-25", "productName": "Personal Loan",
},
},
{
"sourceLoanNumber": "LN-2026-000124",
"borrower": {
"name": "Priya Nair",
"phone": "9123456789",
"address": {"line1": "Flat 4B, Sea View Apartments", "city": "Kochi", "state": "Kerala", "pincode": "682001",
"latitude": "9.965389", "longitude": "76.242083"},
},
"loan": {"outstandingAmount": 8200, "dpd": "61-90", "emiAmount": 4100, "lateCharges": 0, "nextDueDate": "05-09-2026"},
},
]
}
# 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
status, _, dry = signed_request("POST", "/api/v1/partner/cases", query="?dryRun=1", body=batch)
print("dry-run", status, dry["summary"], dry["errors"])
# 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
status, replay, result = signed_request(
"POST", "/api/v1/partner/cases", body=batch,
extra_headers={"Idempotency-Key": "nightly-2026-08-18-001"},
)
print("commit", status, "replay:", replay, result["summary"], result["errors"])
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class PushCases {
static final String BASE = System.getenv().getOrDefault("FP_BASE", "https://alpha-gig.fluxusforge.in"); // production: https://gig.fluxusforge.in
static final String KEY_ID = System.getenv("FP_KEY_ID"); // pk_yourorg_test_…
static final String SECRET = System.getenv("FP_SECRET"); // shown once at key issuance
static final HttpClient HTTP = HttpClient.newHttpClient();
static String hmacHex(String canonical) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); // lowercase hex
}
static HttpResponse<String> signedPost(String path, String query, String rawBody, String idempotencyKey) throws Exception {
String ts = Long.toString(System.currentTimeMillis()); // unix milliseconds
String canonical = "POST\n" + path + "\n" + ts + "\n" + rawBody; // PATH only — never the query
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path + query))
.header("Authorization", "Bearer " + KEY_ID)
.header("Content-Type", "application/json")
.header("X-Fieldproof-Timestamp", ts)
.header("X-Fieldproof-Signature", hmacHex(canonical))
.POST(HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8)); // exactly the signed bytes
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
public static void main(String[] args) throws Exception {
// Serialise ONCE (a text block here; with Jackson: writeValueAsString once and reuse the String).
String batch = """
{"rows":[
{"sourceLoanNumber":"LN-2026-000123",
"borrower":{"name":"Ravi Sharma","phone":"+919876543210","email":"[email protected]","language":"hindi",
"address":{"line1":"12 MG Road","line2":"Near Trinity Metro","landmark":"Opp. Garuda Mall",
"city":"Bengaluru","state":"Karnataka","pincode":"560001","latitude":"12.975326","longitude":"77.606262"}},
"loan":{"outstandingAmount":12500,"dpd":45,"emiAmount":2500,"loanAmount":50000,"disbursedAmount":48500,
"lateCharges":350,"interestRate":24,"tenure":24,"tenureUnit":"months",
"nextDueDate":"2026-09-05","disbursedDate":"15-Mar-25","productName":"Personal Loan"}},
{"sourceLoanNumber":"LN-2026-000124",
"borrower":{"name":"Priya Nair","phone":"9123456789",
"address":{"line1":"Flat 4B, Sea View Apartments","city":"Kochi","state":"Kerala","pincode":"682001",
"latitude":"9.965389","longitude":"76.242083"}},
"loan":{"outstandingAmount":8200,"dpd":"61-90","emiAmount":4100,"lateCharges":0,"nextDueDate":"05-09-2026"}}
]}
""";
// 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
HttpResponse<String> dry = signedPost("/api/v1/partner/cases", "?dryRun=1", batch, null);
System.out.println("dry-run " + dry.statusCode() + " " + dry.body());
// 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
HttpResponse<String> commit = signedPost("/api/v1/partner/cases", "", batch, "nightly-2026-08-18-001");
System.out.println("commit " + commit.statusCode()
+ " replay: " + commit.headers().firstValue("X-Idempotent-Replay").orElse("false")
+ " " + commit.body());
}
}
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
var (
base = envOr("FP_BASE", "https://alpha-gig.fluxusforge.in") // production: https://gig.fluxusforge.in
keyID = os.Getenv("FP_KEY_ID") // pk_yourorg_test_…
secret = os.Getenv("FP_SECRET") // shown once at key issuance
)
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func signedPost(path, query string, rawBody []byte, idempotencyKey string) (int, bool, []byte, error) {
ts := strconv.FormatInt(time.Now().UnixMilli(), 10) // unix milliseconds
canonical := "POST\n" + path + "\n" + ts + "\n" + string(rawBody) // PATH only — never the query
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(canonical))
sig := hex.EncodeToString(mac.Sum(nil)) // lowercase hex
req, err := http.NewRequest(http.MethodPost, base+path+query, bytes.NewReader(rawBody)) // exactly the signed bytes
if err != nil {
return 0, false, nil, err
}
req.Header.Set("Authorization", "Bearer "+keyID)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fieldproof-Timestamp", ts)
req.Header.Set("X-Fieldproof-Signature", sig)
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
return 0, false, nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
return res.StatusCode, res.Header.Get("X-Idempotent-Replay") == "true", body, err
}
func main() {
// Serialise ONCE (a raw literal here; with encoding/json: json.Marshal once and reuse the bytes).
batch := []byte(`{"rows":[
{"sourceLoanNumber":"LN-2026-000123",
"borrower":{"name":"Ravi Sharma","phone":"+919876543210","email":"[email protected]","language":"hindi",
"address":{"line1":"12 MG Road","line2":"Near Trinity Metro","landmark":"Opp. Garuda Mall",
"city":"Bengaluru","state":"Karnataka","pincode":"560001","latitude":"12.975326","longitude":"77.606262"}},
"loan":{"outstandingAmount":12500,"dpd":45,"emiAmount":2500,"loanAmount":50000,"disbursedAmount":48500,
"lateCharges":350,"interestRate":24,"tenure":24,"tenureUnit":"months",
"nextDueDate":"2026-09-05","disbursedDate":"15-Mar-25","productName":"Personal Loan"}},
{"sourceLoanNumber":"LN-2026-000124",
"borrower":{"name":"Priya Nair","phone":"9123456789",
"address":{"line1":"Flat 4B, Sea View Apartments","city":"Kochi","state":"Kerala","pincode":"682001",
"latitude":"9.965389","longitude":"76.242083"}},
"loan":{"outstandingAmount":8200,"dpd":"61-90","emiAmount":4100,"lateCharges":0,"nextDueDate":"05-09-2026"}}
]}`)
// 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
status, _, body, err := signedPost("/api/v1/partner/cases", "?dryRun=1", batch, "")
if err != nil {
panic(err)
}
fmt.Println("dry-run", status, string(body))
// 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
status, replay, body, err := signedPost("/api/v1/partner/cases", "", batch, "nightly-2026-08-18-001")
if err != nil {
panic(err)
}
fmt.Println("commit", status, "replay:", replay, string(body))
}
using System;
using System.Linq;
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 PushCases
{
static readonly string Base = Environment.GetEnvironmentVariable("FP_BASE") ?? "https://alpha-gig.fluxusforge.in"; // production: https://gig.fluxusforge.in
static readonly string KeyId = Environment.GetEnvironmentVariable("FP_KEY_ID")!; // pk_yourorg_test_…
static readonly string Secret = Environment.GetEnvironmentVariable("FP_SECRET")!; // shown once at key issuance
static readonly HttpClient Http = new HttpClient();
static async Task<(int status, bool replay, string body)> SignedPost(string path, string query, string rawBody, string? idempotencyKey)
{
var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); // unix milliseconds
var canonical = $"POST\n{path}\n{ts}\n{rawBody}"; // PATH only — never the query
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
var sig = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); // lowercase hex
var req = new HttpRequestMessage(HttpMethod.Post, Base + path + query);
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {KeyId}");
req.Headers.Add("X-Fieldproof-Timestamp", ts);
req.Headers.Add("X-Fieldproof-Signature", sig);
if (idempotencyKey != null) req.Headers.Add("Idempotency-Key", idempotencyKey);
req.Content = new StringContent(rawBody, Encoding.UTF8); // exactly the signed bytes
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var res = await Http.SendAsync(req);
var replay = res.Headers.TryGetValues("X-Idempotent-Replay", out var v) && v.FirstOrDefault() == "true";
return ((int)res.StatusCode, replay, await res.Content.ReadAsStringAsync());
}
static async Task Main()
{
// Serialise ONCE and reuse the same string for every request.
var batch = JsonSerializer.Serialize(new
{
rows = new object[]
{
new
{
sourceLoanNumber = "LN-2026-000123",
borrower = new
{
address = new
{
line1 = "12 MG Road", line2 = "Near Trinity Metro", landmark = "Opp. Garuda Mall",
city = "Bengaluru", state = "Karnataka", pincode = "560001",
latitude = "12.975326", longitude = "77.606262", // strings keep every decimal place
},
},
loan = new
{
outstandingAmount = 12500, dpd = 45, emiAmount = 2500, loanAmount = 50000, disbursedAmount = 48500,
lateCharges = 350, interestRate = 24, tenure = 24, tenureUnit = "months",
nextDueDate = "2026-09-05", disbursedDate = "15-Mar-25", productName = "Personal Loan",
},
},
new
{
sourceLoanNumber = "LN-2026-000124",
borrower = new
{
name = "Priya Nair", phone = "9123456789",
address = new
{
line1 = "Flat 4B, Sea View Apartments", city = "Kochi", state = "Kerala", pincode = "682001",
latitude = "9.965389", longitude = "76.242083",
},
},
loan = new { outstandingAmount = 8200, dpd = "61-90", emiAmount = 4100, lateCharges = 0, nextDueDate = "05-09-2026" },
},
},
});
// 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
var dry = await SignedPost("/api/v1/partner/cases", "?dryRun=1", batch, null);
Console.WriteLine($"dry-run {dry.status} {dry.body}");
// 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
var commit = await SignedPost("/api/v1/partner/cases", "", batch, "nightly-2026-08-18-001");
Console.WriteLine($"commit {commit.status} replay: {commit.replay} {commit.body}");
}
}
<?php
$base = getenv("FP_BASE") ?: "https://alpha-gig.fluxusforge.in"; // production: https://gig.fluxusforge.in
$keyId = getenv("FP_KEY_ID"); // pk_yourorg_test_…
$secret = getenv("FP_SECRET"); // shown once at key issuance
function signedPost(string $path, string $query, string $rawBody, ?string $idempotencyKey = null): array {
global $base, $keyId, $secret;
$ts = (string) (int) round(microtime(true) * 1000); // unix milliseconds
$canonical = "POST\n{$path}\n{$ts}\n{$rawBody}"; // PATH only — never the query
$sig = hash_hmac("sha256", $canonical, $secret); // lowercase hex
$headers = [
"Authorization: Bearer {$keyId}",
"Content-Type: application/json",
"X-Fieldproof-Timestamp: {$ts}",
"X-Fieldproof-Signature: {$sig}",
];
if ($idempotencyKey !== null) $headers[] = "Idempotency-Key: {$idempotencyKey}";
$ch = curl_init($base . $path . $query);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $rawBody, // exactly the signed bytes
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 60,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$respHeaders = substr($raw, 0, $headerSize);
$replay = stripos($respHeaders, "x-idempotent-replay: true") !== false;
return [$status, $replay, json_decode(substr($raw, $headerSize), true)];
}
$batch = ["rows" => [
[
"sourceLoanNumber" => "LN-2026-000123",
"borrower" => [
"name" => "Ravi Sharma", "phone" => "+919876543210", "email" => "[email protected]", "language" => "hindi",
"address" => ["line1" => "12 MG Road", "line2" => "Near Trinity Metro", "landmark" => "Opp. Garuda Mall",
"city" => "Bengaluru", "state" => "Karnataka", "pincode" => "560001",
"latitude" => "12.975326", "longitude" => "77.606262"], // strings keep every decimal place
],
"loan" => [
"outstandingAmount" => 12500, "dpd" => 45, "emiAmount" => 2500, "loanAmount" => 50000, "disbursedAmount" => 48500,
"lateCharges" => 350, "interestRate" => 24, "tenure" => 24, "tenureUnit" => "months",
"nextDueDate" => "2026-09-05", "disbursedDate" => "15-Mar-25", "productName" => "Personal Loan",
],
],
[
"sourceLoanNumber" => "LN-2026-000124",
"borrower" => [
"name" => "Priya Nair", "phone" => "9123456789",
"address" => ["line1" => "Flat 4B, Sea View Apartments", "city" => "Kochi", "state" => "Kerala", "pincode" => "682001",
"latitude" => "9.965389", "longitude" => "76.242083"],
],
"loan" => ["outstandingAmount" => 8200, "dpd" => "61-90", "emiAmount" => 4100, "lateCharges" => 0, "nextDueDate" => "05-09-2026"],
],
]];
$rawBody = json_encode($batch, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // serialise ONCE
// 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
[$status, , $dry] = signedPost("/api/v1/partner/cases", "?dryRun=1", $rawBody);
echo "dry-run {$status} ", json_encode($dry["summary"]), " ", json_encode($dry["errors"]), PHP_EOL;
// 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
[$status, $replay, $result] = signedPost("/api/v1/partner/cases", "", $rawBody, "nightly-2026-08-18-001");
echo "commit {$status} replay: ", var_export($replay, true), " ", json_encode($result["summary"]), " ", json_encode($result["errors"]), PHP_EOL;
BASE="${FP_BASE:-https://alpha-gig.fluxusforge.in}" # production: https://gig.fluxusforge.in
KEY_ID="$FP_KEY_ID" # pk_yourorg_test_…
SECRET="$FP_SECRET" # shown once at key issuance
# The body lives in a file so the bytes we sign and the bytes curl sends are identical.
cat > batch.json <<'EOF'
{"rows":[
{"sourceLoanNumber":"LN-2026-000123",
"borrower":{"name":"Ravi Sharma","phone":"+919876543210","email":"[email protected]","language":"hindi",
"address":{"line1":"12 MG Road","line2":"Near Trinity Metro","landmark":"Opp. Garuda Mall",
"city":"Bengaluru","state":"Karnataka","pincode":"560001","latitude":"12.975326","longitude":"77.606262"}},
"loan":{"outstandingAmount":12500,"dpd":45,"emiAmount":2500,"loanAmount":50000,"disbursedAmount":48500,
"lateCharges":350,"interestRate":24,"tenure":24,"tenureUnit":"months",
"nextDueDate":"2026-09-05","disbursedDate":"15-Mar-25","productName":"Personal Loan"}},
{"sourceLoanNumber":"LN-2026-000124",
"borrower":{"name":"Priya Nair","phone":"9123456789",
"address":{"line1":"Flat 4B, Sea View Apartments","city":"Kochi","state":"Kerala","pincode":"682001",
"latitude":"9.965389","longitude":"76.242083"}},
"loan":{"outstandingAmount":8200,"dpd":"61-90","emiAmount":4100,"lateCharges":0,"nextDueDate":"05-09-2026"}}
]}
EOF
# sign METHOD PATH BODYFILE → lowercase hex; uses $TS. PATH never carries the query string.
sign() {
{ printf '%s\n%s\n%s\n' "$1" "$2" "$TS"; cat "$3"; } \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
}
# 1. Dry-run: same validation, nothing written, no events. Idempotency-Key is ignored here.
TS=$(($(date +%s) * 1000)) # unix ms (second precision is fine — the skew window is ±5 min)
SIG=$(sign POST /api/v1/partner/cases batch.json)
curl -sS -X POST "$BASE/api/v1/partner/cases?dryRun=1" \
-H "Authorization: Bearer $KEY_ID" -H "Content-Type: application/json" \
-H "X-Fieldproof-Timestamp: $TS" -H "X-Fieldproof-Signature: $SIG" \
--data-binary @batch.json
echo
# 2. Commit. One Idempotency-Key per logical batch — reuse the SAME key if you retry after a timeout.
TS=$(($(date +%s) * 1000))
SIG=$(sign POST /api/v1/partner/cases batch.json)
curl -sS -i -X POST "$BASE/api/v1/partner/cases" \
-H "Authorization: Bearer $KEY_ID" -H "Content-Type: application/json" \
-H "X-Fieldproof-Timestamp: $TS" -H "X-Fieldproof-Signature: $SIG" \
-H "Idempotency-Key: nightly-2026-08-18-001" \
--data-binary @batch.json
# -i prints response headers: look for X-Idempotent-Replay: true on a retried commit
:::tip Signature failing?
Sandbox-only POST /api/v1/partner/debug/echo-signature returns the canonical
string the server computed for your request — see
debugging signatures.
:::
GET /cases/{sourceLoanNumber} — read one case
The authoritative live snapshot of a loan's case: use it to reconcile after an event gap, to check a case before recalling it, or on demand. It returns the active case if there is one, otherwise the most recent closed one.
GET /api/v1/partner/cases/LN-2026-000123
Signing note: the canonical PATH is the path exactly as transmitted, percent-encoded.
A loan number containing / must be encoded (LN/001 → /api/v1/partner/cases/LN%2F001),
and that encoded string is what you sign. RAW_BODY is the empty string on a GET.
const loan = "LN-2026-000123";
const res = await signedRequest("GET", `/api/v1/partner/cases/${encodeURIComponent(loan)}`);
console.log(res.status, res.json);
{
"success": true,
"case": {
"sourceLoanNumber": "LN-2026-000123",
"caseId": "CC-2026-1A2B3C4D5E",
"status": "in_progress",
"isActive": true,
"assignedAt": "2026-08-18T06:12:40.000Z",
"totalCollected": 5000,
"outstandingAmount": 12500,
"visits": [
{
"visitAt": "2026-08-18T11:05:12.000Z",
"outcome": "partial",
"amountCollected": 5000,
"paymentMode": "upi",
"paymentReference": "UTR123456789012",
"ptpDate": null
}
],
"createdAt": "2026-08-17T18:30:02.000Z",
"updatedAt": "2026-08-18T11:05:12.000Z"
}
}
| Field | Meaning |
|---|---|
sourceLoanNumber | Your loan number, as requested. |
caseId | Our id for this case. Changes when a closed loan is re-pushed (fresh case). |
status | pending · in_progress · collected · closed_unrecovered. |
isActive | true while the case is open on the marketplace / with a partner. |
assignedAt | When a field partner took the case, or null. |
totalCollected | Absolute amount collected on this case (INR). |
outstandingAmount | Outstanding as we currently hold it (INR). |
visits[] | The last 20 visits: visitAt, outcome, amountCollected, paymentMode, paymentReference, ptpDate. On the static-QR payment mode, amountCollected before our verification is the partner's unverified claim — trust the payment.collected event for booked money. |
createdAt / updatedAt | ISO timestamps of the case record. |
404 CASE_NOT_FOUND — no case for that loan number in your organisation
(another organisation's loan numbers are indistinguishable from unknown ones).
POST /cases/{sourceLoanNumber}/recall — withdraw a case
Call it the moment a borrower settles with you directly, the loan is restructured or sold, or you otherwise want field activity to stop.
POST /api/v1/partner/cases/LN-2026-000123/recall
Content-Type: application/json
{ "reason": "settled directly with lender" }
- Body:
{ "reason": "…" }, sent asapplication/json.reasonis optional, ≤ 200 characters — free text kept on the case history for audit. Send{}(or an empty body withContent-Type: application/json) if you have no reason. A POST with neither a body nor aContent-Typegets422 VALIDATION_FAILED. Other properties are ignored. - Same percent-encoding rule as
GET /cases/{sourceLoanNumber}; the signed path is/api/v1/partner/cases/<encoded loan>/recall.
{ "success": true, "alreadyClosed": false, "caseId": "CC-2026-1A2B3C4D5E", "status": "closed_unrecovered" }
{ "success": true, "alreadyClosed": true, "caseId": "CC-2026-1A2B3C4D5E", "status": "collected" }
What happens on an active case:
- The case is closed as
closed_unrecoveredand detached from the field partner; the partner is notified to stop any visit in progress. - A
case.closedevent is emitted withpayload.status = "closed_unrecovered"andpayload.reason = "recalled"(plustotalCollectedso far — event reference). - The call is idempotent: repeating it (or recalling a case that already
reached
collected/closed_unrecovered) returnsalreadyClosed: truewith the case's current status and emits nothing. 404 CASE_NOT_FOUNDif we hold no case for that loan number.
A recalled loan can be pushed again later — that opens a fresh case with a new
caseId and sequence starting at 1.
const loan = "LN-2026-000123";
const res = await signedRequest("POST", `/api/v1/partner/cases/${encodeURIComponent(loan)}/recall`, {
body: { reason: "settled directly with lender" },
});
console.log(res.status, res.json); // { success: true, alreadyClosed: false, caseId: "…", status: "closed_unrecovered" }
:::note Sandbox
There are no simulated field agents in the sandbox: pushing a case emits
case.received, and you can dry-run, recall, poll, replay and test webhooks
yourself. Assignment and visit events are driven by the Fieldproof team on
request — see sandbox testing.
:::