> ## Documentation Index
> Fetch the complete documentation index at: https://docs.olympex.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign requests

> The signing algorithm for Olympex signed endpoints, with reference implementations, known-answer vectors and troubleshooting.

Every request to a signed endpoint carries four headers: your API key ID, your passphrase, a timestamp, nonce and body hash, and a signature over those values and the request's method, path and query, made with your secret key. This page is the normative specification. An implementation that follows it and reproduces the [known-answer vector](#known-answer-vector) produces exactly the signatures the server expects.

<Info>
  You need an API key ID, a secret key and a passphrase. [Create a test API key](/get-started/create-an-api-key) if you don't have them, and read [Credentials](/authentication/credentials) before you store them.
</Info>

## Overview

A signature covers the request's method, path and query, the current time, a single-use nonce and a hash of the body. It doesn't cover other headers: see [What the signature covers](#what-the-signature-covers). The server accepts a signed request only when all of these hold:

* `x-api-key-id` identifies an active account, and `x-passphrase` matches it.
* `x-signature` matches the HMAC the server computes over the [signed message](#algorithm), built from the values in `x-value-info` and the method, path and query of the request it received, keyed with the secret key it holds for your account.
* The timestamp is within 300 seconds of server time, in either direction.
* The nonce is 24 hexadecimal characters and hasn't been used in the last 5 minutes.
* The body the server received hashes to the `bodyHash` you signed. A `GET` or `DELETE` has no body, so its `bodyHash` is the hash of the empty string.

The API gateway runs the first four checks and answers `403` with `{"message":"Forbidden"}` when one fails, without saying which. Olympex runs the body check and answers `403` with the error code `FORBIDDEN` and the message `"Invalid body hash"`. A request that is missing a signing header gets `401` with `{"message":"Unauthorized"}` from the gateway.

## Algorithm

1. **Serialize the body.** `bodyString` is the [canonical JSON](#canonical-json) of the body: object keys sorted at every level, no whitespace. In JavaScript, that's `JSON.stringify(sortKeysDeep(body))`. Send exactly these bytes, as UTF-8, as the request body. A `GET` or `DELETE` has no body: its `bodyString` is the empty string. See [Requests without a body](#requests-without-a-body).
2. **Hash the body.** `bodyHash` is the SHA-256 digest of `bodyString`, encoded as base64url without padding. It is 43 characters from `A-Z`, `a-z`, `0-9`, `-` and `_`.
3. **Take a timestamp and a nonce.** `timestamp` is the current Unix time in **seconds**, as a decimal string. `nonce` is 24 hexadecimal characters: 12 bytes from a cryptographically secure random generator, hex-encoded. Generate both again for every attempt, retries included.
4. **Take the method, the path and the query.** `method` is the HTTP method in uppercase. `path` is the path of the URL you send, without the query string, starting with `/api/v1`: for example `/api/v1/limit-order/afc47108-d059-473c-b2e1-5f2ca7951466`. `query` is the [canonical query](#canonical-query), or the empty string when the URL has no query.
5. **Build the signed message.** `message` is seven lines joined with line feeds, with no trailing newline: `OLPX-HMAC-SHA256-V2`, `timestamp`, `nonce`, `method`, `path`, `query` and `bodyHash`. The query line is empty when there is no query.
6. **Encode `x-value-info`.** `x-value-info` is the standard base64 encoding (alphabet `A-Z a-z 0-9 + /`, with `=` padding), on one line, of `timestamp + "\n" + nonce + "\n" + bodyHash`. It doesn't carry the method, the path or the query: the server reads them from the request it receives.
7. **Sign the message.** `x-signature` is the HMAC-SHA256 of `message`, as lowercase hexadecimal (64 characters). The HMAC key is the secret key **string** exactly as `POST /accounts` returned it, as UTF-8 bytes. Don't base64-decode it.
8. **Send the request.** Send the headers `content-type: application/json`, `x-api-key-id`, `x-value-info`, `x-passphrase` and `x-signature`. Send `bodyString` as the body of a `POST` or `PATCH`, and no body with a `GET` or `DELETE`. Send the URL whose path and query you signed: query parameters go in the URL, for example `/tokens?chainId=137`.

The same algorithm as pseudocode:

```text theme={null}
bodyString   = canonicalJson(body)                          // keys sorted at every level, no whitespace; "" for GET and DELETE
bodyHash     = base64url(sha256(utf8(bodyString)))          // no "=" padding
timestamp    = floor(unixTimeSeconds())                     // for example "1758700000"
nonce        = hex(secureRandomBytes(12))                   // 24 hexadecimal characters
path         = urlPath(url)                                 // for example "/api/v1/limit-order", no query string
query        = canonicalQuery(urlQuery(url))                // "" when there is no query
message      = join("\n", "OLPX-HMAC-SHA256-V2", timestamp, nonce, upper(method), path, query, bodyHash)   // no trailing newline

x-value-info = base64(utf8(timestamp + "\n" + nonce + "\n" + bodyHash))   // standard alphabet, padded, one line
x-signature  = hex(hmacSha256(key = utf8(secretKey), data = utf8(message)))
```

## Canonical JSON

The server hashes the canonical form of the body it receives: it parses the JSON, sorts object keys at every level, and serializes the result again with JavaScript's `JSON.stringify`. Your `bodyHash` has to be the hash of that same string.

* Sort object keys at every level, including objects inside arrays, in ascending order of UTF-16 code units, which is JavaScript's default sort. For ASCII keys this is byte order: uppercase letters, then `_`, then lowercase letters.
* Keep array elements in their original order.
* Write no whitespace between tokens.
* Escape strings as standard JSON. Non-ASCII characters appear as raw UTF-8, not as `\u` escapes.

For example, this body:

```json theme={null}
{
  "params": { "slippage": "1", "chainId": 137, "amount": "10" },
  "mode": "single-chain"
}
```

has this canonical form, which is the string you hash and send:

```text theme={null}
{"mode":"single-chain","params":{"amount":"10","chainId":137,"slippage":"1"}}
```

Because the server canonicalizes what it receives, whitespace and key order in the bytes you send don't affect verification: a pretty-printed body is accepted when `bodyHash` covers the canonical form. Send the canonical string anyway. It is what you hashed, and sending it rules out a whole class of mismatches.

### Portability rules

JavaScript parses every JSON number as a double and gives some keys special treatment. A value that your language serializes differently from JavaScript produces a different hash. Keep every body inside these rules:

| Rule | Why |
| - | - |
| Format numbers exactly as JavaScript's `JSON.stringify` does. | The server serializes numbers the JavaScript way: `1.0` becomes `1`, `1e21` becomes `1e+21`, and `0.0000001` becomes `1e-7`. A number your language writes differently produces a different hash. |
| Integers only between `-9007199254740991` and `9007199254740991`, that is ±(2<sup>53</sup> − 1). | Larger integers lose precision when they're parsed as doubles. |
| Object keys in ASCII, and never numeric such as `"10"`. | JavaScript orders integer-like keys before the others, and sorts non-ASCII keys by UTF-16 code units, which differs from the default sort in most languages. |
| No `__proto__` key. | JavaScript drops it when it rebuilds the sorted object. |
| Valid Unicode only, with no lone surrogates. | Languages encode or reject them differently. |

The reference implementations follow these rules. The TypeScript and Python signers and the [API console](/api-reference/console) canonicalize numbers the way JavaScript does, so fractional numbers, such as a DCA `minPrice` of `0.00025`, sign correctly in all three. The Python version raises an error when a body contains a value it can't sign portably: an integer outside ±(2<sup>53</sup> − 1), `NaN`, an infinity, a numeric or non-ASCII key, or `__proto__`. If you write your own signer in another language, format numbers exactly as JavaScript's `JSON.stringify` does, and check it with the [known-answer vector](#known-answer-vector) and the [API console](#compare-against-the-api-console).

Whether a field is a string or a number is set by the API, not by signing:

* Quote, swap and limit-order amounts, prices and slippage are decimal strings, for example `"0.5"`. Quotes and swaps reject a JSON number in these fields with `400 VALIDATION_ERROR`.
* DCA's `totalAmount`, `slippage`, `minPrice` and `maxPrice` must be JSON numbers, for example `0.5`. A string returns `400 VALIDATION_ERROR`.

[API conventions](/api-reference/conventions#amounts) lists the type of every field.

## Canonical query

The server rebuilds the query from the URL it receives and checks your signature against that canonical form, so the order and the encoding of the parameters in the URL you send don't matter. Build the same string:

1. Take the part of the URL after `?`. Without one, the canonical query is the empty string.
2. Split it on `&`, and each parameter at its first `=`. A parameter without `=` has an empty value. Decode keys and values: `+` is a space, and `%XX` escapes are UTF-8 bytes.
3. Sort the parameters by key, then by value, in ascending order of UTF-16 code units, which is JavaScript's default sort (byte order for ASCII).
4. Percent-encode each key and value per RFC 3986: keep `A-Z`, `a-z`, `0-9`, `-`, `.`, `_` and `~`, and write every other UTF-8 byte as `%` followed by two uppercase hexadecimal digits. In JavaScript, that's `encodeURIComponent` with `!`, `'`, `(`, `)` and `*` also encoded.
5. Join each key and value with `=`, and the parameters with `&`.

| Query in the URL | Canonical query |
| - | - |
| None | The empty string |
| `?status=pending&chainId=137` | `chainId=137&status=pending` |
| `?b=2&a=1&a=0` | `a=0&a=1&b=2` |
| `?q=a+b` or `?q=a%20b` | `q=a%20b` |
| `?x=%7E*!` | `x=~%2A%21` |
| `?flag` | `flag=` |

Send every parameter as `key=value`. The TypeScript and Python reference implementations and the [API console](/api-reference/console) compute the canonical query from the URL they call. The bash function and the cURL blocks on this page sign the query exactly as you write it, so write it in canonical form there.

## Requests without a body

`GET` and `DELETE` requests have no body. Sign them over the empty string, and send no body:

| Value | For a request without a body |
| - | - |
| `bodyString` | The empty string. |
| `bodyHash` | `47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU`, the hash of the empty string. It is the same for every request without a body. |
| Request body | None. Don't send one, not even `{}`. |
| Headers | The same four signed headers, plus `content-type: application/json`. |
| Query parameters | In the URL, for example `/tokens?chainId=137`. They're signed as the [canonical query](#canonical-query), as on every request. |

The reference implementations handle this for you: `olympexRequest("GET", "/chains")`, `olympex_request("GET", "/chains")` and `olympex_request GET /chains` sign the empty string and send no body. They refuse a body on `GET` and `DELETE`, and require one on `POST` and `PATCH`. [Sign a request with cURL](#sign-a-request-with-curl) has a self-contained `GET` example, and the [known-answer vector](#known-answer-vector) includes a request without a body.

## What the signature covers

The signature covers the method, the path, the canonical query, the timestamp, the nonce and the body hash. It doesn't cover the other headers. Two consequences follow:

* **Headers authenticate only the request you signed.** Headers signed for `GET /limit-order/{id}` are rejected on `DELETE /limit-order/{id}` and on another ID, and headers signed for one query are rejected with another.
* **The nonce and the timestamp limit reuse.** The server accepts each set of headers once, and only within 300 seconds of its timestamp. Until then, whoever holds an unused set can send that exact request.

<Warning>
  Treat signed headers as a single-use credential for the one request you signed them for. Sign right before you send, never log, store or share signed headers, and redact `x-value-info`, `x-signature` and `x-passphrase` wherever you log outgoing requests.
</Warning>

## Content-Type

Send `content-type: application/json` on every request, public endpoints and requests without a body included. Without it, or with a type that isn't JSON or text, the API gateway base64-encodes the body before it reaches Olympex. That includes `application/x-www-form-urlencoded`, which curl sends with `-d` or `--data-raw` unless you pass `-H "content-type: application/json"`. A signed `POST` or `PATCH` then fails with `403 FORBIDDEN` (`"Invalid body hash"`), which looks like a hashing mistake, and `POST /accounts` fails with `400 VALIDATION_ERROR` (`"Invalid JSON body"`). Header names are case-insensitive.

## Reference implementations

Each implementation below signs a request and sends it. Call it with the method, the path (with its query string, if any) and, for `POST` and `PATCH`, the body. It signs the method, the path with its `/api/v1` prefix and the query of the URL it calls. The TypeScript and Python versions canonicalize the body for you, raise an error that carries `error.code` and `meta.requestId` when a request fails, and set a 35-second client timeout, a little above the gateway timeout of about 30 seconds. Gateway responses, such as a `401` or `403`, have no `meta.requestId`, so the error the helpers raise for them carries no request ID: log the `apigw-requestid` response header for those failures. See [Keep the request ID](/authentication/errors-and-retries#keep-the-request-id).

| Language | Signature | Example |
| - | - | - |
| TypeScript | `olympexRequest<T>(method, path, body?)` | `olympexRequest("GET", "/tokens?chainId=137")` |
| Python | `olympex_request(method, path, body=None)` | `olympex_request("GET", "/tokens?chainId=137")` |
| Bash | `olympex_request METHOD PATH [BODY]` | `olympex_request GET '/tokens?chainId=137'` |

<Tabs>
  <Tab title="TypeScript">
    Needs Node.js 18 or later and no dependencies. Save the file as `sign-request.ts`. It is an ES module: it runs as is in a folder with no `package.json`, and inside a project it needs `"type": "module"` in `package.json` (Node.js rejects its `import` lines when the type is `"commonjs"`, which `npm init -y` writes; run `npm pkg set type=module` after it). Node.js 22.18 and later run TypeScript files directly with `node app.ts`. On older versions, run them with [tsx](https://tsx.is): `npx tsx app.ts`.

    ```ts sign-request.ts theme={null}
    // Olympex REST API: request signing (signature v2) and a minimal client.
    // Node.js 18+ (global fetch, node:crypto). Runs unchanged under Node's built-in type stripping.
    import { createHash, createHmac, randomBytes } from "node:crypto";

    export const OLYMPEX_BASE_URL = "https://api-rest.olympex.io/api/v1";
    export const SIGNATURE_VERSION = "OLPX-HMAC-SHA256-V2";

    export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
    export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";

    export interface OlympexCredentials {
      apiKeyId: string; // sent as x-api-key-id
      secretKey: string; // shown once when the account is created; never sent
      passphrase: string; // the account password; sent as x-passphrase
    }

    export interface SignedRequest {
      method: string;
      path: string; // the URL path, starting with /api/v1
      query: string; // the canonical query, "" when there is none
      bodyString: string;
      bodyHash: string;
      timestamp: string;
      nonce: string;
      message: string; // the HMAC input
      headers: Record<string, string>;
    }

    const sortKeysDeep = (value: unknown): unknown => {
      if (Array.isArray(value)) return value.map(sortKeysDeep);
      if (value !== null && typeof value === "object") {
        const record = value as Record<string, unknown>;
        const sorted: Record<string, unknown> = {};
        for (const key of Object.keys(record).sort()) sorted[key] = sortKeysDeep(record[key]);
        return sorted;
      }
      return value;
    };

    /** The exact string the server hashes: keys sorted at every level, no whitespace. Empty when there is no body. */
    export const canonicalBody = (body: Json | undefined): string =>
      body === undefined || body === null ? "" : JSON.stringify(sortKeysDeep(body));

    // encodeURIComponent leaves !'()* as they are; RFC 3986 percent-encodes them.
    const rfc3986 = (text: string): string =>
      encodeURIComponent(text).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);

    /** The query the server signs: parameters decoded ("+" is a space), sorted by key and then by value, re-encoded per RFC 3986 and joined with "&". Empty when there is no query. */
    export const canonicalQuery = (search: string): string =>
      [...new URLSearchParams(search)]
        .sort(([ak, av], [bk, bv]) => (ak < bk ? -1 : ak > bk ? 1 : av < bv ? -1 : av > bv ? 1 : 0))
        .map(([key, value]) => `${rfc3986(key)}=${rfc3986(value)}`)
        .join("&");

    /**
     * Builds the four signed headers for one request. `url` is the full URL you send, query included.
     * Pass `undefined` as the body for GET and DELETE. Sign every attempt again: nonces are single-use.
     */
    export const signRequest = (
      method: string,
      url: string,
      body: Json | undefined,
      credentials: OlympexCredentials,
      options: { timestamp?: string; nonce?: string } = {},
    ): SignedRequest => {
      const verb = method.toUpperCase();
      const { pathname: path, search } = new URL(url);
      const query = canonicalQuery(search);
      const bodyString = canonicalBody(body);
      const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000).toString();
      const nonce = options.nonce ?? randomBytes(12).toString("hex");
      const bodyHash = createHash("sha256").update(bodyString, "utf8").digest("base64url");
      const message = [SIGNATURE_VERSION, timestamp, nonce, verb, path, query, bodyHash].join("\n");
      const signature = createHmac("sha256", credentials.secretKey).update(message, "utf8").digest("hex");
      return {
        method: verb,
        path,
        query,
        bodyString,
        bodyHash,
        timestamp,
        nonce,
        message,
        headers: {
          "content-type": "application/json",
          "x-api-key-id": credentials.apiKeyId,
          "x-value-info": Buffer.from(`${timestamp}\n${nonce}\n${bodyHash}`, "utf8").toString("base64"),
          "x-passphrase": credentials.passphrase,
          "x-signature": signature,
        },
      };
    };

    export class OlympexApiError extends Error {
      readonly status: number;
      readonly code: string;
      readonly details: unknown[];
      readonly requestId: string | undefined;

      constructor(status: number, code: string, message: string, details: unknown[] = [], requestId?: string) {
        super(message);
        this.name = "OlympexApiError";
        this.status = status;
        this.code = code;
        this.details = details;
        this.requestId = requestId;
      }
    }

    interface Envelope<T> {
      success?: boolean;
      data?: T;
      error?: { code: string; message: string; details?: unknown[] };
      meta?: { requestId?: string };
      message?: string; // gateway responses (401, 403, 429, 500, 503) carry only this field
    }

    export const credentialsFromEnv = (
      env: Record<string, string | undefined> = process.env,
    ): OlympexCredentials => {
      const { OLYMPEX_API_KEY_ID: apiKeyId, OLYMPEX_SECRET_KEY: secretKey, OLYMPEX_PASSPHRASE: passphrase } = env;
      if (!apiKeyId || !secretKey || !passphrase) {
        throw new Error("Set OLYMPEX_API_KEY_ID, OLYMPEX_SECRET_KEY and OLYMPEX_PASSPHRASE.");
      }
      return { apiKeyId, secretKey, passphrase };
    };

    /**
     * Signs and sends one request, then returns `data` or throws OlympexApiError.
     * POST and PATCH need a body; GET and DELETE take none and are signed over the empty string.
     * `path` may carry a query string, for example "/tokens?chainId=137". The signature covers the method, the path and the query.
     */
    export async function olympexRequest<T = unknown>(
      method: HttpMethod,
      path: string,
      body?: Json,
      credentials: OlympexCredentials = credentialsFromEnv(),
    ): Promise<T> {
      const hasBody = method === "POST" || method === "PATCH";
      if (hasBody && body === undefined) throw new TypeError(`${method} ${path} needs a body`);
      if (!hasBody && body !== undefined) throw new TypeError(`${method} requests take no body`);
      const url = `${OLYMPEX_BASE_URL}${path}`;
      const signed = signRequest(method, url, body, credentials);
      const response = await fetch(url, {
        method,
        headers: signed.headers,
        body: hasBody ? signed.bodyString : undefined, // send exactly the bytes that were hashed
        signal: AbortSignal.timeout(35_000),
      });
      const text = await response.text();
      let payload: Envelope<T> | undefined;
      try {
        payload = JSON.parse(text) as Envelope<T>;
      } catch {
        payload = undefined;
      }
      if (response.ok && payload?.success === true) return payload.data as T;
      if (payload?.error) {
        const { code, message, details = [] } = payload.error;
        throw new OlympexApiError(response.status, code, message, details, payload.meta?.requestId);
      }
      throw new OlympexApiError(response.status, `HTTP_${response.status}`, payload?.message ?? (text || response.statusText));
    }
    ```

    ```ts app.ts theme={null}
    import { olympexRequest } from "./sign-request.ts";

    // GET: no body. The signer signs the empty string.
    const { chainIds } = await olympexRequest<{ chainIds: number[] }>("GET", "/chains");
    // POST: the signer canonicalizes and signs the body.
    const supported = await olympexRequest<boolean>("POST", "/support-chain", { chainId: 137 });
    console.log(chainIds.includes(137), supported); // true true
    ```
  </Tab>

  <Tab title="Python">
    Needs Python 3.8 or later and [Requests](https://requests.readthedocs.io), installed in a virtual environment: `python3 -m venv .venv && . .venv/bin/activate && python3 -m pip install requests`. Homebrew's Python and recent Debian and Ubuntu releases refuse a system-wide `pip install` with `error: externally-managed-environment`. Save the file as `sign_request.py` next to your code.

    ```python sign_request.py theme={null}
    """Olympex REST API: request signing (signature v2) and a minimal client (Python 3.8+)."""
    import base64
    import hashlib
    import hmac
    import json
    import os
    import re
    import secrets
    import time
    from urllib.parse import quote, unquote_plus, urlsplit

    import requests

    OLYMPEX_BASE_URL = "https://api-rest.olympex.io/api/v1"
    SIGNATURE_VERSION = "OLPX-HMAC-SHA256-V2"
    _MAX_SAFE_INTEGER = 2**53 - 1  # the server parses JSON numbers as JavaScript doubles


    def _check_signable(value, where="body"):
        """Reject values whose JSON text would differ between Python and the server's JavaScript canonicalizer."""
        if value is None or isinstance(value, (bool, str)):
            return
        if isinstance(value, int):
            if abs(value) > _MAX_SAFE_INTEGER:
                raise ValueError(f"{where}: integer outside +/-(2**53 - 1); send it as a string")
            return
        if isinstance(value, float):
            if value != value or value in (float("inf"), float("-inf")):
                raise ValueError(f"{where}: NaN and Infinity are not valid JSON")
            return
        if isinstance(value, dict):
            for key, item in value.items():
                # JavaScript orders integer-like keys first, sorts by UTF-16 code units and drops "__proto__".
                if not isinstance(key, str) or not key.isascii() or key.isdigit() or key == "__proto__":
                    raise ValueError(f"{where}: object keys must be non-numeric ASCII strings, got {key!r}")
                _check_signable(item, f"{where}.{key}")
            return
        if isinstance(value, (list, tuple)):
            for index, item in enumerate(value):
                _check_signable(item, f"{where}[{index}]")
            return
        raise TypeError(f"{where}: unsupported type {type(value).__name__}")


    def _js_number(value):
        """Format a float the way JavaScript's JSON.stringify does (ECMAScript Number::toString): 1.0 is "1", 1e21 is "1e+21"."""
        if value == 0:
            return "0"  # also -0.0
        mantissa, _, exponent = repr(abs(value)).partition("e")  # repr gives the shortest round-trip digits, like JavaScript
        whole, _, fraction = mantissa.partition(".")
        raw = whole + fraction
        digits = raw.lstrip("0")
        point = len(whole) + int(exponent or 0) - (len(raw) - len(digits))  # value = 0.<digits> * 10**point
        digits = digits.rstrip("0")
        if len(digits) <= point <= 21:
            text = digits + "0" * (point - len(digits))
        elif 0 < point <= 21:
            text = digits[:point] + "." + digits[point:]
        elif -6 < point <= 0:
            text = "0." + "0" * -point + digits
        else:
            exp = point - 1
            text = digits[0] + ("." + digits[1:] if len(digits) > 1 else "") + ("e+" if exp >= 0 else "e-") + str(abs(exp))
        return ("-" if value < 0 else "") + text


    def _canonical(value):
        if value is None:
            return "null"
        if isinstance(value, bool):
            return "true" if value else "false"
        if isinstance(value, int):
            return str(value)
        if isinstance(value, float):
            return _js_number(value)
        if isinstance(value, str):
            return json.dumps(value, ensure_ascii=False)
        if isinstance(value, dict):
            return "{" + ",".join(f"{json.dumps(key, ensure_ascii=False)}:{_canonical(value[key])}" for key in sorted(value)) + "}"
        return "[" + ",".join(_canonical(item) for item in value) + "]"


    def canonical_body(body):
        """The exact string the server hashes: keys sorted at every level, no whitespace, raw UTF-8,
        numbers formatted as JavaScript formats them. Empty when there is no body."""
        if body is None:
            return ""
        _check_signable(body)
        return _canonical(body)


    def canonical_query(query):
        """The query the server signs: parameters decoded ("+" is a space), sorted by key and then by value,
        re-encoded per RFC 3986 and joined with "&". Empty when there is no query."""
        pairs = []
        for part in query.split("&"):
            if part:
                key, _, value = part.partition("=")
                pairs.append((unquote_plus(key), unquote_plus(value)))
        # JavaScript compares strings by UTF-16 code units, which is the order of their UTF-16-BE bytes.
        pairs.sort(key=lambda pair: (pair[0].encode("utf-16-be"), pair[1].encode("utf-16-be")))
        return "&".join(f"{quote(key, safe='')}={quote(value, safe='')}" for key, value in pairs)


    def sign_request(method, url, body, credentials, timestamp=None, nonce=None):
        """Return the canonical body and the signed headers for one request. `url` is the full URL you send,
        query included. Pass body=None for GET and DELETE. Sign every attempt again: nonces are single-use."""
        method = method.upper()
        parts = urlsplit(url)
        path, query = parts.path, canonical_query(parts.query)
        body_string = canonical_body(body)
        timestamp = str(int(time.time()) if timestamp is None else timestamp)
        nonce = secrets.token_hex(12) if nonce is None else nonce
        if not re.fullmatch(r"[0-9a-fA-F]{24}", nonce):
            raise ValueError("nonce must be 24 hexadecimal characters")
        digest = hashlib.sha256(body_string.encode("utf-8")).digest()
        body_hash = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
        message = "\n".join([SIGNATURE_VERSION, timestamp, nonce, method, path, query, body_hash])
        # The HMAC key is the secret key string itself (UTF-8 bytes), not its base64url-decoded bytes.
        signature = hmac.new(credentials["secret_key"].encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
        value_info = f"{timestamp}\n{nonce}\n{body_hash}"
        return {
            "method": method,
            "path": path,
            "query": query,
            "body_string": body_string,
            "body_hash": body_hash,
            "timestamp": timestamp,
            "nonce": nonce,
            "message": message,
            "headers": {
                "content-type": "application/json",
                "x-api-key-id": credentials["api_key_id"],
                "x-value-info": base64.b64encode(value_info.encode("utf-8")).decode("ascii"),
                "x-passphrase": credentials["passphrase"],
                "x-signature": signature,
            },
        }


    class OlympexApiError(Exception):
        def __init__(self, status, code, message, details=None, request_id=None):
            super().__init__(f"{status} {code}: {message}")
            self.status = status
            self.code = code
            self.message = message
            self.details = details or []
            self.request_id = request_id


    def credentials_from_env():
        try:
            return {
                "api_key_id": os.environ["OLYMPEX_API_KEY_ID"],
                "secret_key": os.environ["OLYMPEX_SECRET_KEY"],
                "passphrase": os.environ["OLYMPEX_PASSPHRASE"],
            }
        except KeyError as missing:
            raise RuntimeError(f"Set {missing.args[0]} (and the other OLYMPEX_* variables).") from None


    def olympex_request(method, path, body=None, credentials=None, timeout=35):
        """Sign and send one request, then return `data` or raise OlympexApiError.

        POST and PATCH need a body; GET and DELETE take none and are signed over the empty string.
        `path` may carry a query string, for example "/tokens?chainId=137". The signature covers the method, the path and the query.
        """
        method = method.upper()
        has_body = method in ("POST", "PATCH")
        if has_body and body is None:
            raise ValueError(f"{method} {path} needs a body")
        if not has_body and body is not None:
            raise ValueError(f"{method} requests take no body")
        url = OLYMPEX_BASE_URL + path
        signed = sign_request(method, url, body, credentials or credentials_from_env())
        response = requests.request(
            method,
            url,
            # bytes: http.client sends a str body as Latin-1
            data=signed["body_string"].encode("utf-8") if has_body else None,
            headers=signed["headers"],
            timeout=timeout,
        )
        try:
            payload = response.json()
        except ValueError:
            payload = None
        if not isinstance(payload, dict):
            payload = {}
        if response.ok and payload.get("success") is True:
            return payload.get("data")
        error = payload.get("error")
        if isinstance(error, dict):
            raise OlympexApiError(
                response.status_code,
                error.get("code"),
                error.get("message"),
                error.get("details"),
                (payload.get("meta") or {}).get("requestId"),
            )
        # Gateway responses (401, 403, 429, 500, 503) carry only {"message": ...}.
        raise OlympexApiError(response.status_code, f"HTTP_{response.status_code}", payload.get("message") or response.text)
    ```

    ```python app.py theme={null}
    from sign_request import olympex_request

    # GET: no body. The signer signs the empty string.
    chain_ids = olympex_request("GET", "/chains")["chainIds"]
    # POST: the signer canonicalizes and signs the body.
    supported = olympex_request("POST", "/support-chain", {"chainId": 137})
    print(137 in chain_ids, supported)  # True True
    ```
  </Tab>

  <Tab title="Bash">
    Needs bash 3.2 or later or zsh, `openssl` (OpenSSL 1.1 or later, or LibreSSL) and `curl`. Save the file as `sign-request.sh`, load it with `source ./sign-request.sh`, and call `olympex_request` with a method, a path and, for `POST` and `PATCH`, a canonical JSON body.

    ```bash sign-request.sh theme={null}
    # Olympex REST API: sign (signature v2) and send one request with openssl and curl.
    # Works in bash 3.2+ and zsh, with OpenSSL 1.1+/3.x or LibreSSL.
    # Needs OLYMPEX_API_KEY_ID, OLYMPEX_SECRET_KEY and OLYMPEX_PASSPHRASE in the environment.
    OLYMPEX_BASE_URL="https://api-rest.olympex.io/api/v1"

    # Usage: olympex_request METHOD PATH [BODY]
    #   olympex_request GET '/tokens?chainId=137'
    #   olympex_request POST /support-chain '{"chainId":137}'
    # POST and PATCH need a body that is already canonical JSON (keys sorted at every level, no whitespace):
    # it is hashed byte for byte and the server hashes its own canonical form.
    # GET and DELETE take no body and are signed over the empty string.
    # The signature also covers the method, the path (with its /api/v1 prefix) and the query. The query is signed as
    # written, so write it in canonical form: parameters sorted by key and then by value, each percent-encoded per RFC 3986.
    # OLYMPEX_TIMESTAMP and OLYMPEX_NONCE, if set, replace the current time and the random nonce. They exist only to
    # reproduce the known-answer vectors: never export them, or every later request fails with 403 (stale time, reused nonce).
    olympex_request() {
      local method="$1" endpoint="$2" body="${3-}" query=""
      local ts nonce body_hash sign_path value_info message signature
      : "${OLYMPEX_API_KEY_ID:?set OLYMPEX_API_KEY_ID}" "${OLYMPEX_SECRET_KEY:?set OLYMPEX_SECRET_KEY}" "${OLYMPEX_PASSPHRASE:?set OLYMPEX_PASSPHRASE}"
      case "$method" in
        POST|PATCH) [ -n "$body" ] || { printf 'olympex_request: %s needs a body\n' "$method" >&2; return 2; } ;;
        GET|DELETE) [ -z "$body" ] || { printf 'olympex_request: %s takes no body\n' "$method" >&2; return 2; } ;;
        *) printf 'olympex_request: unsupported method %s\n' "$method" >&2; return 2 ;;
      esac
      # Never name a variable `path`: zsh ties it to PATH.
      sign_path="/${OLYMPEX_BASE_URL#https://*/}${endpoint%%\?*}"
      case "$endpoint" in *\?*) query="${endpoint#*\?}" ;; esac
      ts="${OLYMPEX_TIMESTAMP:-$(date +%s)}"
      nonce="${OLYMPEX_NONCE:-$(openssl rand -hex 12)}"
      body_hash="$(printf '%s' "$body" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
      value_info="$(printf '%s\n%s\n%s' "$ts" "$nonce" "$body_hash" | openssl base64 -A)"
      message="$(printf 'OLPX-HMAC-SHA256-V2\n%s\n%s\n%s\n%s\n%s\n%s' "$ts" "$nonce" "$method" "$sign_path" "$query" "$body_hash")"
      signature="$(printf '%s' "$message" | openssl dgst -sha256 -hmac "$OLYMPEX_SECRET_KEY" -binary | od -An -v -tx1 | tr -d ' \n')"
      curl -sS --max-time 35 -X "$method" "$OLYMPEX_BASE_URL$endpoint" \
        -H "content-type: application/json" \
        -H "x-api-key-id: $OLYMPEX_API_KEY_ID" \
        -H "x-value-info: $value_info" \
        -H "x-passphrase: $OLYMPEX_PASSPHRASE" \
        -H "x-signature: $signature" \
        ${body:+--data-raw} ${body:+"$body"}
    }
    ```

    ```bash theme={null}
    olympex_request GET /chains
    olympex_request POST /support-chain '{"chainId":137}'
    ```

    * A shell can't canonicalize JSON. Pass the body already canonical: keys sorted at every level, no whitespace.
    * Quote a path that has a query string, as in `olympex_request GET '/tokens?chainId=137'`: zsh treats an unquoted `?` as a wildcard.
    * The function signs the query string exactly as you write it. Write it as a [canonical query](#canonical-query): parameters sorted by key, then by value, each percent-encoded per RFC 3986, as in `olympex_request GET '/limit-order?chainId=137&status=pending'`.
    * Feed values to `openssl` with `printf '%s'`, never `echo`. `echo` appends a newline, and some shells interpret backslashes, so the bytes you hash change.
    * Use `openssl base64 -A`. Without `-A`, openssl wraps its output every 64 characters, and a wrapped `x-value-info` is rejected.
    * `openssl dgst -hmac` takes the secret key as a command-line argument, which other users of the same machine can read with `ps`. Use the shell version on a machine only you use, and the TypeScript or Python version in production.
  </Tab>
</Tabs>

## Sign a request with cURL

To send one signed request from a terminal without a helper file, run one of these blocks. The `POST` block checks whether Polygon (`137`) is supported; the `GET` block lists the enabled chains. For another endpoint, change only the comment and the `METHOD`, `ENDPOINT`, `QUERY` and `BODY` lines. `ENDPOINT` is the path after `/api/v1`, and `QUERY` is the canonical query without `?`, or `''`. The block signs `/api/v1$ENDPOINT`, the path it calls.

* For a `POST` or `PATCH`, `BODY` must already be canonical JSON, and `--data-raw "$BODY"` sends it.
* For a `GET` or `DELETE`, `BODY` is empty and there is no `--data-raw`: the signature covers the empty string.
* `QUERY` is signed exactly as you write it, so write it as a [canonical query](#canonical-query), for example `QUERY='chainId=137&status=pending'`.

<CodeGroup>
  ```bash POST theme={null}
  # Check whether Polygon (137) is supported.
  # Needs openssl, curl and OLYMPEX_API_KEY_ID, OLYMPEX_SECRET_KEY, OLYMPEX_PASSPHRASE.
  METHOD=POST
  ENDPOINT=/support-chain
  QUERY=''
  BODY='{"chainId":137}'
  TS="${OLYMPEX_TIMESTAMP:-$(date +%s)}"
  NONCE="${OLYMPEX_NONCE:-$(openssl rand -hex 12)}"
  BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
  VALUE_INFO="$(printf '%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH")"
  MESSAGE="$(printf 'OLPX-HMAC-SHA256-V2\n%s\n%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "$METHOD" "/api/v1$ENDPOINT" "$QUERY" "$BODY_HASH")"
  curl -sS -X "$METHOD" "https://api-rest.olympex.io/api/v1$ENDPOINT${QUERY:+?$QUERY}" \
    -H "content-type: application/json" \
    -H "x-api-key-id: $OLYMPEX_API_KEY_ID" \
    -H "x-value-info: $(printf '%s' "$VALUE_INFO" | openssl base64 -A)" \
    -H "x-passphrase: $OLYMPEX_PASSPHRASE" \
    -H "x-signature: $(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$OLYMPEX_SECRET_KEY" -binary | od -An -v -tx1 | tr -d ' \n')" \
    --data-raw "$BODY"
  ```

  ```bash GET theme={null}
  # List the chains Olympex has enabled.
  # Needs openssl, curl and OLYMPEX_API_KEY_ID, OLYMPEX_SECRET_KEY, OLYMPEX_PASSPHRASE.
  METHOD=GET
  ENDPOINT=/chains
  QUERY=''
  BODY='' # GET has no body: the signature covers the empty string
  TS="${OLYMPEX_TIMESTAMP:-$(date +%s)}"
  NONCE="${OLYMPEX_NONCE:-$(openssl rand -hex 12)}"
  BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
  VALUE_INFO="$(printf '%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH")"
  MESSAGE="$(printf 'OLPX-HMAC-SHA256-V2\n%s\n%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "$METHOD" "/api/v1$ENDPOINT" "$QUERY" "$BODY_HASH")"
  curl -sS -X "$METHOD" "https://api-rest.olympex.io/api/v1$ENDPOINT${QUERY:+?$QUERY}" \
    -H "content-type: application/json" \
    -H "x-api-key-id: $OLYMPEX_API_KEY_ID" \
    -H "x-value-info: $(printf '%s' "$VALUE_INFO" | openssl base64 -A)" \
    -H "x-passphrase: $OLYMPEX_PASSPHRASE" \
    -H "x-signature: $(printf '%s' "$MESSAGE" | openssl dgst -sha256 -hmac "$OLYMPEX_SECRET_KEY" -binary | od -An -v -tx1 | tr -d ' \n')"
  ```
</CodeGroup>

A successful response to the `POST`:

```json theme={null}
{
  "success": true,
  "data": true,
  "meta": {
    "requestId": "ENUlxjCsIAMEMUA=",
    "version": "v1",
    "accountType": "integrator",
    "apiKeyId": "00000000-0000-4000-8000-000000000000"
  }
}
```

## Known-answer vector

Use this vector to test a signer offline. With these inputs, your implementation must produce these outputs byte for byte. Every reference implementation and signed cURL example on this site is checked against it before publishing. The credentials don't belong to a real account and the timestamp is in the past, so the server rejects these headers: use them for testing only. The test secret key is an arbitrary string: the algorithm uses any secret key string the same way.

| Input | Value |
| - | - |
| API key ID | `00000000-0000-4000-8000-000000000000` |
| Secret key | `aol_DocsTestVector_notARealSecret_000000000` |
| Passphrase | `correct horse battery staple` |
| timestamp | `1758700000` |
| nonce | `0123456789abcdef01234567` |
| method | `POST` |
| URL | `https://api-rest.olympex.io/api/v1/support-chain` |
| body | `{"chainId":137}` |

| Output | Value |
| - | - |
| path | `/api/v1/support-chain` |
| query | The empty string |
| bodyHash | `tEbfovAAOGtxILx3OhHE0H2UiM4EyEE_DYjZoWMtos4` |
| x-value-info | `MTc1ODcwMDAwMAowMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1NjcKdEViZm92QUFPR3R4SUx4M09oSEUwSDJVaU00RXlFRV9EWWpab1dNdG9zNA==` |
| x-signature | `c825b601a902a30f017ed578c7372004ab43c13300c58b0994e94afab80728f9` |

The body is already canonical, so `bodyString` is `{"chainId":137}`. The signed message, with six line feeds, an empty query line and no trailing newline, is:

```text theme={null}
OLPX-HMAC-SHA256-V2
1758700000
0123456789abcdef01234567
POST
/api/v1/support-chain

tEbfovAAOGtxILx3OhHE0H2UiM4EyEE_DYjZoWMtos4
```

`x-value-info` encodes only `timestamp`, `nonce` and `bodyHash`, joined with two line feeds. The API key ID and the passphrase travel as they are, so they don't change any output.

### Without a body

For `GET https://api-rest.olympex.io/api/v1/limit-order?status=pending&chainId=137`, use the same credentials, timestamp and nonce with no body. `bodyString` is the empty string, and the canonical query sorts the two parameters, so the outputs are:

| Output | Value |
| - | - |
| path | `/api/v1/limit-order` |
| query | `chainId=137&status=pending` |
| bodyHash | `47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU` |
| x-value-info | `MTc1ODcwMDAwMAowMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1NjcKNDdERVFwajhIQlNhLV9USW1XLTVKQ2V1UWVSa201Tk1wSldaRzNoU3VGVQ==` |
| x-signature | `6aa92f3921afaf1640d94a423bec8ca7f3727b39cff4f8da56fd3d1dc8860e38` |

The signed message is:

```text theme={null}
OLPX-HMAC-SHA256-V2
1758700000
0123456789abcdef01234567
GET
/api/v1/limit-order
chainId=137&status=pending
47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU
```

### Run the vector

To run both vectors against the reference implementations:

<CodeGroup>
  ```ts known-answer.test.ts theme={null}
  import assert from "node:assert/strict";
  import { OLYMPEX_BASE_URL, signRequest } from "./sign-request.ts";

  const credentials = {
    apiKeyId: "00000000-0000-4000-8000-000000000000",
    secretKey: "aol_DocsTestVector_notARealSecret_000000000",
    passphrase: "correct horse battery staple",
  };
  const at = { timestamp: "1758700000", nonce: "0123456789abcdef01234567" };

  const signed = signRequest("POST", `${OLYMPEX_BASE_URL}/support-chain`, { chainId: 137 }, credentials, at);
  assert.equal(signed.path, "/api/v1/support-chain");
  assert.equal(signed.query, "");
  assert.equal(signed.bodyString, '{"chainId":137}');
  assert.equal(signed.bodyHash, "tEbfovAAOGtxILx3OhHE0H2UiM4EyEE_DYjZoWMtos4");
  assert.equal(signed.headers["x-value-info"], "MTc1ODcwMDAwMAowMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1NjcKdEViZm92QUFPR3R4SUx4M09oSEUwSDJVaU00RXlFRV9EWWpab1dNdG9zNA==");
  assert.equal(signed.headers["x-signature"], "c825b601a902a30f017ed578c7372004ab43c13300c58b0994e94afab80728f9");

  const bodyless = signRequest("GET", `${OLYMPEX_BASE_URL}/limit-order?status=pending&chainId=137`, undefined, credentials, at);
  assert.equal(bodyless.path, "/api/v1/limit-order");
  assert.equal(bodyless.query, "chainId=137&status=pending");
  assert.equal(bodyless.bodyString, "");
  assert.equal(bodyless.bodyHash, "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU");
  assert.equal(bodyless.headers["x-value-info"], "MTc1ODcwMDAwMAowMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1NjcKNDdERVFwajhIQlNhLV9USW1XLTVKQ2V1UWVSa201Tk1wSldaRzNoU3VGVQ==");
  assert.equal(bodyless.headers["x-signature"], "6aa92f3921afaf1640d94a423bec8ca7f3727b39cff4f8da56fd3d1dc8860e38");
  console.log("Known-answer vectors: pass");
  ```

  ```python test_known_answer.py theme={null}
  from sign_request import OLYMPEX_BASE_URL, sign_request

  credentials = {
      "api_key_id": "00000000-0000-4000-8000-000000000000",
      "secret_key": "aol_DocsTestVector_notARealSecret_000000000",
      "passphrase": "correct horse battery staple",
  }
  at = {"timestamp": "1758700000", "nonce": "0123456789abcdef01234567"}

  signed = sign_request("POST", OLYMPEX_BASE_URL + "/support-chain", {"chainId": 137}, credentials, **at)
  assert signed["path"] == "/api/v1/support-chain"
  assert signed["query"] == ""
  assert signed["body_string"] == '{"chainId":137}'
  assert signed["body_hash"] == "tEbfovAAOGtxILx3OhHE0H2UiM4EyEE_DYjZoWMtos4"
  assert signed["headers"]["x-value-info"] == "MTc1ODcwMDAwMAowMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1NjcKdEViZm92QUFPR3R4SUx4M09oSEUwSDJVaU00RXlFRV9EWWpab1dNdG9zNA=="
  assert signed["headers"]["x-signature"] == "c825b601a902a30f017ed578c7372004ab43c13300c58b0994e94afab80728f9"

  bodyless = sign_request("GET", OLYMPEX_BASE_URL + "/limit-order?status=pending&chainId=137", None, credentials, **at)
  assert bodyless["path"] == "/api/v1/limit-order"
  assert bodyless["query"] == "chainId=137&status=pending"
  assert bodyless["body_string"] == ""
  assert bodyless["body_hash"] == "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
  assert bodyless["headers"]["x-value-info"] == "MTc1ODcwMDAwMAowMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1NjcKNDdERVFwajhIQlNhLV9USW1XLTVKQ2V1UWVSa201Tk1wSldaRzNoU3VGVQ=="
  assert bodyless["headers"]["x-signature"] == "6aa92f3921afaf1640d94a423bec8ca7f3727b39cff4f8da56fd3d1dc8860e38"
  print("Known-answer vectors: pass")
  ```
</CodeGroup>

## Troubleshooting

The gateway's `403 Forbidden` doesn't say which check failed. Find your symptom below, or compare your values with the [API console](#compare-against-the-api-console) and the [known-answer vectors](#known-answer-vector).

| Mistake | What you see | Fix |
| - | - | - |
| A signing header is missing, often because an environment variable isn't set in the process that sends the request. | `401` `{"message":"Unauthorized"}` | Send all four `x-` headers. Check that `OLYMPEX_API_KEY_ID`, `OLYMPEX_SECRET_KEY` and `OLYMPEX_PASSPHRASE` are set. |
| Unknown API key ID, wrong passphrase or inactive account. | `403` `{"message":"Forbidden"}` | Check the values against what you saved. |
| A signer written for the previous algorithm, which signs only the timestamp, the nonce and `bodyHash`, without the `OLPX-HMAC-SHA256-V2` line, the method, the path and the query. | `403` `{"message":"Forbidden"}` | Sign the seven-line message in [Algorithm](#algorithm). |
| Path signed without `/api/v1`, with the host or with the query string, or the method signed in lowercase. | `403` `{"message":"Forbidden"}` | Sign the method in uppercase and the path of the URL you send, from `/api/v1` up to the `?`. |
| Query signed as written instead of in canonical form, for example with its parameters unsorted or a space sent as `+`. | `403` `{"message":"Forbidden"}` | Sign the [canonical query](#canonical-query). |
| Headers signed for one request sent with another method, path, ID or query, for example headers signed for `GET /limit-order/{id}` sent with `DELETE`. | `403` `{"message":"Forbidden"}` | Sign every request separately, right before you send it. |
| `x-value-info` built from the seven-line signed message. | `403` `{"message":"Forbidden"}` | Encode only `timestamp`, `nonce` and `bodyHash` in `x-value-info`. |
| Timestamp in milliseconds (13 digits). | `403` `{"message":"Forbidden"}` | Use Unix seconds: `Math.floor(Date.now() / 1000)`, `int(time.time())` or `date +%s`. |
| Timestamp more than 300 seconds from server time: a drifting clock, or headers signed long before they were sent. | `403` `{"message":"Forbidden"}` | Synchronize the clock with NTP. Sign immediately before each send, and never queue or cache signed headers. |
| Nonce reused within 5 minutes, usually by a retry that resends the same headers. | `403` `{"message":"Forbidden"}` | Sign every attempt again, with a new timestamp and nonce. |
| `OLYMPEX_NONCE` or `OLYMPEX_TIMESTAMP` left set in the shell, for example after testing with the known-answer vectors. The shell signer and the cURL blocks use them instead of a new nonce and the current time. | `403` `{"message":"Forbidden"}` on every request after the first, or on all of them | Run `unset OLYMPEX_NONCE OLYMPEX_TIMESTAMP`. |
| Nonce that isn't 24 hexadecimal characters, such as 32 characters from 16 random bytes. | `403` `{"message":"Forbidden"}` | Hex-encode 12 random bytes. |
| HMAC keyed with the base64- or base64url-decoded secret key. | `403` `{"message":"Forbidden"}` | Use the secret key string exactly as returned, as UTF-8 bytes. |
| Trailing newline in the signed message, for example from `echo` or a heredoc. | `403` `{"message":"Forbidden"}` | Join the seven lines of the signed message with six `\n` (the query line stays empty when there is no query), and the three parts of `x-value-info` with two. In a shell, use `printf '%s'`. |
| `x-value-info` wrapped across lines by `openssl base64` without `-A`. | curl stops with `curl: (43) A libcurl function was given a bad argument` and sends nothing. Over HTTP/1.1, the gateway answers `400` with an empty body. | Use `openssl base64 -A`. |
| `Content-Type` missing or not JSON, for example curl's default `application/x-www-form-urlencoded` when `-H "content-type: application/json"` is left out. The gateway base64-encodes the body. | `403` `FORBIDDEN` `"Invalid body hash"` on a signed endpoint; `400` `VALIDATION_ERROR` `"Invalid JSON body"` on `POST /accounts` | Send `content-type: application/json`. Check this first: it is the quickest `"Invalid body hash"` cause to rule out. |
| `bodyHash` encoded as padded standard base64, or as hex. | `403` `FORBIDDEN` `"Invalid body hash"` | Use base64url without padding: 43 characters, `-` and `_` instead of `+` and `/`, no `=`. |
| Keys hashed in the order you wrote them instead of sorted. | `403` `FORBIDDEN` `"Invalid body hash"` | Hash the canonical JSON: keys sorted at every level, no whitespace. |
| The body sent isn't the body hashed, or breaks a [portability rule](#portability-rules), such as a number formatted differently from JavaScript or an integer beyond ±(2<sup>53</sup> − 1). | `403` `FORBIDDEN` `"Invalid body hash"` | Send the exact `bodyString` you hashed, and format numbers exactly as JavaScript's `JSON.stringify` does. |
| A `GET` or `DELETE` signed over a body, for example the body of an earlier `POST`. | `403` `FORBIDDEN` `"Invalid body hash"` | Sign the empty string, and send no body. See [Requests without a body](#requests-without-a-body). |
| Wrong path, or a method the path doesn't support, such as `POST /chains`. | `404` `NOT_FOUND` (`"No route for POST /api/v1/chains"`) | Check the method and the path against the [API reference](/api-reference/overview). |

These variations are accepted, so they aren't the cause of a rejection:

* A pretty-printed or reordered body, when `bodyHash` covers its canonical form.
* Query parameters in any order, or a space sent as `+` or `%20`, when you sign the canonical query.
* An uppercase hex `x-signature`.
* A timestamp exactly 300 seconds old.

If your signer reproduces both known-answer vectors and the values in the console's **Signing details**, and correctly signed requests still get `403`, contact [partners@olympex.io](mailto:partners@olympex.io) with your API key ID, the UTC time of the failing requests and their `apigw-requestid` response headers. Never send the secret key or the passphrase.

## Compare against the API console

The [API console](/api-reference/console) signs requests in your browser with the same algorithm. After you send a request or click **Copy as cURL**, open **Signing details**. It shows the method and path, the canonical query, the canonical body, `bodyHash`, `timestamp`, `nonce`, `x-value-info` and `x-signature` that the console used. Pass the same method, URL, body, secret key, timestamp and nonce to your signer and compare the outputs. If your browser blocks the console's calls, [Copy as cURL](/api-reference/console#copy-as-curl) still fills in **Signing details**, and the copied command sends the same signed request from a terminal. Use a test API key in the console, never production credentials.

## What this means for your integration

* Use a reference implementation, or run both known-answer vectors against yours in CI.
* Sign immediately before each send and again for every retry. Signed headers authenticate one request, once, so never log, store or reuse them.
* Sign the method, the exact path you call (it starts with `/api/v1`) and the canonical query, and send that same URL with exactly the bytes you hashed and `content-type: application/json`. Sign `GET` and `DELETE` over the empty string and send no body.
* Keep bodies portable: numbers formatted exactly as JavaScript formats them, integers within ±(2<sup>53</sup> − 1), ASCII keys. Send decimal strings where the API expects strings, and JSON numbers in DCA strategies.

## Related

<CardGroup cols={2}>
  <Card title="Errors and retries" icon="circle-exclamation" href="/authentication/errors-and-retries">
    The error envelope, gateway responses and what to retry.
  </Card>

  <Card title="Limits" icon="gauge" href="/authentication/limits">
    The timestamp window, nonce reuse and other hard limits.
  </Card>

  <Card title="Credentials" icon="key" href="/authentication/credentials">
    What each credential does and how to store it.
  </Card>

  <Card title="API console" icon="bolt" href="/api-reference/console">
    Sign and send any endpoint from your browser.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.