Skip to main content

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 as X-Timestamp header, as a string of digits.
  • METHOD — uppercase HTTP verb: GET, POST, PATCH, DELETE.
  • path — the request originalUrl, 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 the sk_ key value itself; older keys without a separate secret fall back to the sk_ 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 transformRequest in the TS sample).
  • Path mismatch. Include the query string. /v1/partner/users and /v1/partner/users?page=1 produce different signatures.
  • Wrong secret. Sign with the hmacSecret, not the sk_ key. Pass the sk_ (or pk_) as the X-Partner-Key header.
  • 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. getGET. 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.