Authentication & signing
Every request to the gateway carries three headers. Bad/missing signature, missing key, or a clock-skewed timestamp all return 401 Unauthorized.
Required headers
Section titled “Required headers”| Header | Value |
|---|---|
X-PAY-Key | Your app’s public_key (looks like pk_<24hex>). |
X-PAY-Timestamp | Unix epoch seconds at signing time. Requests with |now − ts| > 300s are rejected. |
X-PAY-Signature | Lowercase hex HMAC-SHA256(secret, canonical). |
Canonical payload
Section titled “Canonical payload”The string the HMAC runs over:
<X-PAY-Timestamp>.<METHOD>.<URL.Path>.<sha256(body) as hex>Notes:
URL.Pathis the path without query string. Sign over the path only — query strings are signed separately if at all.sha256(body)forGET/DELETE(no body) is the SHA-256 of the empty string:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.- All hex output is lowercase.
- Body is the exact bytes you send — don’t re-serialize JSON between signing and sending or the hashes will differ.
Reference signers
Section titled “Reference signers”Node / TypeScript
Section titled “Node / TypeScript”import { createHmac, createHash } from 'node:crypto';
interface SignArgs { method: string; // 'POST', 'GET', etc. path: string; // '/v1/payments' body?: string; // raw bytes; '' for no-body methods timestamp: number; // Math.floor(Date.now() / 1000) secret: string; // your app secret}
export function sign({ method, path, body = '', timestamp, secret }: SignArgs): string { const bodyHash = createHash('sha256').update(body).digest('hex'); const canonical = `${timestamp}.${method}.${path}.${bodyHash}`; return createHmac('sha256', secret).update(canonical).digest('hex');}package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt")
func Sign(secret, method, path string, body []byte, ts int64) string { bodyHash := sha256.Sum256(body) canonical := fmt.Sprintf("%d.%s.%s.%x", ts, method, path, bodyHash[:]) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(canonical)) return hex.EncodeToString(mac.Sum(nil))}function sign(string $secret, string $method, string $path, string $body, int $ts): string { $bodyHash = hash('sha256', $body); $canonical = "$ts.$method.$path.$bodyHash"; return hash_hmac('sha256', $canonical, $secret);}require 'openssl'require 'digest'
def sign(secret, method, path, body, ts) body_hash = Digest::SHA256.hexdigest(body) canonical = "#{ts}.#{method}.#{path}.#{body_hash}" OpenSSL::HMAC.hexdigest('sha256', secret, canonical)endBash (debugging)
Section titled “Bash (debugging)”PK=pk_xxxSK=xxxBODY='{"external_user_id":"u-1",...}'TS=$(date +%s)H=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')S=$(printf '%s' "$TS.POST./v1/payments.$H" | openssl dgst -sha256 -hmac "$SK" -hex | awk '{print $NF}')echo "$S"Common mistakes
Section titled “Common mistakes”| Symptom | Likely cause |
|---|---|
401 missing auth headers | One of the three headers absent. Check spelling — X-PAY-Key, not X-Pay-Key (some HTTP libs canonicalize headers but the gateway checks the exact name). |
401 timestamp out of range | Clock skew on your machine. NTP. |
401 invalid signature | Body bytes mismatch — common when middleware re-serializes JSON, or when you trim trailing newlines. Sign the EXACT bytes you send. |
401 invalid signature (when body and headers look right) | Hex case mismatch — both body_hash and the final HMAC must be lowercase hex. |
What about webhooks (gateway → product)?
Section titled “What about webhooks (gateway → product)?”Webhooks use a different, simpler signing scheme. See Verifying webhooks.
Rotation
Section titled “Rotation”If your secret is compromised, the operator archives the app and provisions a new one. You can’t rotate in-place today — by design, since rotation requires both old + new secrets to coexist briefly, which adds complexity for a single-tenant gateway. If this becomes a real need we’ll wire it.