3. Authentication & HMAC Signing
Authentication & HMAC Signing
Server-to-server endpoints require three headers on every request. Browser/widget endpoints require only one. Get these wrong and the gateway returns 401 Unauthorized.
Required headers
Server-to-server (HMAC)
| Header | Value |
|---|---|
X-Partner-Key |
Your secret key, e.g. sk_live_<64hex> |
X-Timestamp |
Unix timestamp in seconds (must be within ±5 minutes of server time) |
X-Signature |
HMAC-SHA256 hex digest, computed as below |
Content-Type |
application/json for POST/PATCH |
Widget (publishable key only)
| Header | Value |
|---|---|
X-Partner-Key |
Your publishable key, e.g. pk_live_<64hex> |
pk_ keys also work on read-only HMAC endpoints if you sign the request. Mutation endpoints decorated @RequiresSecretKey() reject pk_ keys with 403 Forbidden.
Signature algorithm
signedPayload = ${timestamp}${METHOD}${path}${SHA256_hex(body)}
X-Signature = HMAC-SHA256_hex(hmacSecret, signedPayload)
Components:
timestamp— same value asX-Timestampheader, as a string of digits.METHOD— uppercase HTTP verb:GET,POST,PATCH,DELETE.path— the requestoriginalUrl, including query string. For example/v1/partner/users?page=1&limit=20.SHA256_hex(body)— lowercase hex SHA-256 of the raw request body bytes. For empty bodies (typical GET), use the SHA-256 of the empty string:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.hmacSecret— the signing secret returned alongside your key (NOT thesk_key value itself; older keys without a separate secret fall back to thesk_value but trigger a deprecation warning).
The signature is compared with constant-time crypto.timingSafeEqual.
Reference recipes
Node.js / TypeScript
import crypto from 'crypto';
import axios from 'axios';
const BASE = 'https://api.sirgiving.org';
const PARTNER_KEY = process.env.SIR_SECRET_KEY!; // sk_live_...
const HMAC_SECRET = process.env.SIR_HMAC_SECRET!; // 64-hex
function sign(method: string, path: string, body?: object) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const rawBody = body ? JSON.stringify(body) : '';
const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex');
const signedPayload = `${timestamp}${method.toUpperCase()}${path}${bodyHash}`;
const signature = crypto
.createHmac('sha256', HMAC_SECRET)
.update(signedPayload)
.digest('hex');
return { timestamp, signature, rawBody };
}
async function callPartnerApi<T>(method: string, path: string, body?: object): Promise<T> {
const { timestamp, signature, rawBody } = sign(method, path, body);
const res = await axios.request<T>({
baseURL: BASE,
url: path,
method,
headers: {
'X-Partner-Key': PARTNER_KEY,
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'application/json',
},
data: rawBody || undefined,
transformRequest: [(d) => d], // do NOT let axios re-serialize — body bytes must match
});
return res.data;
}
Python
import hashlib, hmac, json, time, requests
BASE = "https://api.sirgiving.org"
PARTNER_KEY = os.environ["SIR_SECRET_KEY"]
HMAC_SECRET = os.environ["SIR_HMAC_SECRET"]
def call(method: str, path: str, body: dict | None = None):
timestamp = str(int(time.time()))
raw_body = json.dumps(body, separators=(",", ":")) if body else ""
body_hash = hashlib.sha256(raw_body.encode()).hexdigest()
signed_payload = f"{timestamp}{method.upper()}{path}{body_hash}"
signature = hmac.new(
HMAC_SECRET.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
headers = {
"X-Partner-Key": PARTNER_KEY,
"X-Timestamp": timestamp,
"X-Signature": signature,
"Content-Type": "application/json",
}
return requests.request(method, BASE + path, headers=headers, data=raw_body or None)
curl (single call)
TS=$(date +%s)
PATH_=/v1/partner/users
BODY=''
BODY_HASH=$(printf '%s' "$BODY" | shasum -a 256 | awk '{print $1}')
SIG=$(printf '%s' "${TS}GET${PATH_}${BODY_HASH}" | \
openssl dgst -sha256 -hmac "$HMAC_SECRET" -hex | awk '{print $2}')
curl https://api.sirgiving.org$PATH_ \
-H "X-Partner-Key: $SIR_SECRET_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG"
Common signing pitfalls
- Body re-serialization. If your HTTP client re-serializes JSON after you compute the hash, the bytes won't match. Pass the exact JSON string you hashed as the body (see
transformRequestin the TS sample). - Path mismatch. Include the query string.
/v1/partner/usersand/v1/partner/users?page=1produce different signatures. - Wrong secret. Sign with the
hmacSecret, not thesk_key. Pass thesk_(orpk_) as theX-Partner-Keyheader. - Clock skew. Tolerance is ±5 minutes. NTP-sync your server.
- Empty body hash. SHA-256 of the empty string is
e3b0c442...b855, not the empty string itself. - Method casing.
get≠GET. The server uppercases before signing.
Error responses
All return 401 Unauthorized with this JSON shape:
{ "error": "INVALID_SIGNATURE", "message": "Request signature verification failed" }
Codes you may see:
| Error code | Meaning |
|---|---|
INVALID_API_KEY |
Missing key, malformed format, not found, expired, or doesn't match |
TIMESTAMP_EXPIRED |
Timestamp missing or outside the ±5-minute window |
INVALID_SIGNATURE |
Signature missing or HMAC didn't verify |
PARTNER_NOT_ACTIVE |
Partner record not in ACTIVE status |
PARTNER_SUSPENDED |
Partner has been suspended |
Idempotency
For all action submissions, supply an idempotencyKey in the body (max 255 chars, unique per partner). If you retry a failed call with the same key, you get the original response back rather than a duplicate token grant. This is how to safely retry on network failure.