Request Signing
Sign Operator API requests with your Ed25519 key
Every Operator API request must be signed with your Ed25519 private key for the environment you call. Phoenix verifies the signature with the public key registered for that environment, the same key that verifies your launch tokens. While you rotate keys an environment holds two, and a signature from either verifies (see Rotating a Key).
A signature covers everything the request states: the operator, the environment, the time, a once-only nonce, the method, the path, the query string, and the body.
Headers
| Header | Description |
|---|---|
X-Operator-Code | Your Phoenix Prediction operator code |
X-Operator-Environment | Target environment: sandbox or prod |
X-Signature-Timestamp | Unix seconds when the request was signed |
X-Signature-Nonce | A value unique to this request: 16 to 128 characters from A-Z, a-z, 0-9, _ and - |
X-Signature | Base64url Ed25519 signature |
Requests whose timestamp is more than 300 seconds away from Phoenix's clock are rejected.
Canonical Payload
Sign this exact string:
operator_code + "\n" +
environment + "\n" +
timestamp + "\n" +
nonce + "\n" +
METHOD + "\n" +
path + "\n" +
query_string + "\n" +
sha256_hex(raw_body)The environment is the same value sent in X-Operator-Environment (sandbox or prod), and nonce is the same value sent in X-Signature-Nonce.
Example for GET /operator/api/stats?from=2026-10-01T00%3A00%3A00Z with no body:
acme
sandbox
1779100000
Zr4kP0w1xQ7mV2nB9cLdHg
GET
/operator/api/stats
from=2026-10-01T00%3A00%3A00Z
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Rules for each part:
pathdoes not include the scheme or host.query_stringis the query exactly as it travels on the wire: everything after?, without the?, with the same percent-encoding and the same parameter order. Do not re-sort or re-encode it.- A request with no query string still has the
query_stringline. It is empty, so the payload contains an empty line between the path and the body hash. - Bodyless requests use the SHA-256 hash of an empty string. Hash the exact bytes you send as the body.
- Send a body with
Content-Type: application/json. Phoenix reads a body only under a JSON (or form) content type; with any other, your request is checked as if the body were empty.
Nonces
Phoenix accepts each nonce once per operator environment. It remembers a nonce for the 300 second window of the request's timestamp, so a replayed request is rejected even though its signature is valid.
Generate a new random nonce for every request, including every retry. A retry builds a new nonce, a new timestamp, and a new signature; it never resends the earlier signed request.
Deprecated: Signing Without a Nonce
The original scheme sends no X-Signature-Nonce header and signs this payload:
operator_code + "\n" +
environment + "\n" +
timestamp + "\n" +
METHOD + "\n" +
path + "\n" +
sha256_hex(raw_body)This scheme is deprecated. Phoenix still accepts it, but only for a request that has no query string, because this payload does not cover one. A request that has a query string and no nonce is rejected. The scheme also carries no replay protection beyond the 300 second timestamp window.
Move every client to the nonce scheme above.
Node.js Example
import { createPrivateKey, createHash, randomBytes, sign } from 'node:crypto';
function sha256Hex(input: string) {
return createHash('sha256').update(input).digest('hex');
}
function signOperatorRequest(input: {
operatorCode: string;
environment: 'sandbox' | 'prod';
method: string;
path: string;
query?: string;
body?: string;
privateKeyPem: string;
}) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(16).toString('base64url');
const payload = [
input.operatorCode,
input.environment,
timestamp,
nonce,
input.method.toUpperCase(),
input.path,
input.query ?? '',
sha256Hex(input.body ?? ''),
].join('\n');
const key = createPrivateKey(input.privateKeyPem);
const signature = sign(null, Buffer.from(payload), key).toString('base64url');
return {
'content-type': 'application/json',
'x-operator-code': input.operatorCode,
'x-operator-environment': input.environment,
'x-signature-timestamp': timestamp,
'x-signature-nonce': nonce,
'x-signature': signature,
};
}
const query = 'from=2026-10-01T00%3A00%3A00Z';
const response = await fetch(
`https://prediction.phoenixverse.io/operator/api/stats?${query}`,
{
headers: signOperatorRequest({
operatorCode: 'acme',
environment: 'sandbox',
method: 'GET',
path: '/operator/api/stats',
query,
privateKeyPem: process.env.PHOENIX_PREDICTION_OPERATOR_PRIVATE_KEY_PEM!,
}),
}
);For Ed25519, Node's sign algorithm argument is null.
Failure Behavior
Authentication failures return HTTP 401 with:
{ "error": "unauthorized" }Phoenix intentionally does not reveal which verification step failed. A bad signature, a stale timestamp, an unknown operator or environment, a suspended operator or environment, a malformed or reused nonce, and a query string sent without a nonce all look the same.