Skip to content

Authentication & signing

Every request to the gateway carries three headers. Bad/missing signature, missing key, or a clock-skewed timestamp all return 401 Unauthorized.

HeaderValue
X-PAY-KeyYour app’s public_key (looks like pk_<24hex>).
X-PAY-TimestampUnix epoch seconds at signing time. Requests with |now − ts| > 300s are rejected.
X-PAY-SignatureLowercase hex HMAC-SHA256(secret, canonical).

The string the HMAC runs over:

<X-PAY-Timestamp>.<METHOD>.<URL.Path>.<sha256(body) as hex>

Notes:

  • URL.Path is the path without query string. Sign over the path only — query strings are signed separately if at all.
  • sha256(body) for GET/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.
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)
end
Terminal window
PK=pk_xxx
SK=xxx
BODY='{"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"
SymptomLikely cause
401 missing auth headersOne 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 rangeClock skew on your machine. NTP.
401 invalid signatureBody 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.

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.