> ## Documentation Index
> Fetch the complete documentation index at: https://public-openfx.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Mint ES256-signed JWT bearer tokens and ES256 request signatures to access the OpenFX API.

OpenFX uses **ES256-signed JWT bearer tokens** plus a body-bound **`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](https://app.openfx.com).

<Warning>
  **Two breaks from older integrations: old code does not work unchanged.**

  1. The JWT TTL ceiling drops to **60 seconds**: JWTs minted with `exp = iat + 120` now return `401` <span class="ofx-enum">AUTH\_TOKEN\_INVALID\_CONFIG</span>.
  2. Every request now requires an **`X-Request-Signature`** ES256 signature header: calls without it are rejected with `401` <span class="ofx-enum">AUTH\_TOKEN\_INVALID</span>.

  Re-mint with the helpers below; the API key and key file format are unchanged.
</Warning>

## JWT shape

A v3 JWT carries the claims and header values below. See [Mint a JWT and sign the request](#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 <span class="ofx-enum">AUTH\_TOKEN\_INVALID\_CONFIG</span>. |
| `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` <span class="ofx-enum">AUTH\_TOKEN\_INVALID</span> (with `details.reason: nonce_replay`). This same value goes into the request-signature canonical string (binds the signature to the JWT). |

<Warning>
  JWTs expire after 60 seconds. Mint a fresh one per request, not per session. Replaying a `nonce` is rejected with `401` <span class="ofx-enum">AUTH\_TOKEN\_INVALID</span> (`details.reason: nonce_replay`). Setting `exp - iat > 60 seconds` returns <span class="ofx-enum">AUTH\_TOKEN\_INVALID\_CONFIG</span>. A missing `X-Request-Signature` header is likewise rejected with `401` <span class="ofx-enum">AUTH\_TOKEN\_INVALID</span>.
</Warning>

## 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` <span class="ofx-enum">AUTH\_INSUFFICIENT\_SCOPE</span>.

## Request signing

Every v3 request carries an **`X-Request-Signature`** header (an ES256 signature, IEEE P-1363 fixed 64-byte `r‖s` encoding, base64url-encoded) over the canonical string:

```text theme={"theme":{"light":"poimandres","dark":"poimandres"}}
<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). |

Sign the canonical string with the same API-key private key that signed the JWT. The server uses the JWT's `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.

1. **Empty path becomes `/`.**
2. **Normalize percent-encoding** per RFC 3986 §6.2.2: decode unreserved octets (`%70` → `p`) and uppercase hex for reserved octets (`%2f` → `%2F`).
3. **Merge consecutive slashes** (`//a///b` → `/a/b`).
4. **Remove dot-segments** per RFC 3986 §5.2.4 (`/a/./b/../c` → `/a/c`). Encoded unreserved dots are normalized first, so `/a/%2E/b` becomes `/a/b`.
5. **Do NOT re-encode** any character that wasn't already encoded.
6. **Preserve case** (paths are case-sensitive).
7. **Preserve trailing slash** (`/a/` and `/a` are distinct).

<Warning>
  **Three failure modes you will hit if you skip canonicalization.**

  1. `URL.pathname` (or its language equivalent) collapses dot-segments but does NOT merge `//`, so `https://api.openfx.com//v3/fx/pairs` signs `//v3/fx/pairs` while the server hashes `/v3/fx/pairs`.
  2. 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.
  3. 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.
</Warning>

### Why nonce reuse and body binding

* **Same nonce in JWT and signature.** Reusing the JWT's `nonce` value 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's `nonce`; 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 `401` <span class="ofx-enum">AUTH\_TOKEN\_INVALID</span> with `details.reason: nonce_replay`.

## Mint a JWT and sign the request

Each sample mints a fresh 60s JWT and computes the matching `X-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.

<CodeGroup>
  ```javascript Node theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // 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);
  }
  ```

  ```python Python theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  """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)
  ```

  ```java Java theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // 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;
      }
  }
  ```

  ```cpp C++ theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // 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
  ```

  ```go Go theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // 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
  }
  ```

  ```javascript Postman theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // 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 });
  ```
</CodeGroup>

<Note>
  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.
</Note>

## 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. |

A key without the scope a call needs returns `403` <span class="ofx-enum">AUTH\_INSUFFICIENT\_SCOPE</span>. The [Cheat sheet](/v3/cheat-sheet#endpoints) lists the scope for each operation.

## Common mistakes

When token minting, signing, or use goes wrong, you'll see one of these `AUTH_*` codes. Full catalog in [Errors → Auth](/v3/errors#error-code-catalog).

| Code | When it fires | Fix |
| - | - | - |
| <span class="ofx-enum">AUTH\_TOKEN\_MISSING</span> | `Authorization` header absent | Add the header. |
| <span class="ofx-enum">AUTH\_TOKEN\_INVALID</span> | 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. |
| <span class="ofx-enum">AUTH\_TOKEN\_EXPIRED</span> | `exp` is in the past | Re-mint. Check NTP clock sync on the host. |
| <span class="ofx-enum">AUTH\_TOKEN\_INVALID\_CONFIG</span> | `exp - iat > 60s` or other unexpected claim config | Re-mint with `exp = iat + 60` (or shorter). |
| <span class="ofx-enum">AUTH\_API\_KEY\_MISSING</span> | The bearer token does not reference an API key | Mint the JWT with your API key name as `sub` and `kid`. |
| <span class="ofx-enum">AUTH\_API\_KEY\_INVALID</span> | API key revoked or not found | Generate a new key in the dashboard. |
| <span class="ofx-enum">AUTH\_FORBIDDEN\_IP</span> | Source IP not in the key's allowlist | Update the IP allowlist for the key. |
| <span class="ofx-enum">AUTH\_INSUFFICIENT\_SCOPE</span> | Key missing the required scope (e.g. `write`) | Generate a key with the needed scope. |

## What's next

<CardGroup cols={2}>
  <Card title="Quickstart" icon="https://public-assets.openfx.com/docs/icons/quickstart.svg" href="/v3/quickstart">
    Mint a token and make your first trade end-to-end.
  </Card>

  <Card title="Environments" icon="https://public-assets.openfx.com/docs/icons/environments.svg" href="/v3/environments">
    Switch between Sandbox and Live modes with the same key flow.
  </Card>

  <Card title="Errors" icon="https://public-assets.openfx.com/docs/icons/errors.svg" href="/v3/errors">
    `AUTH_TOKEN_*` codes and how to recover.
  </Card>

  <Card title="Rate limiting" icon="https://public-assets.openfx.com/docs/icons/rate-limiting.svg" href="/v3/rate-limiting">
    The per-org, per-environment limit, plus the headers to watch.
  </Card>
</CardGroup>
