# Request signing


Source: https://docs.stxapp.io/api/authentication/

There are two ways to authenticate: an API key, described on this page, for your own account, and [OAuth](/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.

## 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_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](/websockets/).

## 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](https://github.com/stxapp/stx-api-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

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

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

### Python

```python
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](https://github.com/stxapp/stx-api-examples/blob/main/python/stx_quickstart.py).

### Node.js

```javascript
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.

### Go

```go
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.

### Java

```java
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.

### Shell

Useful for a one-off curl or for filling in Postman variables:

```bash
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:

```bash
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

Either let us generate the pair when you create the key, or bring your own and register the
public half:

```bash
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.

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

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.

## Still stuck?

Ask in Discord: **https://discord.gg/yF9eVzPzNZ**. Include the operation or channel
name, the environment, and the exact error text. [Support](/support/) lists what
helps us answer in one round trip.
