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-ididentifies an active account, andx-passphrasematches it.x-signaturematches the HMAC the server computes over the signed message, built from the values inx-value-infoand 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
bodyHashyou signed. AGETorDELETEhas no body, so itsbodyHashis the hash of the empty string.
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
- Serialize the body.
bodyStringis the canonical JSON of the body: object keys sorted at every level, no whitespace. In JavaScript, that’sJSON.stringify(sortKeysDeep(body)). Send exactly these bytes, as UTF-8, as the request body. AGETorDELETEhas no body: itsbodyStringis the empty string. See Requests without a body. - Hash the body.
bodyHashis the SHA-256 digest ofbodyString, encoded as base64url without padding. It is 43 characters fromA-Z,a-z,0-9,-and_. - Take a timestamp and a nonce.
timestampis the current Unix time in seconds, as a decimal string.nonceis 24 hexadecimal characters: 12 bytes from a cryptographically secure random generator, hex-encoded. Generate both again for every attempt, retries included. - Take the method, the path and the query.
methodis the HTTP method in uppercase.pathis 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.queryis the canonical query, or the empty string when the URL has no query. - Build the signed message.
messageis seven lines joined with line feeds, with no trailing newline:OLPX-HMAC-SHA256-V2,timestamp,nonce,method,path,queryandbodyHash. The query line is empty when there is no query. - Encode
x-value-info.x-value-infois the standard base64 encoding (alphabetA-Z a-z 0-9 + /, with=padding), on one line, oftimestamp + "\n" + nonce + "\n" + bodyHash. It doesn’t carry the method, the path or the query: the server reads them from the request it receives. - Sign the message.
x-signatureis the HMAC-SHA256 ofmessage, as lowercase hexadecimal (64 characters). The HMAC key is the secret key string exactly asPOST /accountsreturned it, as UTF-8 bytes. Don’t base64-decode it. - Send the request. Send the headers
content-type: application/json,x-api-key-id,x-value-info,x-passphraseandx-signature. SendbodyStringas the body of aPOSTorPATCH, and no body with aGETorDELETE. Send the URL whose path and query you signed: query parameters go in the URL, for example/tokens?chainId=137.
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’sJSON.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
\uescapes.
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 with400 VALIDATION_ERROR. - DCA’s
totalAmount,slippage,minPriceandmaxPricemust be JSON numbers, for example0.5. A string returns400 VALIDATION_ERROR.
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:- Take the part of the URL after
?. Without one, the canonical query is the empty string. - Split it on
&, and each parameter at its first=. A parameter without=has an empty value. Decode keys and values:+is a space, and%XXescapes are UTF-8 bytes. - 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).
- 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’sencodeURIComponentwith!,',(,)and*also encoded. - 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 onDELETE /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.
Content-Type
Sendcontent-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, forPOST 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.
- TypeScript
- Python
- Bash
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. ThePOST 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
POSTorPATCH,BODYmust already be canonical JSON, and--data-raw "$BODY"sends it. - For a
GETorDELETE,BODYis empty and there is no--data-raw: the signature covers the empty string. QUERYis signed exactly as you write it, so write it as a canonical query, for exampleQUERY='chainId=137&status=pending'.
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
ForGET 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’s403 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
bodyHashcovers 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.
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 andcontent-type: application/json. SignGETandDELETEover 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.
Related
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.
