Polyester API keys authenticate private API requests with Ed25519 signatures. Generate the keypair locally, register only the public key, and keep the private key in your own secure signing environment.
Create a key
Generate an Ed25519 keypair locally; the public key is exactly 32 bytes. Create the Polyester API
key with POST /v1/auth/api-keys (ConnectRPC CreateApiKey) using an interactive
JWT session. A Main Account key belongs to the authenticated Main Account. Creating a
Subaccount-bound key requires owner or administrator access to that Subaccount.
The API reference lists every request field. Rules it does not spell out:
labelis required and trimmed, 1 to 64 characters; multiline and markup-like input is rejected.publicKeyEd25519is the 32 public-key bytes. In JSON, protobuf bytes use Base64 encoding.- Omit
subaccountIdfor a Main Account key. iconis trimmed, up to 32 characters; tabs, newlines, and angle brackets are rejected.coloris a lowercase token of up to 32 characters usinga-z,0-9,_, and-.ipWhitelistis an enforced list of up to 32 unique IPv4 or IPv6 CIDR entries.
ipWhitelist permits the key only when the resolved client address belongs
to at least one configured CIDR. An empty list is unrestricted. Polyester derives the address from
the network connection and explicitly trusted proxy chain; client-supplied forwarding headers
cannot override it.See API key IP allowlists for creation, update, lockout-prevention, and production deployment guidance.
API-key creation does not accept scopes, a policy ID, or an expiry. Attach a policy afterwards with PATCH /v1/auth/api-keys/{key_id}/policy (ConnectRPC SetApiKeyPolicy), and
set an expiry with PATCH /v1/auth/api-keys/{key_id}. See API key policies. Creation needs an enrolled MFA factor and a fresh step-up, and the Main Account must have accepted the current terms; otherwise
creation fails with AUTH_TERMS_NOT_ACCEPTED. The full sequence, from registration to rotation, is
in API key lifecycle.
The response returns an apiKey object containing the key metadata and keyId. It never returns
private key material.
Required headers
Every API-key-authenticated request requires:
X-API-KEY-ID: <key_id>X-API-TIMESTAMP: <Unix time in milliseconds>X-API-SIGNATURE: <Ed25519 signature encoded as hex or standard Base64>
X-API-TIMESTAMP must be within 10 seconds of Polyester server time. Timestamps too far in the
past or future are rejected.
X-API-NONCE is optional. It is outside the canonical string and does not change the signature.
It is not a substitute for a fresh timestamp and signature, and it is not an operation
idempotency key.
Canonical string
Build the following UTF-8 string and separate its five fields with a single newline character. Do not add a trailing newline.
<timestamp_ms>
<uppercase_method>
<path>
<canonical_query>
<body_sha256_hex>Use:
- the exact decimal string sent in
X-API-TIMESTAMP - the HTTP method converted to uppercase
- the original request path exactly as sent to Polyester, preserving percent-encoding and excluding scheme, host, query, and fragment
- the normalized query string described below, or an empty string when there is no query
- the lowercase hexadecimal SHA-256 digest of the exact request body bytes
An empty request body is still hashed. Its SHA-256 value is:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Query normalization
Normalize the raw query as follows:
- Parse all query pairs, including repeated keys and empty values.
- Percent-decode each key and value. A raw
+is decoded as a space, so a literal plus sign must be sent as%2B. - Sort pairs by decoded key, then by decoded value.
- Percent-encode each key and value using RFC 3986 query encoding. Encode spaces as
%20and literal plus signs as%2B. - Write every pair as
key=value, preserving repeated pairs, and join them with&.
For example:
Raw: tag=z&q=hello+world&literal=%2B&tag=a&empty=
Canonical: empty=&literal=%2B&q=hello%20world&tag=a&tag=zMalformed or ambiguously encoded query strings are unsupported. Send a valid percent-encoded query so client and server canonicalization agree.
Sign and encode
Sign the UTF-8 bytes of the complete canonical string with the private Ed25519 key. The resulting signature must be exactly 64 bytes before encoding.
Send X-API-SIGNATURE as either:
- 128 hexadecimal characters
- standard padded Base64, normally 88 characters for a 64-byte signature
Polyester authentication verifies the timestamp, key status and expiry, signature, and configured IP allowlist, then resolves the key's Main Account and optional Subaccount binding. Each endpoint separately applies its supported authorization and policy checks.
Retry safely
For every request attempt, including retries:
- Generate an
X-API-TIMESTAMPthat is distinct from the previous attempt and within the accepted server-time window. - Rebuild the canonical string from the exact request.
- Sign the rebuilt canonical string.
For retries of the same logical state-changing operation, keep the endpoint's idempotency key and operation payload unchanged. A new signature authenticates a new HTTP attempt, but does not prevent the operation from running twice.
See API key replay protection for the complete retry and idempotency guidance.
Authentication errors
| Code | Meaning |
|---|---|
UNAUTHENTICATED | No usable authentication was supplied, including a missing X-API-KEY-ID. |
MISSING_HEADERS | A key ID was supplied, but the timestamp or signature header is missing. |
TIMESTAMP_SKEW | The timestamp is malformed or more than 10 seconds from server time. |
SIGNATURE_INVALID | The key ID is unknown, signature encoding is invalid, signature verification failed, or replay protection rejected a repeated signed request. |
KEY_DISABLED | The key exists but is disabled or revoked. |
KEY_EXPIRED | The key's expiry time has passed. |
IP_NOT_ALLOWED | The signature is valid, but the trusted client address is outside the key's IP whitelist or cannot be safely determined. |
REST answers these codes with HTTP 401 and an application/problem+json body whose code is the
value above. ConnectRPC, gRPC, and gRPC-web calls to order reads, order and trigger writes, and
balance reads answer with the error code unauthenticated and an auth.v1.AuthErrorDetail in the
error details. Its code is the same reason with the AUTH_ prefix, such as AUTH_SIGNATURE_INVALID or AUTH_IP_NOT_ALLOWED. UNAUTHENTICATED maps to AUTH_AUTHENTICATION_REQUIRED, and an API that
does not accept API keys answers AUTH_API_KEY_NOT_ACCEPTED (REST API_KEY_NOT_ACCEPTED).
Do not blindly retry an authentication error. Correct the signing input first. If a state-changing request may already have succeeded, preserve its endpoint idempotency key and payload when signing the next attempt.
Key handling
- Store private keys in a secure keystore, hardware security module, or dedicated signing service.
- Use TLS. Request signatures authenticate the request but do not encrypt it.
- Prefer Subaccount-bound keys when they match your integration.
- Rotate keys by registering a new public key, moving traffic to it, and revoking the old key with
DELETE /v1/auth/api-keys/{key_id}. Revocation is permanent and needs a fresh step-up. - Revoke keys immediately if they are unused or may be compromised.