Skip to main content

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:

Response
{
"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."
}
FieldWhat it tells you
canonicalStringThe exact string the server HMAC'd — method, path, timestamp and raw body joined by \n
canonicalParts.pathThe path as the server received it: percent-encoded, no query string
canonicalParts.timestampThe X-Fieldproof-Timestamp value the server read
canonicalParts.bodyBytes / bodySha256Byte count and SHA-256 of the body as received — compare against what you signed
expectedSignatureHMAC of canonicalString with your current secret
receivedSignatureWhat you sent (null if the header was missing)
signatureMatchesCase-insensitive comparison of the two — the same rule the real endpoints apply
timestampWithinSkewWhether 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:

echo.js — Node, reuses fieldproof.js
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
})();
Same thing with the shell helper (source fieldproof.sh first)
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​

  1. Point your failing client at /api/v1/partner/debug/echo-signature, keeping everything else identical (same signing code, same body).
  2. Diff canonicalParts against what your code signed:
    • path differs → 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 ?….
    • timestamp differs → you signed one value and sent another, or sent seconds instead of milliseconds (a 10-digit value is seconds; expect 13).
    • bodyBytes / bodySha256 differ → your transmitted bytes ≠ signed bytes. Typical causes: re-serialising JSON after signing (key order, whitespace, \/ escaping), a json= / auto-serialise option in your HTTP library, a trailing newline from echo, a middleware or gateway mutating the body, or signing a UTF-16 string where UTF-8 was sent.
    • Everything matches but signatureMatches is false → 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.
  3. When expectedSignature equals 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:

ResponseIt is not the signature — check…
401 UNAUTHORIZEDHeaders present? Key ID correct and not revoked? Test key on the sandbox host, live key on production? Source IP on your allowlist?
401 TIMESTAMP_SKEWClock drift (sync NTP), a cached timestamp, or seconds instead of milliseconds
429 AUTH_LOCKEDYou are locked out; wait Retry-After seconds — a correct signature does not unlock early
403 SANDBOX_ONLYYou called the debugger with a live key or against production
403 LENDER_INACTIVEYour 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.