Skip to content

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 GET cannot be replayed as a DELETE.

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.

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_path

For a REST call, the path is the endpoint you are calling, including any query string:

1700000000000POST/api/v1/me

Rules 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, not https://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 a 401 indistinguishable 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/websocket

even though you connect to /socket/websocket?vsn=2.0.0. See WebSocket channels.

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.

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.

Each of these was run against the vector above and produced exactly that signature.

import base64, time
from 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.

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.

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.pem
openssl pkey -in ~/.stx/ontario-staging.pem -pubout

The 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.

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.

  1. Is the method uppercase in the message?
  2. Does the path match exactly, including any query string, and exclude scheme and host?
  3. On the WebSocket: did you sign GET and /socket/websocket without ?vsn=2.0.0, and use the X- prefixed header names?
  4. Is the base64 standard and padded, rather than URL-safe?
  5. Are you signing the message bytes rather than a hash of them?
  6. Is the timestamp in the message byte-for-byte the one in the header?
  7. 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.

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.

v1.5.9Changelogllms.txtllms-full.txt