Request signing
There are two ways to authenticate: an API key, described on this page, for your own account, and OAuth for apps that act for other STX members.
Every request to STX is signed. There is no login call, no session, and no token to refresh: you hold an Ed25519 private key, and each request carries a signature proving it came from you.
The signature covers the timestamp, the HTTP method and the path. That buys three things:
- Your key never crosses the wire. Only a signature does, so nothing in a request lets someone else make one.
- A captured request cannot be replayed. The timestamp is part of what you sign, and must be within 30 seconds of our clock.
- A captured request cannot be repointed. The method and path are signed too, so a
GETcannot be replayed as aDELETE.
One key authenticates both surfaces: the REST API and the account WebSocket channels. Any language with Ed25519 works; the implementations below all produce identical output.
The scheme
Section titled “The scheme”Every authenticated request carries three headers:
| Header | Value |
|---|---|
X-STX-ACCESS-KEY |
your Key ID, from Account -> API Keys |
X-STX-ACCESS-TIMESTAMP |
current Unix time in milliseconds, as a decimal string |
X-STX-ACCESS-SIGNATURE |
base64 Ed25519 signature of the message below |
The message is three values concatenated with no separator:
message = timestamp_ms + HTTP_METHOD_UPPERCASE + request_pathFor a REST call, the path is the endpoint you are calling, including any query string:
1700000000000POST/api/v1/meRules that matter:
- The request body is not signed. Only timestamp, method and path.
- The path includes its query string when there is one, and never the scheme or host.
/api/v1/orders?status=open, nothttps://host/api/v1/orders?status=open. - Plain Ed25519, not
Ed25519ph. Sign the UTF-8 bytes of the message directly. The pre-hashed variant is one word away in several libraries and produces a well-formed signature that always fails, and the only symptom is a401indistinguishable from a wrong key. - Standard base64 with padding. Not URL-safe base64.
- ±30 seconds. The timestamp must be within 30 seconds of our clock, so generate it per request and keep your host on NTP. Do not cache or reuse a signature.
The WebSocket handshake signs the same way, against GET and the handshake path with the query
string dropped:
1700000000000GET/socket/websocketeven though you connect to /socket/websocket?vsn=2.0.0. See
WebSocket channels.
Identify your client
Section titled “Identify your client”Send a User-Agent naming your software, on REST calls and on the WebSocket
handshake:
<product>/<version> (<runtime>)For example acme-mm/1.4 (python/3.13). Our own clients follow the same shape:
the C# SDK sends STX.Sdk/1.2.3-net8.0, and the
examples send
stx-api-examples/1.0 (python/3.13.7).
A User-Agent header is required. A REST request or WebSocket handshake
without one is refused with 403. Most HTTP libraries send one by default, but
some WebSocket clients, such as Node’s ws, do not unless you set it. Any value
is accepted, and one you choose is recorded against your API key,
so a recognizable string is what lets us find your calls when you report
something, instead of picking your traffic out of every default
python-requests/2.x in the logs. Name the software rather than the HTTP
library, and change the version when you deploy.
Check your implementation
Section titled “Check your implementation”Ed25519 signatures are deterministic, so the same key and message always produce the same signature. Check your implementation against this before doing anything else.
This key exists only for testing. It is not registered anywhere and will never authenticate a real request. Do not use it beyond verifying your code.
-----BEGIN PRIVATE KEY-----MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f-----END PRIVATE KEY-----| Message | 1700000000000POST/api/v1/me |
| Signature | ZFJ0qEoHt8TLKbGP+UhJ77BNy/Cdf7+oqbQzwynaM5XM0Yphmq5t1YWumC+5pLaDk/xY9EG3h4brxVdodYloCA== |
Byte-identical output means your key loading, message construction, signing mode and base64
encoding are all correct, and any later unauthorized is a timestamp, header or key-id problem
rather than a crypto one.
Implementations
Section titled “Implementations”Each of these was run against the vector above and produced exactly that signature.
Python
Section titled “Python”import base64, timefrom cryptography.hazmat.primitives import serialization
with open("test_key.pem", "rb") as fh: key = serialization.load_pem_private_key(fh.read(), password=None)
timestamp = str(int(time.time() * 1000))message = f"{timestamp}POST/api/v1/me".encode("utf-8")signature = base64.b64encode(key.sign(message)).decode()pip install cryptography. Full example: python/stx_quickstart.py.
Node.js
Section titled “Node.js”import { createPrivateKey, sign } from 'crypto';import { readFileSync } from 'fs';
const key = createPrivateKey(readFileSync('test_key.pem'));const timestamp = Date.now().toString();const message = Buffer.from(`${timestamp}POST/api/v1/me`, 'utf8');const signature = sign(null, message, key).toString('base64');No dependencies: Ed25519 is in the standard crypto module. Passing null as the first
argument to sign selects pure Ed25519.
import ( "crypto/ed25519" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" "os" "time")
data, _ := os.ReadFile("test_key.pem")block, _ := pem.Decode(data)parsed, _ := x509.ParsePKCS8PrivateKey(block.Bytes)key := parsed.(ed25519.PrivateKey)
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())message := []byte(timestamp + "POST/api/v1/me")signature := base64.StdEncoding.EncodeToString(ed25519.Sign(key, message))Standard library only.
import java.nio.file.*;import java.security.*;import java.security.spec.PKCS8EncodedKeySpec;import java.util.Base64;
String pem = Files.readString(Path.of("test_key.pem")) .replaceAll("-----[A-Z ]+-----", "").replaceAll("\\s", "");PrivateKey key = KeyFactory.getInstance("Ed25519") .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem)));
String timestamp = String.valueOf(System.currentTimeMillis());Signature signer = Signature.getInstance("Ed25519");signer.initSign(key);signer.update((timestamp + "POST/api/v1/me").getBytes("UTF-8"));String signature = Base64.getEncoder().encodeToString(signer.sign());Java 15 or newer, no dependencies.
Useful for a one-off curl or for filling in Postman variables:
TS=$(python3 -c 'import time; print(int(time.time()*1000))')SIG=$(printf "%sGET/api/v1/me" "$TS" \ | openssl pkeyutl -sign -inkey ~/.stx/ontario-staging.pem -rawin \ | openssl base64 -A)
curl -s https://demo.stxapp.ca/api/v1/me \ -H "X-STX-ACCESS-KEY: $STX_KEY_ID" \ -H "X-STX-ACCESS-TIMESTAMP: $TS" \ -H "X-STX-ACCESS-SIGNATURE: $SIG"A POST is signed exactly the same way. The body is not part of the signed message (only the timestamp, method and path are), so it is sent unsigned alongside the three headers:
TS=$(python3 -c 'import time; print(int(time.time()*1000))')SIG=$(printf "%sPOST/api/v1/tnc/accept" "$TS" \ | openssl pkeyutl -sign -inkey ~/.stx/ontario-staging.pem -rawin \ | openssl base64 -A)
curl -s -X POST https://demo.stxapp.ca/api/v1/tnc/accept \ -H "X-STX-ACCESS-KEY: $STX_KEY_ID" \ -H "X-STX-ACCESS-TIMESTAMP: $TS" \ -H "X-STX-ACCESS-SIGNATURE: $SIG" \ -H "Content-Type: application/json" \ -d '{ "device_id": "my-device-001", "accept_terms": true, "accept_privacy": true, "accept_house_rules": true }'All three consents must be true; anything else is a 400. This endpoint needs a
read_write key.
-rawin is what selects pure Ed25519. OpenSSL 1.1.1 or newer.
Generating your own key
Section titled “Generating your own key”Either let us generate the pair when you create the key, or bring your own and register the public half:
openssl genpkey -algorithm ed25519 -out ~/.stx/ontario-staging.pemopenssl pkey -in ~/.stx/ontario-staging.pem -puboutThe second command prints the SPKI PEM public key to paste when creating the API key. The private key never leaves your machine, and we cannot recover it.
When a signature will not verify
Section titled “When a signature will not verify”Work through these in order. The server deliberately returns the same unauthorized for every
signature failure, so the cause has to come from your side.
- Is the method uppercase in the message?
- Does the path match exactly, including any query string, and exclude scheme and host?
- On the WebSocket: did you sign
GETand/socket/websocketwithout?vsn=2.0.0, and use theX-prefixed header names? - Is the base64 standard and padded, rather than URL-safe?
- Are you signing the message bytes rather than a hash of them?
- Is the timestamp in the message byte-for-byte the one in the header?
- Is your clock within 30 seconds of ours?
curl -sI https://demo.stxapp.ca | grep -i date
If the test vector above reproduces exactly and a real request still fails, the problem is the Key ID, the key’s status, or the clock, not the signing.
Still stuck?
Section titled “Still stuck?”Ask in Discord: https://discord.gg/yF9eVzPzNZ. Include the operation or channel name, the environment, and the exact error text. Support lists what helps us answer in one round trip.

