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, dedupe, geocoding, assignment
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: signed GET /openapi.json, or the API reference.
Recommended rhythm
- New overdue loans — push as they cross your DPD threshold (or hourly).
- Changes — re-push the row when outstanding/contact/address changes; 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 accepts only these two properties — any other top-level key
is a 422 VALIDATION_FAILED. Inside a row, unknown fields are ignored
(data contract).
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": 198,
"created": 150,
"updatedSnapshot": 40,
"unchanged": 8,
"flaggedNeedsReview": 0,
"errored": 2
},
"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\")" }
]
}
| 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. |
errors[] | { index, sourceLoanNumber, reason } per rejected row (index is the row's position in rows, from 0). Capped at 50 entries. |
Rows fail individually — one bad phone number never blocks the batch. 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 (send application/json), 422 VALIDATION_FAILED
with a message for malformed JSON. Every response carries an X-Request-Id
header — quote it to [email protected]. Auth failures (401/403/429)
are listed on the errors page; auth runs before body
validation, so an unauthenticated call never sees schema errors.
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 guaranteed no-op that returns
idempotentReplay: truewith the original summary. (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, contact…). Optional fields you omit are
preserved, except
loan.dpdandloan.lateCharges, which reset to 0 when omitted. Nothing can be cleared tonull. - 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. 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", city: "Bengaluru", state: "Karnataka", pincode: "560001", landmark: "Opp. Garuda Mall" },
},
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" },
},
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", "city": "Bengaluru",
"state": "Karnataka", "pincode": "560001", "landmark": "Opp. Garuda Mall"},
},
"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"},
},
"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","city":"Bengaluru","state":"Karnataka","pincode":"560001","landmark":"Opp. Garuda Mall"}},
"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"}},
"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","city":"Bengaluru","state":"Karnataka","pincode":"560001","landmark":"Opp. Garuda Mall"}},
"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"}},
"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", city = "Bengaluru", state = "Karnataka", pincode = "560001", landmark = "Opp. Garuda Mall" },
},
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" },
},
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", "city" => "Bengaluru",
"state" => "Karnataka", "pincode" => "560001", "landmark" => "Opp. Garuda Mall"],
],
"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"],
],
"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","city":"Bengaluru","state":"Karnataka","pincode":"560001","landmark":"Opp. Garuda Mall"}},
"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"}},
"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": "…" }, optional, ≤ 200 characters — free text kept on the case history for audit. Send{}if you have no reason. No other properties are accepted (422 VALIDATION_FAILED). - 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.
:::