> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs-v3.openfx.dev/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>
  **v3 hardens v2 auth — old code does not work unchanged.** Two breaks vs v2:
  (1) the JWT TTL ceiling drops from 120 seconds to **60 seconds** — JWTs minted
  with `exp = iat + 120` now return `401 AUTH_TOKEN_INVALID_CONFIG`; (2) every
  v3 request now requires an **`X-Request-Signature`** ES256 signature header —
  calls without it are rejected with `401 AUTH_TOKEN_INVALID`. 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 `AUTH_TOKEN_INVALID_CONFIG`.                                                                                                                                                    |
| `alg`   | header | `"ES256"`                                  | Only ES256 is accepted.                                                                                                                                                                                                  |
| `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 within a 70-second window. Reuse → `401 AUTH_TOKEN_INVALID` (with `details.reason: REPLAYED`). 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 AUTH_TOKEN_INVALID` (`details.reason: REPLAYED`). Setting `exp - iat > 60 seconds` returns `AUTH_TOKEN_INVALID_CONFIG`. A missing `X-Request-Signature` header is likewise rejected with `401 AUTH_TOKEN_INVALID`.
</Warning>

## 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={null}
<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 the query parameters ascending by key, URL-encode each `key=value` pair, join with `&`. Empty string when there are no query 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 in order; sign the result. These rules are a strict subset of what our ingress (Istio Envoy with `pathNormalization: DEFAULT`) applies on the server side, so the path you sign is the path the server hashes.

1. **Empty path becomes `/`.**
2. **Merge consecutive slashes** (`//a///b` → `/a/b`).
3. **Remove dot-segments** per RFC 3986 §5.2.4 (`/a/./b/../c` → `/a/c`).
4. **Do NOT percent-decode** — preserve `%xx` sequences exactly (`%2F` stays `%2F`).
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, 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 in Valkey for 70 seconds (60s JWT TTL + 10s clock-skew headroom) before the signature is verified. A replayed nonce is rejected with `401 AUTH_TOKEN_INVALID` (`details.reason: REPLAYED`) before any ECDSA work happens — replay attempts cost the attacker a round-trip and gain nothing.

## 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={null}
  // 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.
  function canonicalizePath(rawPath) {
    if (!rawPath) return "/";
    let path = rawPath.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 3: drop "."
      if (seg === "..") {
        // step 3: pop ".."
        if (output.length > 1) output.pop();
        continue;
      }
      output.push(seg); // steps 4,5,6: no decode, no re-encode, preserve case
    }
    path = output.join("/");
    if (path === "") path = "/"; // step 1
    if (hadTrailingSlash && !path.endsWith("/")) path += "/"; // step 7
    return path;
  }
  // 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 = [...parsed.searchParams.entries()]
      .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
      .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
      .join("&");
    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 };
  if (require.main === module) {
    const { headers } = signRequest({
      method: "GET",
      url: "https://api.openfx.com/v3/fx/pairs",
    });
    console.log(headers);
  }
  ```

  ```python Python theme={null}
  """Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3."""
  import base64
  import hashlib
  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 "/"
      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 3
              continue
          if seg == "..":  # step 3
              if len(out) > 1:
                  out.pop()
              continue
          out.append(seg)  # steps 4,5,6: no decode, no re-encode, preserve case
      path = "/".join(out) or "/"
      if had_trailing and not path.endswith("/"):  # step 7
          path += "/"
      return path
  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 = sorted(
          parse_qsl(parsed.query, keep_blank_values=True), key=lambda kv: kv[0]
      )
      canonical_query = "&".join(
          f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in 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={null}
  // 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.URLEncoder;
  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 "/";
          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 3
              if (seg.equals("..")) { // step 3
                  if (out.size() > 1) out.remove(out.size() - 1);
                  continue;
              }
              out.add(seg); // steps 4,5,6: no decode, no re-encode, preserve case
          }
          String path = String.join("/", out);
          if (path.isEmpty()) path = "/";
          if (hadTrailing && !path.endsWith("/")) path += "/"; // step 7
          return path;
      }
      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) {
          return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
      }
      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.getPath());
          String rawQuery = uri.getQuery();
          String canonicalQuery = "";
          if (rawQuery != null && !rawQuery.isEmpty()) {
              String[] pairs = rawQuery.split("&");
              List<String> encoded = new ArrayList<>();
              for (String p : pairs) {
                  String[] kv = p.split("=", 2);
                  encoded.add(pctEncode(kv[0]) + "=" + pctEncode(kv.length > 1 ? kv[1] : ""));
              }
              Collections.sort(encoded);
              canonicalQuery = String.join("&", encoded);
          }
          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={null}
  // 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 (isalnum(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;
  }
  // 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({pair, ""});
          } else {
              pairs.push_back({pair.substr(0, eq), pair.substr(eq + 1)});
          }
          if (amp == std::string::npos) break;
          start = amp + 1;
      }
      std::stable_sort(pairs.begin(), pairs.end(),
                        [] (const auto& a, const auto& b) { return a.first < b.first; });
      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 "/";
      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 3
          if (s == "..") { if (out.size() > 1) out.pop_back(); continue; } // step 3
          out.push_back(s); // steps 4,5,6: no decode, no 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={null}
  // 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"
  	"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 "/"
  	}
  	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 3
  			continue
  		}
  		if seg == ".." { // step 3
  			if len(out) > 1 {
  				out = out[:len(out)-1]
  			}
  			continue
  		}
  		out = append(out, seg) // steps 4,5,6: no decode, no 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 {
  	// RFC 3986 percent-encoding. Go's url.QueryEscape is form-urlencoded
  	// (space -> "+"); the spec requires "%20".
  	return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
  }
  func buildCanonicalQuery(values url.Values) string {
  	keys := make([]string, 0, len(values))
  	for k := range values {
  		keys = append(keys, k)
  	}
  	sort.Strings(keys)
  	parts := make([]string, 0, len(values))
  	for _, k := range keys {
  		for _, v := range values[k] {
  			parts = append(parts, pctEncode(k)+"="+pctEncode(v))
  		}
  	}
  	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
  }
  ```
</CodeGroup>

<Note>
  Retrieve the key name from the `name` parameter and the private key from the
  `privateKey` parameter in the JSON file downloaded during API key creation.
  The org ID is encoded inside `name` (format: `org/{org - id}/apiKey/   {api - key - id}`).
</Note>

## Common errors

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                                                                                                                                                                                                                                                                                                         |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_TOKEN_MISSING`        | `Authorization` header absent                                                                                                                                                                                                                                                                                                                                                                              | Add the header.                                                                                                                                                                                                                                                                                             |
| `AUTH_TOKEN_INVALID`        | JWT signature/format invalid, **or** the `X-Request-Signature` is missing or doesn't verify (`details.reason: SIGNATURE_INVALID` — common causes: DER instead of IEEE P-1363, body re-serialized between hash and send, path not canonicalized, query not sorted, nonce in signature doesn't match JWT header), **or** the JWT `nonce` was replayed within the server window (`details.reason: REPLAYED`). | 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_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. `trade`)                                                                                                                                                                                                                                                                                                                                                              | Generate a key with the needed scope.                                                                                                                                                                                                                                                                       |

## What's next

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/v3/quickstart">
    Mint a token and make your first trade end-to-end.
  </Card>

  <Card title="Environments" icon="flask" href="/v3/environments">
    Switch between Sandbox and Live modes with the same key flow.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/v3/errors">
    `AUTH_TOKEN_*` codes and how to recover.
  </Card>

  <Card title="Rate limiting" icon="gauge-high" href="/v3/rate-limiting">
    Per-key and per-org limits, plus the headers to watch.
  </Card>
</CardGroup>
