Skip to main content
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 produces exactly the signatures the server expects.
You need an API key ID, a secret key and a passphrase. Create a test API key if you don’t have them, and read Credentials before you store them.

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. 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, 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 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.
  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, 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:

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:
has this canonical form, which is the string you hash and send:
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: The reference implementations follow these rules. The TypeScript and Python signers and the API 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 ±(253 − 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 and 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 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 &.
Send every parameter as key=value. The TypeScript and Python reference implementations and the API 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: 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 has a self-contained GET example, and the 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.
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.

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.
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: npx tsx app.ts.
sign-request.ts
app.ts

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, for example QUERY='chainId=137&status=pending'.
A successful response to the POST:

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. 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:
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: The signed message is:

Run the vector

To run both vectors against the reference implementations:

Troubleshooting

The gateway’s 403 Forbidden doesn’t say which check failed. Find your symptom below, or compare your values with the API console and the known-answer vectors. 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 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 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 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 ±(253 − 1), ASCII keys. Send decimal strings where the API expects strings, and JSON numbers in DCA strategies.

Errors and retries

The error envelope, gateway responses and what to retry.

Limits

The timestamp window, nonce reuse and other hard limits.

Credentials

What each credential does and how to store it.

API console

Sign and send any endpoint from your browser.