X-Request-Signature header for all v3 API access. The JWT carries identity and is single-use with a 60-second maximum TTL; the request signature binds the call to that specific JWT, request method, path, query, and body. You mint both per request by signing with the name and privateKey from the API key JSON you download from the OpenFX dashboard.
Two breaks from older integrations: old code does not work unchanged.
- The JWT TTL ceiling drops to 60 seconds: JWTs minted with
exp = iat + 120now return401AUTH_TOKEN_INVALID_CONFIG. - Every request now requires an
X-Request-SignatureES256 signature header: calls without it are rejected with401AUTH_TOKEN_INVALID.
JWT shape
A v3 JWT carries the claims and header values below. See Mint a JWT and sign the request for how each language builds these.| Field | Where | Value | Notes |
|---|---|---|---|
aud | claim | ["developer-api:v3"] | Audience identifier; fixed string. |
iss | claim | "openfx" | Issuer; fixed string. |
sub | claim | name from the API key JSON | Format: org/{org-id}/apiKey/{api-key-id}. |
iat | claim | seconds since epoch | Issued-at timestamp. |
nbf | claim | seconds since epoch | Not-before timestamp; usually equal to iat. |
exp | claim | iat + 60 | Max 60 seconds. Larger values return AUTH_TOKEN_INVALID_CONFIG. |
alg | header | "ES256" | Only ES256 is accepted. |
typ | header | "JWT" | Fixed token-type header. |
kid | header | name from the API key JSON | Key identifier; same value as sub. |
nonce | header | random UUID or 16 random bytes hex-encoded | Server-enforced unique for at least 80 seconds. Reuse → 401 AUTH_TOKEN_INVALID (with details.reason: nonce_replay). This same value goes into the request-signature canonical string (binds the signature to the JWT). |
JWTs expire after 60 seconds. Mint a fresh one per request, not per session. Replaying a
nonce is rejected with 401 AUTH_TOKEN_INVALID (details.reason: nonce_replay). Setting exp - iat > 60 seconds returns AUTH_TOKEN_INVALID_CONFIG. A missing X-Request-Signature header is likewise rejected with 401 AUTH_TOKEN_INVALID.API key scopes
Selecting Write when you create an API key grants both read and write access, so you can also call GET endpoints with that key. Selecting Read grants read access only.POST /v3/fx/quotes requires read access even though it creates a quote; the other public POST operations require write access. A key without the required permission receives 403 AUTH_INSUFFICIENT_SCOPE.
Request signing
Every v3 request carries anX-Request-Signature header (an ES256 signature, IEEE P-1363 fixed 64-byte r‖s encoding, base64url-encoded) over the canonical string:
<METHOD>\n<CANONICAL_PATH>\n<CANONICAL_QUERY>\n<NONCE>\n<SHA256_HEX(body)>
| Component | How to build it |
|---|---|
METHOD | Uppercase HTTP method (GET, POST). |
CANONICAL_PATH | Run the request path through the 7-step canonicalization below. |
CANONICAL_QUERY | Sort decoded parameters ascending by key, then value for repeated keys. Encode each pair with encodeURIComponent semantics (space → %20) and join with &. Empty when there are no parameters. |
NONCE | The same nonce value placed in the JWT header. Sharing this value between the JWT and the signature binds them together. A captured signature cannot be paired with a different JWT. |
SHA256_HEX(body) | Lowercase hex SHA-256 of the raw request body bytes. An empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 (the hash of the empty string). |
kid to look up the matching public key and verifies both the JWT signature and the request signature against it.
Path canonicalization (7 steps)
Apply these steps in order before signing. The server applies the same canonicalization to the request path.- Empty path becomes
/. - Normalize percent-encoding per RFC 3986 §6.2.2: decode unreserved octets (
%70→p) and uppercase hex for reserved octets (%2f→%2F). - Merge consecutive slashes (
//a///b→/a/b). - Remove dot-segments per RFC 3986 §5.2.4 (
/a/./b/../c→/a/c). Encoded unreserved dots are normalized first, so/a/%2E/bbecomes/a/b. - Do NOT re-encode any character that wasn’t already encoded.
- Preserve case (paths are case-sensitive).
- Preserve trailing slash (
/a/and/aare distinct).
Three failure modes you will hit if you skip canonicalization.
URL.pathname(or its language equivalent) collapses dot-segments but does NOT merge//, sohttps://api.openfx.com//v3/fx/pairssigns//v3/fx/pairswhile the server hashes/v3/fx/pairs.- Re-serializing the JSON body between hashing and sending produces different bytes. The server’s SHA-256 will not match. Serialize once and pass the same bytes to both the hasher and the HTTP client.
- Default ECDSA encoding in most languages is DER; the server expects IEEE P-1363 fixed 64-byte
r‖s. Each sample below uses the language’s idiomatic path to produce IEEE P-1363 output. Keep that setting; don’t substitute the default.
Why nonce reuse and body binding
- Same nonce in JWT and signature. Reusing the JWT’s
noncevalue as the signature nonce binds the two together. An attacker who exfiltrates a valid signature cannot pair it with a different JWT because the signature’s canonical string includes the original JWT’snonce; verifying against a new JWT (with a new nonce) fails. - Body binding. Including
SHA256_HEX(body)in the canonical string means a captured signature only authorizes the exact request it was signed for. Without this, a stolen bearer token could be paired with an attacker-chosen body until the JWT expired. - Server-side nonce uniqueness. The edge reserves each nonce atomically for 80 seconds after the request signature verifies. An invalid signature does not consume the nonce. Reusing a reserved nonce returns
401AUTH_TOKEN_INVALID withdetails.reason: nonce_replay.
Mint a JWT and sign the request
Each sample mints a fresh 60s JWT and computes the matchingX-Request-Signature in a single helper. Treat the helper as signRequest({ method, url, body }) => { headers, body }. Call it for every request, not once per session.
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
const { sign: jwtSign } = require("jsonwebtoken");
const { createHash, createSign, randomUUID } = require("node:crypto");
// From the API key JSON downloaded in the dashboard.
const KEY_NAME = "org/{org-id}/apiKey/{api-key-id}"; // `name`
const KEY_SECRET =
"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----"; // `privateKey`
// 7-step path canonicalization — see /v3/authentication#request-signing.
const UNRESERVED_PATH_CHAR = /^[A-Za-z0-9._~-]$/;
function canonicalizePath(rawPath) {
if (!rawPath) return "/";
let path = rawPath.replace(/%[0-9A-Fa-f]{2}/g, (match) => {
const char = String.fromCharCode(Number.parseInt(match.slice(1), 16));
return UNRESERVED_PATH_CHAR.test(char) ? char : match.toUpperCase();
}); // step 3: normalize percent-encoding
path = path.replace(/\/{2,}/g, "/"); // step 2: merge //
const hadTrailingSlash = path.length > 1 && path.endsWith("/");
const input = path.split("/");
const output = [];
for (let i = 0; i < input.length; i++) {
const seg = input[i];
if (seg === "" && i !== 0 && i !== input.length - 1) continue;
if (seg === ".") continue; // step 4: drop "."
if (seg === "..") {
// step 4: pop ".."
if (output.length > 1) output.pop();
continue;
}
output.push(seg); // steps 5,6: do not re-encode; preserve case
}
path = output.join("/");
if (path === "") path = "/"; // step 1
if (hadTrailingSlash && !path.endsWith("/")) path += "/"; // step 7
return path;
}
function canonicalizeQuery(params) {
return [...params.entries()]
.sort(([keyA, valueA], [keyB, valueB]) =>
keyA < keyB ? -1 : keyA > keyB ? 1 : valueA < valueB ? -1 : valueA > valueB ? 1 : 0,
)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
// Mints a fresh JWT and computes the matching X-Request-Signature. Call this
// for every request — both rotate together and must never be reused.
function signRequest({ method, url, body }) {
const now = Math.floor(Date.now() / 1000);
const nonce = randomUUID();
// 1. Mint a 60s JWT. The header `nonce` is reused inside the signature canonical string.
const jwt = jwtSign(
{
aud: ["developer-api:v3"],
iss: "openfx",
sub: KEY_NAME,
iat: now,
nbf: now,
exp: now + 60,
},
KEY_SECRET,
{
algorithm: "ES256",
header: { alg: "ES256", typ: "JWT", kid: KEY_NAME, nonce },
},
);
// 2. Build the canonical string.
const parsed = new URL(url);
const canonicalPath = canonicalizePath(parsed.pathname);
// Sort by byte/codepoint, NOT localeCompare — server uses raw comparison.
const canonicalQuery = canonicalizeQuery(parsed.searchParams);
const bodyBytes = body ?? ""; // serialize once: same bytes go to hasher and HTTP client
const bodyHash = createHash("sha256").update(bodyBytes).digest("hex");
const canonical = [
method.toUpperCase(),
canonicalPath,
canonicalQuery,
nonce,
bodyHash,
].join("\n");
// 3. Sign with IEEE P-1363 (fixed 64-byte r‖s) — NOT DER.
const signer = createSign("SHA256");
signer.update(canonical);
signer.end();
const signature = signer
.sign({ key: KEY_SECRET, dsaEncoding: "ieee-p1363" })
.toString("base64url");
return {
headers: {
Authorization: `Bearer ${jwt}`,
"X-Request-Signature": signature,
"Content-Type": "application/json",
"X-App-Mode": "LIVE", // For sandbox the header value should be "SANDBOX"
},
body: bodyBytes,
};
}
module.exports = { signRequest, canonicalizePath, canonicalizeQuery };
if (require.main === module) {
const { headers } = signRequest({
method: "GET",
url: "https://api.openfx.com/v3/fx/pairs",
});
console.log(headers);
}
"""Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3."""
import base64
import hashlib
import re
import secrets
import time
from typing import Optional, Tuple
from urllib.parse import urlsplit, parse_qsl, quote
import jwt
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
# From the API key JSON downloaded in the dashboard.
KEY_NAME = "org/{org-id}/apiKey/{api-key-id}" # `name`
KEY_SECRET_PEM = b"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----" # `privateKey`
def canonicalize_path(raw_path: str) -> str:
"""7-step path canonicalization. See /v3/authentication#request-signing."""
if not raw_path:
return "/"
unreserved = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
raw_path = re.sub(
r"%[0-9A-Fa-f]{2}",
lambda match: (
chr(int(match.group()[1:], 16))
if chr(int(match.group()[1:], 16)) in unreserved
else match.group().upper()
),
raw_path,
) # step 3: decode unreserved octets and uppercase reserved escapes
while "//" in raw_path: # step 2: merge consecutive slashes
raw_path = raw_path.replace("//", "/")
had_trailing = len(raw_path) > 1 and raw_path.endswith("/")
segments = raw_path.split("/")
out: list[str] = []
for i, seg in enumerate(segments):
if seg == "" and i not in (0, len(segments) - 1):
continue
if seg == ".": # step 4
continue
if seg == "..": # step 4
if len(out) > 1:
out.pop()
continue
out.append(seg) # steps 5,6: do not re-encode; preserve case
path = "/".join(out) or "/"
if had_trailing and not path.endswith("/"): # step 7
path += "/"
return path
def canonicalize_query(params: list[tuple[str, str]]) -> str:
"""Sort decoded query entries by key then value before percent-encoding."""
params = sorted(params, key=lambda kv: (kv[0], kv[1]))
safe_chars = "~!*'()"
return "&".join(f"{quote(k, safe=safe_chars)}={quote(v, safe=safe_chars)}" for k, v in params)
def _sign_es256_p1363(private_key, message: bytes) -> bytes:
"""ES256 sign producing IEEE P-1363 fixed 64-byte r‖s output.
cryptography returns DER by default; decode and concat r,s as fixed-width big-endian.
"""
der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der_sig)
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def sign_request(
method: str, url: str, body: Optional[bytes] = None
) -> Tuple[dict, bytes]:
"""Mints a fresh JWT and computes the matching X-Request-Signature. Call this
for every request — both rotate together and must never be reused."""
body_bytes = body or b"" # serialize once: same bytes to hasher and HTTP client
now = int(time.time())
nonce = secrets.token_hex(16)
# 1. Mint a 60s JWT. Header `nonce` is reused inside the signature canonical string.
payload = {
"aud": ["developer-api:v3"],
"iss": "openfx",
"sub": KEY_NAME,
"iat": now,
"nbf": now,
"exp": now + 60,
}
jwt_token = jwt.encode(
payload,
KEY_SECRET_PEM,
algorithm="ES256",
headers={"alg": "ES256", "typ": "JWT", "kid": KEY_NAME, "nonce": nonce},
)
# 2. Build canonical string.
parsed = urlsplit(url)
canonical_path = canonicalize_path(parsed.path)
params = parse_qsl(parsed.query, keep_blank_values=True)
canonical_query = canonicalize_query(params)
body_hash = hashlib.sha256(body_bytes).hexdigest()
canonical = "\n".join(
[method.upper(), canonical_path, canonical_query, nonce, body_hash]
).encode("utf-8")
# 3. ES256 sign producing IEEE P-1363 fixed 64-byte r‖s — NOT DER.
private_key = serialization.load_pem_private_key(KEY_SECRET_PEM, password=None)
sig_p1363 = _sign_es256_p1363(private_key, canonical)
signature = base64.urlsafe_b64encode(sig_p1363).rstrip(b"=").decode("ascii")
headers = {
"Authorization": f"Bearer {jwt_token}",
"X-Request-Signature": signature,
"Content-Type": "application/json",
"X-App-Mode": "LIVE" # For sandbox the header value should be "SANDBOX"
}
return headers, body_bytes
if __name__ == "__main__":
headers, _ = sign_request("GET", "https://api.openfx.com/v3/fx/pairs")
print(headers)
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
// Uses only the built-in JDK security APIs — no external dependency.
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.KeyFactory;
import java.security.Signature;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.*;
public class SignRequest {
// From the API key JSON downloaded in the dashboard.
static final String KEY_NAME = "org/{org-id}/apiKey/{api-key-id}"; // `name`
static final String KEY_SECRET_PEM =
"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----"; // `privateKey`
public static void main(String[] args) throws Exception {
Map<String, String> headers = signRequest(
"GET", "https://api.openfx.com/v3/fx/pairs", new byte[0]);
System.out.println(headers);
}
/** 7-step path canonicalization. See /v3/authentication#request-signing. */
static String canonicalizePath(String rawPath) {
if (rawPath == null || rawPath.isEmpty()) return "/";
StringBuilder normalized = new StringBuilder();
for (int i = 0; i < rawPath.length(); i++) {
if (rawPath.charAt(i) == '%' && i + 2 < rawPath.length()) {
int high = Character.digit(rawPath.charAt(i + 1), 16);
int low = Character.digit(rawPath.charAt(i + 2), 16);
if (high >= 0 && low >= 0) {
char decoded = (char) ((high << 4) | low);
if (isUnreserved(decoded)) normalized.append(decoded);
else normalized.append('%').append(Character.toUpperCase(rawPath.charAt(i + 1)))
.append(Character.toUpperCase(rawPath.charAt(i + 2)));
i += 2;
continue;
}
}
normalized.append(rawPath.charAt(i));
}
rawPath = normalized.toString(); // step 3: normalize percent-encoding before dot-segment removal
while (rawPath.contains("//")) rawPath = rawPath.replace("//", "/"); // step 2
boolean hadTrailing = rawPath.length() > 1 && rawPath.endsWith("/");
String[] segments = rawPath.split("/");
List<String> out = new ArrayList<>();
for (int i = 0; i < segments.length; i++) {
String seg = segments[i];
if (seg.isEmpty() && i != 0 && i != segments.length - 1) continue;
if (seg.equals(".")) continue; // step 4
if (seg.equals("..")) { // step 4
if (out.size() > 1) out.remove(out.size() - 1);
continue;
}
out.add(seg); // steps 5,6: do not re-encode; preserve case
}
String path = String.join("/", out);
if (path.isEmpty()) path = "/";
if (hadTrailing && !path.endsWith("/")) path += "/"; // step 7
return path;
}
private static boolean isUnreserved(char c) {
return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
|| c == '-' || c == '.' || c == '_' || c == '~';
}
static String canonicalizeQuery(String rawQuery) throws Exception {
if (rawQuery == null || rawQuery.isEmpty()) return "";
List<Map.Entry<String, String>> params = new ArrayList<>();
for (String pair : rawQuery.split("&", -1)) {
if (pair.isEmpty()) continue;
int equals = pair.indexOf('=');
String key = equals < 0 ? pair : pair.substring(0, equals);
String value = equals < 0 ? "" : pair.substring(equals + 1);
params.add(new AbstractMap.SimpleImmutableEntry<>(
URLDecoder.decode(key, StandardCharsets.UTF_8),
URLDecoder.decode(value, StandardCharsets.UTF_8)));
}
params.sort(Map.Entry.<String, String>comparingByKey().thenComparing(Map.Entry::getValue));
List<String> encoded = new ArrayList<>();
for (Map.Entry<String, String> param : params) {
encoded.add(pctEncode(param.getKey()) + "=" + pctEncode(param.getValue()));
}
return String.join("&", encoded);
}
static ECPrivateKey loadPrivateKey(String pem) throws Exception {
String cleaned = pem.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(cleaned);
KeyFactory kf = KeyFactory.getInstance("EC");
return (ECPrivateKey) kf.generatePrivate(new PKCS8EncodedKeySpec(der));
}
/** RFC 3986 percent-encoding. URLEncoder.encode is form-urlencoded
* (space -> "+"); the spec requires "%20". */
static String pctEncode(String s) {
final char[] hex = "0123456789ABCDEF".toCharArray();
StringBuilder encoded = new StringBuilder();
for (byte raw : s.getBytes(StandardCharsets.UTF_8)) {
int c = raw & 0xff;
if (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
|| c == '-' || c == '_' || c == '.' || c == '!' || c == '~'
|| c == '*' || c == '\'' || c == '(' || c == ')') {
encoded.append((char) c);
} else {
encoded.append('%').append(hex[c >>> 4]).append(hex[c & 0x0f]);
}
}
return encoded.toString();
}
static String base64UrlNoPad(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/** ES256 sign producing IEEE P-1363 fixed 64-byte r‖s — NOT DER. */
static byte[] signEs256P1363(ECPrivateKey key, byte[] message) throws Exception {
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(key);
sig.update(message);
return derToP1363(sig.sign());
}
static byte[] derToP1363(byte[] der) {
int offset = 2; // SEQUENCE tag + length
offset++; // INTEGER tag
int rLen = der[offset++] & 0xff;
byte[] r = Arrays.copyOfRange(der, offset, offset + rLen);
offset += rLen;
offset++; // INTEGER tag
int sLen = der[offset++] & 0xff;
byte[] s = Arrays.copyOfRange(der, offset, offset + sLen);
byte[] out = new byte[64];
System.arraycopy(toFixed32(r), 0, out, 0, 32);
System.arraycopy(toFixed32(s), 0, out, 32, 32);
return out;
}
static byte[] toFixed32(byte[] value) {
int start = 0;
while (start < value.length - 32 && value[start] == 0) start++;
byte[] trimmed = Arrays.copyOfRange(value, start, value.length);
if (trimmed.length == 32) return trimmed;
byte[] out = new byte[32];
System.arraycopy(trimmed, 0, out, 32 - trimmed.length, trimmed.length);
return out;
}
static String signJwtEs256(String keyName, ECPrivateKey key, String nonce, long now) throws Exception {
String headerJson = "{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\":\"" + keyName + "\",\"nonce\":\"" + nonce + "\"}";
String payloadJson = "{\"aud\":[\"developer-api:v3\"],\"iss\":\"openfx\",\"sub\":\"" + keyName
+ "\",\"iat\":" + now + ",\"nbf\":" + now + ",\"exp\":" + (now + 60) + "}";
String encodedHeader = base64UrlNoPad(headerJson.getBytes(StandardCharsets.UTF_8));
String encodedPayload = base64UrlNoPad(payloadJson.getBytes(StandardCharsets.UTF_8));
String signingInput = encodedHeader + "." + encodedPayload;
byte[] sig = signEs256P1363(key, signingInput.getBytes(StandardCharsets.UTF_8));
return signingInput + "." + base64UrlNoPad(sig);
}
/** Mints a fresh JWT and computes the matching X-Request-Signature. Call this
* for every request — both rotate together and must never be reused. */
static Map<String, String> signRequest(String method, String urlStr, byte[] body) throws Exception {
long now = Instant.now().getEpochSecond();
byte[] nonceBytes = new byte[16];
new java.security.SecureRandom().nextBytes(nonceBytes);
StringBuilder nonceHex = new StringBuilder();
for (byte b : nonceBytes) nonceHex.append(String.format("%02x", b));
String nonce = nonceHex.toString();
ECPrivateKey key = loadPrivateKey(KEY_SECRET_PEM);
String jwtToken = signJwtEs256(KEY_NAME, key, nonce, now);
URI uri = URI.create(urlStr);
String canonicalPath = canonicalizePath(uri.getRawPath());
String canonicalQuery = canonicalizeQuery(uri.getRawQuery());
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] bodyHashBytes = sha256.digest(body);
StringBuilder bodyHashHex = new StringBuilder();
for (byte b : bodyHashBytes) bodyHashHex.append(String.format("%02x", b));
String canonical = String.join("\n", method.toUpperCase(), canonicalPath, canonicalQuery, nonce, bodyHashHex.toString());
byte[] sigP1363 = signEs256P1363(key, canonical.getBytes(StandardCharsets.UTF_8));
String signature = base64UrlNoPad(sigP1363);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Authorization", "Bearer " + jwtToken);
headers.put("X-Request-Signature", signature);
headers.put("Content-Type", "application/json");
headers.put("X-App-Mode", "LIVE"); // For sandbox the header value should be "SANDBOX"
return headers;
}
}
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
// Requires OpenSSL. On macOS with Homebrew, add -I/opt/homebrew/include
// -L/opt/homebrew/lib (Apple Silicon) or -I/usr/local/include -L/usr/local/lib
// (Intel) — OpenSSL headers aren't on the default search path there.
#include <openssl/pem.h>
#include <openssl/evp.h>
#include <openssl/sha.h>
#include <openssl/ecdsa.h>
#include <openssl/rand.h>
#include <iostream>
#include <iomanip>
#include <sstream>
#include <string>
#include <vector>
#include <chrono>
#include <stdexcept>
#include <algorithm>
#include <cctype>
struct SignedRequest {
std::string authHeader;
std::string sigHeader;
};
// From the API key JSON downloaded in the dashboard.
static const char* KEY_NAME = "org/{org-id}/apiKey/{api-key-id}"; // `name`
static const char* KEY_SECRET_PEM =
"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----"; // `privateKey`
std::string b64url(const unsigned char* data, size_t len) {
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
int val = 0, valb = -6;
for (size_t i = 0; i < len; i++) {
val = (val << 8) + data[i];
valb += 8;
while (valb >= 0) {
out.push_back(tbl[(val >> valb) & 0x3F]);
valb -= 6;
}
}
if (valb > -6) out.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]);
for (auto& c : out) {
if (c == '+') c = '-';
if (c == '/') c = '_';
}
return out;
}
std::string b64url(const std::string& s) {
return b64url(reinterpret_cast<const unsigned char*>(s.data()), s.size());
}
// RFC 3986 percent-encoding: unreserved chars pass through unescaped,
// everything else becomes %XX (uppercase hex) — including space as %20,
// never "+".
std::string pctEncode(const std::string& s) {
static const char* hex = "0123456789ABCDEF";
std::string out;
for (unsigned char c : s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|| c == '-' || c == '_' || c == '.' || c == '!' || c == '~'
|| c == '*' || c == '\'' || c == '(' || c == ')') {
out.push_back((char)c);
} else {
out.push_back('%');
out.push_back(hex[(c >> 4) & 0xF]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
int hexValue(unsigned char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
std::string pctDecode(const std::string& value) {
std::string out;
for (size_t i = 0; i < value.size(); i++) {
if (value[i] == '+') {
out.push_back(' ');
} else if (value[i] == '%' && i + 2 < value.size()) {
int high = hexValue(static_cast<unsigned char>(value[i + 1]));
int low = hexValue(static_cast<unsigned char>(value[i + 2]));
if (high >= 0 && low >= 0) {
out.push_back(static_cast<char>((high << 4) | low));
i += 2;
} else {
out.push_back(value[i]);
}
} else {
out.push_back(value[i]);
}
}
return out;
}
// Sort the query parameters ascending by key and percent-encode each
// key=value pair. See /v3/authentication#request-signing.
std::string buildCanonicalQuery(const std::string& query) {
if (query.empty()) return "";
std::vector<std::pair<std::string, std::string>> pairs;
size_t start = 0;
while (start <= query.size()) {
size_t amp = query.find('&', start);
std::string pair = query.substr(start, amp == std::string::npos ? std::string::npos : amp - start);
size_t eq = pair.find('=');
if (eq == std::string::npos) {
pairs.push_back({pctDecode(pair), ""});
} else {
pairs.push_back({pctDecode(pair.substr(0, eq)), pctDecode(pair.substr(eq + 1))});
}
if (amp == std::string::npos) break;
start = amp + 1;
}
std::sort(pairs.begin(), pairs.end(), [] (const auto& a, const auto& b) {
return a.first < b.first || (a.first == b.first && a.second < b.second);
});
std::string out;
for (size_t i = 0; i < pairs.size(); i++) {
if (i > 0) out += "&";
out += pctEncode(pairs[i].first) + "=" + pctEncode(pairs[i].second);
}
return out;
}
std::string sha256hex(const std::string& data) {
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast<const unsigned char*>(data.data()), data.size(), hash);
std::ostringstream oss;
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++)
oss << std::hex << std::setfill('0') << std::setw(2) << (int)hash[i];
return oss.str();
}
// 7-step path canonicalization. See /v3/authentication#request-signing.
std::string canonicalizePath(std::string rawPath) {
if (rawPath.empty()) return "/";
std::string normalized;
for (size_t i = 0; i < rawPath.size(); i++) {
if (rawPath[i] == '%' && i + 2 < rawPath.size()) {
int high = hexValue(static_cast<unsigned char>(rawPath[i + 1]));
int low = hexValue(static_cast<unsigned char>(rawPath[i + 2]));
if (high >= 0 && low >= 0) {
unsigned char decoded = static_cast<unsigned char>((high << 4) | low);
bool unreserved = (decoded >= 'A' && decoded <= 'Z') || (decoded >= 'a' && decoded <= 'z')
|| (decoded >= '0' && decoded <= '9') || decoded == '-' || decoded == '.'
|| decoded == '_' || decoded == '~';
if (unreserved) {
normalized.push_back(static_cast<char>(decoded));
} else {
static const char* hex = "0123456789ABCDEF";
normalized.push_back('%');
normalized.push_back(hex[(decoded >> 4) & 0xF]);
normalized.push_back(hex[decoded & 0xF]);
}
i += 2;
continue;
}
}
normalized.push_back(rawPath[i]);
}
rawPath = normalized; // step 3: normalize percent-encoding before dot-segment removal
size_t pos;
while ((pos = rawPath.find("//")) != std::string::npos) rawPath.replace(pos, 2, "/"); // step 2
bool hadTrailing = rawPath.size() > 1 && rawPath.back() == '/';
std::vector<std::string> raw;
{ std::string tmp; for (char c : rawPath) { if (c == '/') { raw.push_back(tmp); tmp.clear(); } else tmp += c; } raw.push_back(tmp); }
std::vector<std::string> out;
for (size_t i = 0; i < raw.size(); i++) {
const std::string& s = raw[i];
if (s.empty() && i != 0 && i != raw.size() - 1) continue;
if (s == ".") continue; // step 4
if (s == "..") { if (out.size() > 1) out.pop_back(); continue; } // step 4
out.push_back(s); // steps 5,6: do not re-encode; preserve case
}
std::string path;
for (size_t i = 0; i < out.size(); i++) { path += out[i]; if (i + 1 < out.size()) path += "/"; }
if (path.empty()) path = "/";
if (hadTrailing && (path.empty() || path.back() != '/')) path += "/"; // step 7
return path;
}
// ES256 sign producing IEEE P-1363 fixed 64-byte r‖s — NOT DER.
std::vector<unsigned char> signEs256P1363(EVP_PKEY* pkey, const std::string& message) {
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
if (!ctx) {
throw std::runtime_error("Could not allocate EVP_MD_CTX.");
}
EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, pkey);
EVP_DigestSignUpdate(ctx, message.data(), message.size());
size_t sigLen = 0;
EVP_DigestSignFinal(ctx, nullptr, &sigLen);
std::vector<unsigned char> der(sigLen);
EVP_DigestSignFinal(ctx, der.data(), &sigLen);
EVP_MD_CTX_free(ctx);
const unsigned char* p = der.data();
ECDSA_SIG* sig = d2i_ECDSA_SIG(nullptr, &p, (long)sigLen);
if (!sig) {
throw std::runtime_error("Could not parse the DER-encoded ECDSA signature.");
}
const BIGNUM* r; const BIGNUM* s;
ECDSA_SIG_get0(sig, &r, &s);
std::vector<unsigned char> out(64, 0);
BN_bn2binpad(r, out.data(), 32);
BN_bn2binpad(s, out.data() + 32, 32);
ECDSA_SIG_free(sig);
return out;
}
EVP_PKEY* loadPrivateKey(const std::string& pem) {
BIO* bio = BIO_new_mem_buf(pem.data(), (int)pem.size());
if (!bio) {
throw std::runtime_error("Could not allocate BIO for the private key PEM.");
}
EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!pkey) {
throw std::runtime_error(
"Could not parse the private key PEM. Replace KEY_SECRET_PEM with the "
"real `privateKey` value from your API key JSON.");
}
return pkey;
}
static std::string signJwtEs256(const std::string& keyName, EVP_PKEY* pkey, const std::string& nonce, long now) {
std::ostringstream headerJson;
headerJson << "{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\":\"" << keyName << "\",\"nonce\":\"" << nonce << "\"}";
std::ostringstream payloadJson;
payloadJson << "{\"aud\":[\"developer-api:v3\"],\"iss\":\"openfx\",\"sub\":\"" << keyName
<< "\",\"iat\":" << now << ",\"nbf\":" << now << ",\"exp\":" << (now + 60) << "}";
std::string encHeader = b64url(headerJson.str());
std::string encPayload = b64url(payloadJson.str());
std::string signingInput = encHeader + "." + encPayload;
auto sig = signEs256P1363(pkey, signingInput);
return signingInput + "." + b64url(sig.data(), sig.size());
}
// Mints a fresh JWT and computes the matching X-Request-Signature. Call this
// for every request — both rotate together and must never be reused.
SignedRequest signRequest(const std::string& method, const std::string& urlPath,
const std::string& query, const std::string& body) {
EVP_PKEY* pkey = loadPrivateKey(KEY_SECRET_PEM);
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
unsigned char nonceBytes[16];
RAND_bytes(nonceBytes, 16);
std::ostringstream nonceHex;
for (int i = 0; i < 16; i++) nonceHex << std::hex << std::setfill('0') << std::setw(2) << (int)nonceBytes[i];
std::string nonce = nonceHex.str();
std::string jwt = signJwtEs256(KEY_NAME, pkey, nonce, now);
std::string canonicalPath = canonicalizePath(urlPath);
std::string canonicalQuery = buildCanonicalQuery(query);
std::string bodyHash = sha256hex(body);
std::string canonical = method + "\n" + canonicalPath + "\n" + canonicalQuery + "\n" + nonce + "\n" + bodyHash;
auto reqSig = signEs256P1363(pkey, canonical);
std::string signature = b64url(reqSig.data(), reqSig.size());
EVP_PKEY_free(pkey);
return { "Bearer " + jwt, signature };
}
#ifndef SIGN_REQUEST_NO_MAIN
int main() {
try {
SignedRequest req = signRequest("GET", "/v3/fx/pairs", "", "");
std::cout << "Authorization: " << req.authHeader << std::endl;
std::cout << "X-Request-Signature: " << req.sigHeader << std::endl;
std::cout << "X-App-Mode: " << "LIVE" << std::endl; // For sandbox the header value should be "SANDBOX"
return 0;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
}
#endif
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
package auth
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// From the API key JSON downloaded in the dashboard.
const (
KeyName = "org/{org-id}/apiKey/{api-key-id}" // `name`
KeySecretPEM = "-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----" // `privateKey`
)
// SignedRequest carries the headers and body bytes to send.
type SignedRequest struct {
Headers map[string]string
Body []byte
}
// CanonicalizePath performs the 7-step path canonicalization documented at
// /v3/authentication#request-signing.
func CanonicalizePath(raw string) string {
if raw == "" {
return "/"
}
var normalized strings.Builder
for i := 0; i < len(raw); i++ {
if raw[i] == '%' && i+2 < len(raw) {
decoded, err := strconv.ParseUint(raw[i+1:i+3], 16, 8)
if err == nil {
b := byte(decoded)
if b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || strings.ContainsRune("-._~", rune(b)) {
normalized.WriteByte(b)
} else {
normalized.WriteString(strings.ToUpper(raw[i : i+3]))
}
i += 2
continue
}
}
normalized.WriteByte(raw[i])
}
raw = normalized.String() // step 3: normalize percent-encoding before dot-segment removal
for strings.Contains(raw, "//") { // step 2
raw = strings.ReplaceAll(raw, "//", "/")
}
hadTrailing := len(raw) > 1 && strings.HasSuffix(raw, "/")
segments := strings.Split(raw, "/")
out := make([]string, 0, len(segments))
for i, seg := range segments {
if seg == "" && i != 0 && i != len(segments)-1 {
continue
}
if seg == "." { // step 4
continue
}
if seg == ".." { // step 4
if len(out) > 1 {
out = out[:len(out)-1]
}
continue
}
out = append(out, seg) // steps 5,6: do not re-encode; preserve case
}
path := strings.Join(out, "/")
if path == "" {
path = "/"
}
if hadTrailing && !strings.HasSuffix(path, "/") { // step 7
path += "/"
}
return path
}
func parsePrivateKey(pemStr string) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("no PEM block found — replace KeySecretPEM with your real privateKey")
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
ecKey, ok := k.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA key")
}
return ecKey, nil
}
func pctEncode(s string) string {
const hex = "0123456789ABCDEF"
var encoded strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.ContainsRune("-_.!~*'()", rune(c)) {
encoded.WriteByte(c)
} else {
encoded.WriteByte('%')
encoded.WriteByte(hex[c>>4])
encoded.WriteByte(hex[c&0x0F])
}
}
return encoded.String()
}
func buildCanonicalQuery(values url.Values) string {
type queryPair struct{ key, value string }
pairs := make([]queryPair, 0, len(values))
for key, vals := range values {
for _, value := range vals {
pairs = append(pairs, queryPair{key: key, value: value})
}
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].key == pairs[j].key {
return pairs[i].value < pairs[j].value
}
return pairs[i].key < pairs[j].key
})
parts := make([]string, 0, len(pairs))
for _, pair := range pairs {
parts = append(parts, pctEncode(pair.key)+"="+pctEncode(pair.value))
}
return strings.Join(parts, "&")
}
// SignRequest mints a fresh JWT and computes the matching X-Request-Signature.
// Call this for every request — both rotate together and must never be reused.
func SignRequest(method, rawURL string, body []byte) (*SignedRequest, error) {
privateKey, err := parsePrivateKey(KeySecretPEM)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
nonce := hex.EncodeToString(nonceBytes)
now := time.Now().Unix()
// 1. Mint a 60s JWT.
claims := jwt.MapClaims{
"aud": []string{"developer-api:v3"},
"iss": "openfx",
"sub": KeyName,
"iat": now,
"nbf": now,
"exp": now + 60,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = KeyName
token.Header["nonce"] = nonce
jwtStr, err := token.SignedString(privateKey)
if err != nil {
return nil, fmt.Errorf("sign jwt: %w", err)
}
// 2. Build canonical string.
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
canonicalPath := CanonicalizePath(parsed.EscapedPath())
canonicalQuery := buildCanonicalQuery(parsed.Query())
bodyHashBytes := sha256.Sum256(body)
bodyHashHex := hex.EncodeToString(bodyHashBytes[:])
canonical := strings.Join([]string{
strings.ToUpper(method), canonicalPath, canonicalQuery, nonce, bodyHashHex,
}, "\n")
// 3. ES256 sign producing IEEE P-1363 fixed 64-byte r||s — NOT DER.
sigP1363, err := signEs256P1363(privateKey, []byte(canonical))
if err != nil {
return nil, err
}
signature := base64.RawURLEncoding.EncodeToString(sigP1363)
return &SignedRequest{
Headers: map[string]string{
"Authorization": "Bearer " + jwtStr,
"X-Request-Signature": signature,
"Content-Type": "application/json",
"X-App-Mode": "LIVE", // For sandbox the header value should be "SANDBOX"
},
Body: body,
}, nil
}
func signEs256P1363(privateKey *ecdsa.PrivateKey, message []byte) ([]byte, error) {
digest := sha256.Sum256(message)
r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:])
if err != nil {
return nil, fmt.Errorf("ecdsa sign: %w", err)
}
curveBytes := (privateKey.Curve.Params().BitSize + 7) / 8 // 32 for P-256
sig := make([]byte, 2*curveBytes)
r.FillBytes(sig[:curveBytes])
s.FillBytes(sig[curveBytes:])
return sig, nil
}
// OpenFX v3 authentication — Postman pre-request script.
// Paste into a collection's Pre-request Script tab. Mints a 60s ES256 JWT
// and computes the matching X-Request-Signature before every request.
// Collection variables: KEY_NAME, KEY_SECRET (PEM, newlines escaped as \n).
// Optional: APP_MODE (defaults to SANDBOX).
const { sign: jwtSign } = pm.require("npm:jsonwebtoken@8.5.1");
const { ec: EC } = pm.require("npm:elliptic@6.6.1");
const brorand = pm.require("npm:brorand@1.1.0");
const keyName = pm.collectionVariables.get("KEY_NAME");
const keySecret = pm.collectionVariables
.get("KEY_SECRET")
.replace(/\\n/g, "\n");
const appMode = pm.collectionVariables.get("APP_MODE") || "SANDBOX";
const now = Math.floor(Date.now() / 1000);
const nonce = Buffer.from(brorand(16)).toString("hex"); // 32 hex chars
// 1. Mint a 60s JWT (v3's max — not the 120s used on v1/v2) with the PEM key.
// The header `nonce` is reused inside the signature's canonical string below.
const jwt = jwtSign(
{
aud: ["developer-api:v3"],
iss: "openfx",
sub: keyName,
iat: now,
nbf: now,
exp: now + 60,
},
keySecret,
{
algorithm: "ES256",
header: { alg: "ES256", typ: "JWT", kid: keyName, nonce },
},
);
// 2. Build the canonical string: METHOD\nPATH\nQUERY\nNONCE\nSHA256_HEX(body).
// Resolve the complete URL first so path segments in BASE_URL are included.
// Canonicalization must match the path and query the server receives.
const method = pm.request.method.toUpperCase();
const requestUrl = new URL(pm.variables.replaceIn(pm.request.url.toString()));
const canonicalPath = canonicalizePath(requestUrl.pathname);
const canonicalQuery = pm.request.url.query
.all()
.filter((p) => !p.disabled)
.map((p) => [
pm.variables.replaceIn(p.key),
pm.variables.replaceIn(p.value ?? ""),
])
.sort(([keyA, valueA], [keyB, valueB]) =>
keyA < keyB ? -1 : keyA > keyB ? 1 : valueA < valueB ? -1 : valueA > valueB ? 1 : 0,
)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const bodyRaw = pm.variables.replaceIn(
(pm.request.body && pm.request.body.raw) || "",
);
const bodyHash = CryptoJS.SHA256(bodyRaw).toString();
const canonical = [
method,
canonicalPath,
canonicalQuery,
nonce,
bodyHash,
].join("\n");
function canonicalizePath(rawPath) {
if (!rawPath) return "/";
let path = rawPath.replace(/%[0-9A-Fa-f]{2}/g, (match) => {
const char = String.fromCharCode(Number.parseInt(match.slice(1), 16));
return /^[A-Za-z0-9._~-]$/.test(char) ? char : match.toUpperCase();
});
path = path.replace(/\/{2,}/g, "/");
const hadTrailingSlash = path.length > 1 && path.endsWith("/");
const input = path.split("/");
const output = [];
for (let i = 0; i < input.length; i++) {
const segment = input[i];
if (segment === "" && i !== 0 && i !== input.length - 1) continue;
if (segment === ".") continue;
if (segment === "..") {
if (output.length > 1) output.pop();
continue;
}
output.push(segment);
}
path = output.join("/");
if (path === "") path = "/";
if (hadTrailingSlash && !path.endsWith("/")) path += "/";
return path;
}
// 3. Extract the raw private-key scalar from the PKCS8 PEM. A P-256 PKCS8
// key is: SEQUENCE { version, AlgorithmIdentifier, OCTET STRING { SEQUENCE {
// version, OCTET STRING <32-byte scalar>, ... } } } — this reads only the
// two SEQUENCE/OCTET STRING pairs needed to reach that scalar and never
// looks at the (optional, ignored either way) trailing curve/public-key
// fields, so it doesn't matter whether your key file includes them.
function derReadTLV(buf, offset) {
const tag = buf[offset];
const lenByte = buf[offset + 1];
let lenOfLen = 0;
let length;
if (lenByte & 0x80) {
lenOfLen = lenByte & 0x7f;
length = 0;
for (let i = 0; i < lenOfLen; i++) length = (length << 8) | buf[offset + 2 + i];
} else {
length = lenByte;
}
const valueStart = offset + 2 + lenOfLen;
const valueEnd = valueStart + length;
return { tag, valueStart, valueEnd, nextOffset: valueEnd };
}
function extractEcPrivateScalar(pem) {
const base64 = pem
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("-----"))
.join("");
const der = Buffer.from(base64, "base64");
let tlv = derReadTLV(der, 0); // outer SEQUENCE (PrivateKeyInfo)
let offset = tlv.valueStart;
tlv = derReadTLV(der, offset); // version INTEGER
offset = tlv.nextOffset;
tlv = derReadTLV(der, offset); // AlgorithmIdentifier SEQUENCE — skip over
offset = tlv.nextOffset;
tlv = derReadTLV(der, offset); // privateKey OCTET STRING, wraps ECPrivateKey DER
const ecDer = der.subarray(tlv.valueStart, tlv.valueEnd);
let inner = derReadTLV(ecDer, 0); // ECPrivateKey SEQUENCE
let innerOffset = inner.valueStart;
inner = derReadTLV(ecDer, innerOffset); // version INTEGER
innerOffset = inner.nextOffset;
inner = derReadTLV(ecDer, innerOffset); // privateKey OCTET STRING — the raw scalar
const scalar = ecDer.subarray(inner.valueStart, inner.valueEnd);
// DER integers can be shorter than 32 bytes (leading zero bytes are
// dropped) or, rarely, 33 with a leading 0x00 padding byte; normalize to
// exactly 32 bytes either way.
return Buffer.concat([Buffer.alloc(32), scalar]).subarray(-32);
}
// 4. Sign SHA-256(canonical) with the extracted scalar.
const ec = new EC("p256");
const keyPair = ec.keyFromPrivate(extractEcPrivateScalar(keySecret));
const digestHex = CryptoJS.SHA256(canonical).toString();
const sig = keyPair.sign(digestHex, { canonical: true });
// Postman's Buffer polyfill may not recognize the "base64url" encoding
// name, so base64url is handled by hand (base64 + character substitution)
// rather than relying on it directly.
const signature = Buffer.concat([
sig.r.toArrayLike(Buffer, "be", 32),
sig.s.toArrayLike(Buffer, "be", 32),
])
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
// 5. Attach headers. upsert (not add) so re-running the script doesn't
// stack duplicate headers.
pm.request.headers.upsert({ key: "Authorization", value: `Bearer ${jwt}` });
pm.request.headers.upsert({
key: "X-Request-Signature",
value: signature,
});
pm.request.headers.upsert({ key: "X-App-Mode", value: appMode });
Retrieve the key name and private key from the
name/privateKey
parameters in the API key JSON. For Postman, paste the script into your
collection’s Pre-request Script, then set collection variables
KEY_NAME and KEY_SECRET plus optional APP_MODE. Needs a Postman version with pm.require("npm:...") support.Scopes
Two scopes apply, and they split on direction:| Scope | Grants |
|---|---|
read | Every GET, plus POST /v3/fx/quotes, which prices a trade without changing anything. |
write | Every other POST: trades, reversals, settlement, withdrawals, and all payments writes. |
403 AUTH_INSUFFICIENT_SCOPE. The Cheat sheet lists the scope for each operation.
Common mistakes
When token minting, signing, or use goes wrong, you’ll see one of theseAUTH_* codes. Full catalog in Errors → Auth.
| Code | When it fires | Fix |
|---|---|---|
| AUTH_TOKEN_MISSING | Authorization header absent | Add the header. |
| AUTH_TOKEN_INVALID | JWT/claim validation fails, the X-Request-Signature is missing or invalid (details.reason: request_signature_missing or request_signature_invalid), or the JWT nonce was replayed (details.reason: nonce_replay). | Re-mint with ES256 and the correct key. For signature failures: re-check IEEE P-1363 encoding, freeze the body bytes once, run the path through canonicalizePath, and confirm you reused the JWT’s nonce inside the canonical string. For a replayed nonce: generate a fresh nonce for every request. |
| AUTH_TOKEN_EXPIRED | exp is in the past | Re-mint. Check NTP clock sync on the host. |
| AUTH_TOKEN_INVALID_CONFIG | exp - iat > 60s or other unexpected claim config | Re-mint with exp = iat + 60 (or shorter). |
| AUTH_API_KEY_MISSING | The bearer token does not reference an API key | Mint the JWT with your API key name as sub and kid. |
| AUTH_API_KEY_INVALID | API key revoked or not found | Generate a new key in the dashboard. |
| AUTH_FORBIDDEN_IP | Source IP not in the key’s allowlist | Update the IP allowlist for the key. |
| AUTH_INSUFFICIENT_SCOPE | Key missing the required scope (e.g. write) | Generate a key with the needed scope. |
What’s next
Quickstart
Mint a token and make your first trade end-to-end.
Environments
Switch between Sandbox and Live modes with the same key flow.
Errors
AUTH_TOKEN_* codes and how to recover.Rate limiting
The per-org, per-environment limit, plus the headers to watch.