Generated from renderApiRequestSigningGuide()

API request signing

Generated from the request-signing module. Regenerate this page from renderApiRequestSigningGuide(). Do not type the header names or the canonical string by hand.

High-risk writes accept a signed request. The signature covers the method, the path including the query string, a unix timestamp, a one-time nonce, and the SHA-256 of the raw body. Lists still use cursor, limit, filter, and sort. Errors still use { success: false, error } with code, message, remediation, and requestId. Rate-limit responses still return limit, remaining, and reset headers.

Enforcement runs when PYROSYNC_REQUEST_SIGNING_SECRET is set. The check does not change who is authenticated. Provider paths whose segments include webhook or webhooks keep their own signatures.

Which requests are signed

A request under /api/ is high-risk when it is not a provider webhook and any of these is true:

  • The method is DELETE.
  • The method is POST, PUT, or PATCH and a path segment is one of api-keys, credentials, model-credentials, secrets, billing, checkout, subscriptions, invoices, payments, sessions, operator, infrastructure, or ends in -credentials or -secrets.

GET, HEAD, and OPTIONS are not signed by this mechanism.

Headers

  • x-pyrosync-timestamp: unix seconds.
  • x-pyrosync-nonce: 16 to 128 characters from A-Z, a-z, 0-9, _, and -. A nonce is accepted once inside the window.
  • x-pyrosync-signature: v1= plus the lowercase hex HMAC-SHA256 of the canonical string.

The timestamp must be within 300 seconds of the server clock.

Canonical string

Five lines, separated by newline:

METHOD
/path?query
timestamp
nonce
sha256-hex of the raw body

The method is uppercase. An empty body hashes as the empty string.

Result

  • A fresh, matching signature is accepted.
  • A missing header is REQUEST_SIGNATURE_MISSING (401).
  • A bad nonce, a bad signature shape, or a mismatched HMAC is REQUEST_SIGNATURE_INVALID (401). A failed attempt does not consume the nonce.
  • A timestamp outside the window is REQUEST_SIGNATURE_STALE (401).
  • A second use of the same nonce inside the window is REQUEST_SIGNATURE_REPLAY (409).

One process keeps nonces in memory. More than one server needs a shared store with the same claim rule.

Reference implementations

TypeScript:

import { signPublicApiRequest } from '@pyrosync/shared';

const signed = signPublicApiRequest({
  secret: process.env.PYROSYNC_REQUEST_SIGNING_SECRET ?? '',
  method: 'POST',
  path: '/api/v1/api-keys',
  body,
  timestamp: Math.floor(Date.now() / 1000),
  nonce,
});

Python:

import hashlib, hmac
canonical = '\n'.join([
    method.upper(),
    path,
    str(timestamp),
    nonce,
    hashlib.sha256(body.encode()).hexdigest(),
])
signature = "v1=" + hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()

curl:

curl -sS -X POST "$HOST/api/v1/api-keys" \
  -H "authorization: Bearer $API_KEY" \
  -H "x-pyrosync-timestamp: $TS" \
  -H "x-pyrosync-nonce: $NONCE" \
  -H "x-pyrosync-signature: $SIG" \
  -H "content-type: application/json" \
  --data "$BODY"