Debugging signatures
Signature mismatches are the #1 integration stall — and impossible to debug blind, because a correct server never reveals what it expected. The sandbox fixes that with a dedicated endpoint that accepts an invalid signature on purpose and echoes back the server-side computation.
POST /api/v1/partner/debug/echo-signature (sandbox only)
Point your existing client at this path on https://alpha-gig.fluxusforge.in
with your test key ID (pk_<org>_test_…), the timestamp header, and any
signature attempt — the same body and headers you would send to a real
endpoint. Instead of 401 SIGNATURE_INVALID you get the full breakdown.
The example below is what the endpoint returns for a POST with body
{"probe":true} at timestamp 1755500000000 when the key's secret is
example-secret and the caller sent the signature deadbeef:
{
"success": true,
"canonicalString": "POST\n/api/v1/partner/debug/echo-signature\n1755500000000\n{\"probe\":true}",
"canonicalParts": {
"method": "POST",
"path": "/api/v1/partner/debug/echo-signature",
"timestamp": "1755500000000",
"bodyBytes": 14,
"bodySha256": "c775500ea34eded73c2a3c3bede193f0d839e6c14b36f21b6cc31472e0720a91"
},
"expectedSignature": "25fdae8fba2198f026488723c4ff37c49b1e998889f06e881df7b0964af5aa8b",
"receivedSignature": "deadbeef",
"signatureMatches": false,
"timestampWithinSkew": true,
"note": "Sign hex(HMAC-SHA256(canonicalString, hmacSecret)) over the EXACT raw body bytes — do not re-serialize JSON after signing."
}
| Field | What it tells you |
|---|---|
canonicalString | The exact string the server HMAC'd — method, path, timestamp and raw body joined by \n |
canonicalParts.path | The path as the server received it: percent-encoded, no query string |
canonicalParts.timestamp | The X-Fieldproof-Timestamp value the server read |
canonicalParts.bodyBytes / bodySha256 | Byte count and SHA-256 of the body as received — compare against what you signed |
expectedSignature | HMAC of canonicalString with your current secret |
receivedSignature | What you sent (null if the header was missing) |
signatureMatches | Case-insensitive comparison of the two — the same rule the real endpoints apply |
timestampWithinSkew | Whether your timestamp would have passed the ±5 minute check |
Only the signature is exempt from checking here. The key ID must be a valid,
active test key, the timestamp header must be present, and your source IP must
pass your allowlist if you set one — otherwise you get the normal
401 UNAUTHORIZED. Live keys, and any key used against production
https://gig.fluxusforge.in, get 403 SANDBOX_ONLY.
Call it with the helper you already have
The fieldproof() wrapper from signing requests
signs whatever it is given, so pointing it at the debugger is one line:
const { fieldproof } = require("./fieldproof");
(async () => {
const r = await fieldproof("POST", "/api/v1/partner/debug/echo-signature", { probe: true });
console.log(r.status, JSON.stringify(r.json, null, 2));
// signatureMatches:true → your signer is correct; point it back at the real endpoints
})();
fp_call POST /api/v1/partner/debug/echo-signature '{"probe":true}'
If your own signer is right you will see signatureMatches: true — the useful
case is when it is not, because then canonicalParts tells you which
component differs.
How to use it
- Point your failing client at
/api/v1/partner/debug/echo-signature, keeping everything else identical (same signing code, same body). - Diff
canonicalPartsagainst what your code signed:pathdiffers → you included the query string, signed the decoded (or double-encoded) path, or a proxy rewrote it. Sign the path percent-encoded exactly as sent, no?….timestampdiffers → you signed one value and sent another, or sent seconds instead of milliseconds (a 10-digit value is seconds; expect 13).bodyBytes/bodySha256differ → your transmitted bytes ≠ signed bytes. Typical causes: re-serialising JSON after signing (key order, whitespace,\/escaping), ajson=/ auto-serialise option in your HTTP library, a trailing newline fromecho, a middleware or gateway mutating the body, or signing a UTF-16 string where UTF-8 was sent.- Everything matches but
signatureMatchesisfalse→ wrong secret (copied with trailing whitespace, a rotated-out value, or the secret of a different key/environment). Verify your code against the test vectors, which use a fixed secret.
- When
expectedSignatureequals what your code produces, point back at the real endpoints — done.
Compute the SHA-256 of your body locally to compare with bodySha256:
printf '%s' "$BODY" | shasum -a 256 # macOS
printf '%s' "$BODY" | sha256sum # Linux
crypto.createHash("sha256").update(rawBody, "utf8").digest("hex") // Node
:::tip Use the echo endpoint, not retries
Ten signature failures within 5 minutes lock the key for 15 minutes
(429 AUTH_LOCKED, Retry-After = seconds remaining), and every request —
including the debugger — counts toward the 60 requests/min limit. Debug
against /debug/echo-signature, fix, then return to the real endpoints.
:::
Reading the other auth errors
Not every 401 is a signature problem. Decide by error.code:
| Response | It is not the signature — check… |
|---|---|
401 UNAUTHORIZED | Headers present? Key ID correct and not revoked? Test key on the sandbox host, live key on production? Source IP on your allowlist? |
401 TIMESTAMP_SKEW | Clock drift (sync NTP), a cached timestamp, or seconds instead of milliseconds |
429 AUTH_LOCKED | You are locked out; wait Retry-After seconds — a correct signature does not unlock early |
403 SANDBOX_ONLY | You called the debugger with a live key or against production |
403 LENDER_INACTIVE | Your organisation is suspended — talk to [email protected] |
Every response carries an X-Request-Id header. If you are stuck, send the
request ID, the canonicalParts you received and the canonical string your
code built to [email protected].
Debugging webhook signatures
There is no echo endpoint for the inbound direction, but
POST /api/v1/partner/webhooks/test fires a signed ping event at your
callback synchronously and returns what happened:
{ "success": true, "delivered": true, "statusCode": 200, "latencyMs": 412, "error": null, "bodyExcerpt": "{\"received\":true}" }
If your receiver rejects the ping, walk the same checklist in reverse:
PATH is your callback URL's pathname only (never put a query string in
the callback URL); RAW_BODY is the request body exactly as received (read the
raw stream — do not verify a re-serialised parsed object); the timestamp is our
X-Fieldproof-Timestamp header (the delivery also carries X-Fieldproof-Event,
X-Fieldproof-Delivery and the legacy X-Quikkred-* duplicates); the secret is
your key's HMAC secret — and if you have just rotated, remember webhooks are
signed with the new secret from the moment of rotation. The ping envelope
carries sourceLoanNumber: null and sequence: null, so a receiver that
rejects nulls fails the test before it ever verifies the signature.
422 CALLBACK_NOT_CONFIGURED means no active callback URL is registered for
this environment. Full receiver contract: webhooks.