For the complete documentation index, see llms.txt. This page is also available as Markdown.

Authentication

Perpl authenticates programmatic clients — bots, trading terminals, and scripts — with API keys. An API key is an Ed25519 key pair (Ed25519 is a public-key signature algorithm). The server only ever stores the public key; the private key never leaves your machine. There is no bearer token and no session cookie to leak: every request is signed with the key's private key.

This page covers how to obtain a key (web UI or programmatic enrollment) and how to sign each REST (Representational State Transfer, i.e. HTTPS) and WebSocket request with it.

The signing scheme includes the network's numeric chain ID, so you must sign with the value for the network you are calling. The two live networks are Mainnet (chain ID 143, default) and Testnet (chain ID 10143). Full endpoint, contract, and market details are on the Networks page — reuse those values rather than hard-coding your own.

Network
Chain ID
REST base URL
WebSocket URL

Mainnet (default)

143

https://app.perpl.xyz/api

wss://app.perpl.xyz

Testnet

10143

https://testnet.perpl.xyz/api

wss://testnet.perpl.xyz

The REST base URL includes the /api suffix; the WebSocket URL does not.

The snippets below read the key material and network from environment variables:

Variable
Meaning

PERPL_API_URL

REST base URL (e.g. https://app.perpl.xyz/api)

PERPL_WS_URL

WebSocket base URL (e.g. wss://app.perpl.xyz)

PERPL_CHAIN_ID

Chain ID (143 mainnet, 10143 testnet)

PERPL_API_KEY

The opaque X-API-Key token from enrollment

PERPL_API_KEY_SECRET

The Ed25519 private key, hex of the 32-byte key

API keys and scopes

Every key carries a scope, chosen at enrollment. Scope is stored as a 32-bit bitmask (scope_mask). Trade implies read.

scope_mask

Name

Grants

1

read (1 << 0)

Read account, order, position, history, and points/rewards data

2

trade (1 << 1)

Place / cancel / modify orders (implies read)

3

read | trade

Both

Withdrawals and transfers-out are never permitted via an API key, regardless of scope. Those actions require a wallet signature on the Exchange contract directly.

API auth is not the same as an exchange account

This is a common source of confusion. Enrolling and authenticating an API key authorizes API access for your wallet. It does not create an on-chain trading account.

Concept
What it means
Required for

API authentication

An enrolled API key can call authenticated API endpoints

Reading order history, position history, connecting to the trading WebSocket

Exchange account

An on-chain account exists on the Exchange contract with collateral

Placing orders, holding positions, trading

To trade you must additionally create an on-chain account with initial collateral by calling createAccount(uint256 amountCNS) on the Exchange contract. Until that account exists, some authenticated calls return 404. See the Networks page for the Exchange contract address per network, and the account-creation walkthrough for the cast commands.

Creating a key

Web UI

Connect your wallet and create a key at:

The UI hands you the opaque X-API-Key token and the Ed25519 private key. Store both — the token is not re-derivable.

Listing and revoking keys is done from the web UI (/apikeys), not via the API. A revoked public key cannot be re-enrolled — generate a fresh key pair.

Programmatic enrollment

Third-party integrations can enroll keys directly on behalf of a user's wallet. Enrollment is a one-time, wallet-authorized flow across two endpoints:

Method
Path
Auth
Purpose

POST

/api/v1/api-key/payload

Wallet signature

Get the EIP-712 payload to sign

POST

/api/v1/api-key/enroll

Wallet signature

Enroll the key, receive the token

EIP-712 is the Ethereum standard for signing typed structured data. The flow is:

1

Generate an Ed25519 key pair

The public key is sent as raw 32 bytes, 0x-hex encoded.

Or with OpenSSL 3.x:

2

Request the enrollment payload

POST /api/v1/api-key/payload with an ApiKeyPayloadRequest body:

It returns an ApiKeyPayloadResponse:

To enroll a key for a delegated account (an operator acting for another profile), set target_profile to the delegated account address. address stays the signing wallet (owner or operator); the server resolves the principal and validates the delegation on-chain at enrollment.

3

Sign and enroll

Enrollment requires two signatures over the returned typed_data:

  1. Wallet signature — the wallet's secp256k1 EIP-712 signature (secp256k1 is the elliptic curve used by Ethereum wallets). Proves the user owns or operates the account.

  2. Proof-of-possession — an Ed25519 signature by the API private key over the EIP-712 digest keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message)). Proves you hold the private key for the public key being enrolled.

POST /api/v1/api-key/enroll with an ApiKeyEnrollRequest body:

The response is an ApiKeyInfo. Its api_key field is the opaque X-API-Key token — store it, it is not re-derivable.

Enroll status codes:

Code
Meaning

404

Target profile not found

409

Public key already registered (revoked keys can't be re-enrolled — use a fresh key pair)

423

Per-profile key limit reached (max 16 active keys)

Signing REST requests

Sign a canonical string with the key's private key and send four headers. No cookies, no bearer token.

The canonical string is these six fields joined by \n (a single newline between each):

The signature is base64url(ed25519_sign(privateKey, canonical)) — base64url encoded, no padding — sent in the X-API-Signature header. (SHA-256 is a cryptographic hash; base64url is the URL-safe base64 alphabet.)

Four headers are required on every authenticated request:

Header
Value

X-API-Key

The opaque token from enrollment

X-API-Timestamp

The same timestamp_ms used in the canonical string

X-API-Nonce

The same nonce used in the canonical string

X-API-Signature

base64url(ed25519 signature)

The request-target must match byte-for-byte what the server receives — include the full query string (?count=100&page=...) exactly as sent, in the same order. Any mismatch changes the canonical string and the signature will be rejected.

Worked example

Suppose you are calling Mainnet (chain_id = 143) with a GET on /v1/trading/fills?count=1, an empty body, at timestamp_ms = 1751932800000, with nonce = 9Nq0Yp3kZ2c1aVb7 (base64url, no padding). The SHA-256 of the empty body is the well-known constant e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

The six fields assemble into this exact byte sequence (each line separated by a single \n):

You then sign that exact string with the Ed25519 private key and base64url-encode (no padding) the result to produce X-API-Signature. The request carries the four headers:

X-API-Timestamp and X-API-Nonce must be the same values you placed into the canonical string. Re-generate both (and re-sign) for every request.

WebSocket authentication

The trading WebSocket lives at /ws/v1/trading. Authenticate by sending an ApiKeySignIn frame (message type mt: 29) as the first message after the socket opens.

The signature covers a WebSocket canonical string — four fields joined by \n:

A trade-scoped key may place orders over the socket. A read-scoped key still receives snapshots and updates, but OrderRequest frames are rejected with status 403.

The market-data WebSocket (/ws/v1/market-data) requires no authentication — connect and subscribe directly.

Signature validity

Rule
Detail

Timestamp window

X-API-Timestamp must be within ±30 seconds of server time. Keep the client clock in sync (NTP).

Nonce

Single-use within the validity window — generate a fresh random nonce per request. Replays are rejected.

Expiry

Requests are rejected once the key is past its expires_at.

IP allow-list

When an ip_cidrs allow-list is set (max 4 CIDRs), requests from an IP outside it are rejected.

Errors

HTTP status codes

Code
Meaning
Recommended action

200

Success

400

Bad Request

Check the request shape / body

401

Unauthorized — missing/invalid headers, bad or stale signature, replayed nonce, revoked/expired key, or IP not allowed

Re-sign with a fresh timestamp + nonce; check the clock, key status, and source IP

403

Forbidden — scope insufficient (e.g. a read key attempting to trade)

Enroll a trade-scoped key

404

Not Found — including when no on-chain exchange account exists yet

Create an exchange account with createAccount()

429

Too Many Requests

Back off (see rate limits)

500

Internal Server Error

Retry with backoff

WebSocket close code

Code
Meaning
Action

3401

Authentication failure

Re-send a fresh signed ApiKeySignIn (mt: 29) frame and reconnect

Next steps

  • Networks — endpoints, chain IDs, contract and collateral addresses, market IDs.

  • REST Endpoints — the full list of HTTP endpoints and which require a signed request.

  • WebSocket — real-time streams, subscription frames, and order placement over the trading socket.

Last updated