Per-request RSA signature + replay protection

Every V4 API request carries three headers on top of the bearer token:

HeaderValue
X-PP-SignatureBase64 RSA-SHA256 signature (your private key) over the canonical payload.
X-PP-NonceUnique random string per request.
X-PP-TimestampUnix timestamp (seconds).
The canonical payload

Both sides must build the exact same byte string, so V4 uses a versioned, key-sorted, length-prefixed form (this removes the "concatenate in the order sent" ambiguity V3 had):

v4.request|key1:len:value|key2:len:value|…

The signed fields are: all route parameters + the full request body + nonce + ts, keys sorted alphabetically. Each part is key:byte-length:value, joined with |, prefixed with v4.request|. Binding the route parameters means the path itself is tamper-proof.

Reference implementation (PHP)
function canonicalV4(string $version, array $fields): string {
    ksort($fields);
    $parts = [];
    foreach ($fields as $k => $v) {
        $s = is_scalar($v) ? (string) $v : json_encode($v);
        $parts[] = $k . ':' . strlen($s) . ':' . $s;
    }
    return $version . '|' . implode('|', $parts);
}

$fields = $routeParams + $body;          // route params + body
$fields['nonce'] = $nonce;
$fields['ts']    = (string) $ts;
$canonical = canonicalV4('v4.request', $fields);
openssl_sign($canonical, $sig, $privateKeyPem, OPENSSL_ALGO_SHA256);
$headers['X-PP-Signature'] = base64_encode($sig);
Reference implementation (Node.js)
const crypto = require('crypto');

function canonicalV4(version, fields) {
  const parts = Object.keys(fields).sort().map((k) => {
    const s = typeof fields[k] === 'object' ? JSON.stringify(fields[k]) : String(fields[k]);
    return `${k}:${Buffer.byteLength(s)}:${s}`;
  });
  return `${version}|${parts.join('|')}`;
}

const fields = { ...routeParams, ...body, nonce, ts: String(ts) };
const signer = crypto.createSign('RSA-SHA256');
signer.update(canonicalV4('v4.request', fields));
const signature = signer.sign(privateKeyPem, 'base64');
Replay protection
  • A reused nonce409 replay_detected.
  • A stale or future timestamp (outside the freshness TTL) → 409 replay_detected.
  • A tampered body or path → 401 invalid_signature.

Generate a fresh nonce and timestamp for every request, including retries of failed sends — idempotency of the operation is handled separately by the Idempotency-Key.