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 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:

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 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:

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
415 UNSUPPORTED_MEDIA_TYPEThe 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_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 (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.