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 an
existing, active (not revoked) 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. A body must be sent as application/json
(anything else is 415 UNSUPPORTED_MEDIA_TYPE). Live keys, and any key used
against production https://gig.fluxusforge.in, get 403 SANDBOX_ONLY.
Because the endpoint never verifies the signature, it is not part of the
signature-failure lockout: calls to it never count toward a lock, and it keeps
answering while your key is locked. It also does not check key expiry — an
expired test key still gets an echo, but the real endpoints reject it with
401 UNAUTHORIZED.
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 on the real endpoints lock the key for
15 minutes (429 AUTH_LOCKED, Retry-After = seconds remaining); the echo
endpoint does not count toward that lock and still works during it. Every
request that passes authentication — including debugger calls — counts toward
the 60 requests/min limit (requests rejected with 401 or AUTH_LOCKED do
not). 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 |
415 UNSUPPORTED_MEDIA_TYPE | The body was not sent as Content-Type: application/json (some HTTP libraries default to text/plain or form encoding). Rejected before the signature is checked, so it does not count toward the lockout |
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 (with two active keys for one environment, the secret
of the key issued first) — 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.