Skip to main content

3. Authentication & HMAC Signing

3. Authentication & HMAC Signing

Authentication & HMAC Signing

Server-to-server endpointsrequests requiremust threebe headerssigned. onBrowser every request. Browser/widget endpoints requirerequests only one.need Geta thesepublishable wrong and the gateway returns 401 Unauthorized.key.

RequiredWhich headersauthentication do I use?

Server-to-server (HMAC)

HeaderEndpoint type ValueKey
Headers X-Partner-KeyBrowser Yourdonation/config 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-Typeendpoints application/jsonpk_... for POST/PATCH

Widget (publishable key only)

Header Value X-Partner-Key YourBackend publishableread key,endpoints e.g.sk_... plus HMAC X-Partner-Key, pk_live_<64hex>X-Timestamp, X-Signature Backend write endpoints sk_... plus HMAC X-Partner-Key, X-Timestamp, X-Signature

If an endpoint says it requires a secret key, pk_pk_... keys alsoare workrejected.

on

Make read-onlyyour HMACfirst endpointssigned ifrequest

you

Set signenvironment variables:

export SIR_BASE_URL="https://devapi.sirgiving.org"
export SIR_SECRET_KEY="sk_test_..."
export SIR_HMAC_SECRET="your-hmac-secret"

Call a read endpoint:

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 "$SIR_HMAC_SECRET" -hex \
  | awk '{print $2}')

curl "$SIR_BASE_URL$PATH_" \
  -H "X-Partner-Key: $SIR_SECRET_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG"

If the request.request Mutationsucceeds, endpointsyour decoratedkey, @RequiresSecretKey()timestamp, rejectand pk_signature keysare with 403 Forbidden.valid.

Signature algorithm

The server computes the same value and compares it to X-Signature.

bodyHash      = SHA256_hex(rawRequestBody)
signedPayload = ${timestamp}${METHOD}${path}${SHA256_hex(body)}timestamp X-Signature+ METHOD + pathWithQueryString + bodyHash
signature     = HMAC-SHA256_hex(HMAC_SHA256_hex(hmacSecret, signedPayload)

Components:Rules:

  • timestamp is the same valuestring assent in X-Timestamp header, as a string of digits..
  • METHOD is uppercaseuppercase, HTTPsuch verb:as GET, or POST, PATCH, DELETE.
  • pathpathWithQueryString includes the request originalUrl, including query string. For example /v1/partner/users?page=1&limit=20.
  • SHA256_hex(body)rawRequestBody must lowercase hex SHA-256 ofbe the raw request bodyexact bytes. Forsent emptyover HTTP.
Empty bodies (typical GET), use the SHA-256 hash of the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.string. hmacSecretThe timestamp themust signingbe secretwithin returned5 alongsideminutes yourof keyserver (NOT the sk_ key value itself; older keys without a separate secret fall back to the sk_ value but trigger a deprecation warning).time.

The signature is compared with constant-time crypto.timingSafeEqual.

Reference recipes

Node.js / TypeScript

helper
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-hexexport function sign(signSirRequest(method: string, path: string, body?: object)unknown) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const rawBody = body === undefined ? '' : 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)process.env.SIR_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,rawBody,
    headers: {
      'X-Partner-Key': PARTNER_KEY,process.env.SIR_SECRET_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

Use

the exact importrawBody hashlib,string hmac,returned json,by 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"

Commonthe signing pitfalls

function
    as Bodythe re-serialization.request Ifbody. Do not let your HTTP client re-serializesserialize JSONit 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. get ≠ GET. The server uppercases before signing.

    ErrorCommon responseserrors

    All return 401 Unauthorized with this JSON shape:

    { "error": "INVALID_SIGNATURE", "message": "Request signature verification failed" }
    

    Codes you may see:

    Error code Meaning
    Fix INVALID_API_KEY MissingMissing, key,malformed, malformed format, not found,inactive, expired, or doesn'twrong matchkey Check the key value and environment TIMESTAMP_EXPIRED Timestamp missing or outside the ±5-minute window Sync server time and regenerate signature INVALID_SIGNATURE SignatureHMAC missingdid ornot match Check path, query string, body bytes, and HMAC didn't verifysecret PARTNER_NOT_ACTIVE Partner recordstatus is not inactive ACTIVECheck partner approval/status PARTNER_SUSPENDED Partner has beenis suspended Contact SIR Giving support

    Idempotency

    ForAction allsubmissions action submissions, supplyrequire an idempotencyKey. inUse a stable key for the bodyreal-world (maxoperation, 255such chars,as uniqueorder_98765 peror partner)volunteer_shift_abc123.

    If you retry a failednetwork call times out, retry with the same key,idempotencyKey. youSIR getGiving returns the original responseresult backinstead ratherof than aissuing duplicate token grant. This is how to safely retry on network failure.rewards.