Signing requests
Every call to the Partner API carries a Bearer key ID (identifies you) and an HMAC-SHA256 signature (proves you hold the secret — which is never transmitted). The same scheme, in reverse, signs the webhooks we send you.
Headers
Authorization: Bearer <apiKeyId>
Content-Type: application/json
X-Quikkred-Timestamp: <unix-milliseconds>
X-Quikkred-Signature: <hex signature>
The canonical string
signature = hex( HMAC-SHA256( METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + RAW_BODY, hmacSecret ) )
| Component | Rule | Common mistake |
|---|---|---|
METHOD | Uppercase (POST) | lowercase |
PATH | URL path only, no query string (/api/v1/partner/cases) | including ?dryRun=1 |
TIMESTAMP | Exact value of the X-Quikkred-Timestamp header, unix milliseconds | seconds instead of ms |
RAW_BODY | The exact bytes you transmit; empty string for GET | re-serialising JSON after signing (key order/whitespace changes the bytes) |
The timestamp must be within ±5 minutes of server time (replay protection).
Implementations
Node.js
const crypto = require("crypto");
function sign(method, path, rawBody, secret) {
const ts = Date.now().toString();
const canonical = `${method}\n${path}\n${ts}\n${rawBody}`;
const sig = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
return { ts, sig };
}
const body = JSON.stringify({ rows: [/* … */] });
const { ts, sig } = sign("POST", "/api/v1/partner/cases", body, process.env.QK_SECRET);
// send `body` EXACTLY as signed — do not stringify again
Python
import hashlib, hmac, json, time
def sign(method: str, path: str, raw_body: str, secret: str):
ts = str(int(time.time() * 1000))
canonical = f"{method}\n{path}\n{ts}\n{raw_body}"
sig = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return ts, sig
body = json.dumps(payload, separators=(",", ":")) # serialise ONCE, sign, send as-is
ts, sig = sign("POST", "/api/v1/partner/cases", body, SECRET)
PHP
function qk_sign(string $method, string $path, string $rawBody, string $secret): array {
$ts = (string) round(microtime(true) * 1000);
$canonical = "$method\n$path\n$ts\n$rawBody";
return [$ts, hash_hmac('sha256', $canonical, $secret)];
}
cURL (bash)
TS=$(date +%s%3N)
BODY='{"rows":[...]}'
SIG=$(printf 'POST\n/api/v1/partner/cases\n%s\n%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$QK_SECRET" -r | cut -d' ' -f1)
curl -X POST "$QK_BASE/api/v1/partner/cases" \
-H "Authorization: Bearer $QK_KEY_ID" -H "Content-Type: application/json" \
-H "X-Quikkred-Timestamp: $TS" -H "X-Quikkred-Signature: $SIG" \
-d "$BODY"
Verifying OUR webhooks
Identical scheme, with your callback path as PATH and the raw request
body you received as RAW_BODY. Always compare with a constant-time function.
See webhooks for full receiver code.
Test it immediately
GET /api/v1/partner/health is the auth smoke test — a correct signature
returns your organisation code:
{ "ok": true, "lenderCode": "YOURORG", "env": "test", "serverTime": "…" }
Failing? The signature debugger shows you the server-side canonical string to diff against yours.