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

Python

This guide ports the Perpl API — the REST (Representational State Transfer, i.e. HTTPS) endpoints and the WebSocket (WSS) streams — to idiomatic Python. Every request is signed with an API key, so the bulk of this page is a small, reusable signing layer plus copy-pasteable examples for the public data, authenticated history, and trading flows.

The signing scheme, endpoints, and message types are identical across languages; only the tooling differs. Where a detail is specific to Python (a library choice, a request-encoding gotcha) it is called out explicitly.

Note: Perpl authenticates programmatic clients with API keys. An API key is an Ed25519 key pair (Ed25519 is a public-key signature algorithm). The server stores only the public key; the private key never leaves your machine. There is no bearer token or session cookie — every request is signed with the private key. Withdrawals and transfers-out are never permitted via an API key, regardless of scope.

Prerequisites

Install three third-party packages:

Package
Used for

requests

REST calls over HTTPS

websocket-client

WebSocket streams (imported as websocket)

pynacl

Ed25519 signing

pip install requests websocket-client pynacl

Note: pynacl is used throughout this page for Ed25519. If you already depend on the cryptography package you can swap it in — both accept a 32-byte private-key seed. See Loading the key for the one-line difference.

You also need an enrolled API key. Create one in the web UI (connect your wallet at https://app.perpl.xyz/apikeys for mainnet or https://testnet.perpl.xyz/apikeys for testnet); the UI hands you the X-API-Key token and the Ed25519 private key. Programmatic enrollment is also possible — see Enrolling a key programmatically.

Note: An API key authorizes API access. It does not create an on-chain trading account. Trading also requires an exchange account created on-chain with initial collateral; some authenticated calls return 404 until that account exists.

Configuration

The examples read the network and key material from environment variables, matching the reference clients. Full endpoint, contract, and market values for both networks are on the Networks page — reuse those rather than hard-coding your own.

Variable
Meaning
Mainnet default

PERPL_API_URL

REST base URL (includes /api)

https://app.perpl.xyz/api

PERPL_WS_URL

WebSocket base URL (no /api)

wss://app.perpl.xyz

PERPL_CHAIN_ID

Chain ID (143 mainnet, 10143 testnet)

143

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

Note: The chain ID is part of the signed canonical string, so you must sign with the value for the network you are calling. A signature built for chain 143 is rejected on testnet (chain 10143).

The signing primitive

Both REST and WebSocket authentication sign a canonical string — a fixed set of fields joined by newline (\n) — with the Ed25519 private key, then base64url-encode the 64-byte signature without padding. These helpers are shared by every signed call.

Loading the key

The private key arrives as hex of the 32-byte seed (with or without a 0x prefix):

Note: With the cryptography package instead of pynacl, this becomes Ed25519PrivateKey.from_private_bytes(seed), and signing is key.sign(message) (which already returns the 64-byte signature directly).

Encoding helpers

Signing REST requests

A REST canonical string is six fields joined by \n:

Send four headers alongside the request:

Header
Value

X-API-Key

the opaque token from enrollment

X-API-Timestamp

the timestamp_ms used in the canonical string

X-API-Nonce

the nonce used in the canonical string

X-API-Signature

base64url(ed25519 signature)

Note: The request-target must match byte-for-byte what the server receives. In Python this means you must build the query string yourself and sign that exact string — do not hand requests a params={...} dict, because requests may re-encode or reorder parameters, breaking the signature. Build the target with urllib.parse.urlencode once, sign it, and request that same URL. The pagination helper below shows the pattern.

Public data (no auth)

Public endpoints require no signature. Fetch the global context (chain, instances, tokens, market configs) and candles with plain requests calls.

Context and market scaling

Prices and sizes on the wire are scaled integers. Divide by 10 ** price_decimals / 10 ** size_decimals (read from each market's config in the context) to get human-readable values; leverage is in hundredths.

Scaling round-trips, using BTC on mainnet (price_decimals = 1, size_decimals = 5):

Candles

The candles endpoint takes a market_id, a resolution in seconds, and a from-to millisecond range. A maximum of 1024 candles are returned per request. Supported resolutions (seconds): 60, 300, 900, 1800, 3600, 7200, 14400, 28800, 43200, 86400.

Authenticated history

The trading-history endpoints are all GET, signed with the four X-API-* headers, and paginated. Response shape is {"d": [...newest to oldest...], "np": "<next cursor>"}. Pass count (default 50, max 100) and page (the np cursor from the previous response). Server-side filtering by market or date is not supported — filter client-side.

Endpoint
Returns

GET /v1/trading/fills

Order fill history

GET /v1/trading/order-history

Historical order events

GET /v1/trading/position-history

Position history

GET /v1/trading/account-history

Account events (deposits, settlements, funding, …)

GET /v1/profile/ref-code

Your referral code (404 + empty if none)

AccountEvent.et (event type) values on account-history: 1 Deposit, 2 Withdrawal, 3 IncreasePositionCollateral, 4 Settlement, 5 Liquidation, 6 TransferToProtocol, 7 TransferFromProtocol, 8 Funding, 9 Deleveraging, 10 Unwinding, 11 PositionCollateralDecreased, 12 LastForwardedDescIdReset.

Rate limits and error handling

Limits are approximate; back off on HTTP 429.

Type
Limit

REST public (/v1/pub/*, market data)

~100 req/min

REST authenticated (profile, trading history)

~60 req/min

WebSocket messages

~50 msg/sec per connection

WebSocket connections

~5 per IP

REST status codes and what to do:

Status
Meaning
Action

400

Bad request

Fix the request shape

401

Bad/stale signature, replayed nonce, revoked/expired key, or IP not in the allow-list

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

403

Scope insufficient (e.g. a read key placing an order)

Use a trade-scoped key

404

Not found (e.g. no exchange account, or no referral code)

Verify the on-chain account exists

429

Too many requests

Exponential backoff (1s / 2s / 4s)

500

Internal server error

Retry with backoff

Note: The signature timestamp must be within ±30 seconds of server time, and each nonce is single-use within that window. A clock that drifts more than 30 seconds produces 401 on every request — keep the client clock in sync (NTP).

WebSocket: market data

The /ws/v1/market-data endpoint needs no authentication. Subscribe with a SubscriptionRequest (mt: 5) carrying a subs list of {stream, subscribe}. Available streams: heartbeat@<chain_id>, gas-stats@<chain_id>, market-config@<chain_id>, market-state@<chain_id>, funding@<chain_id>, candles@<market_id>*<resolution>, order-book@<market_id>, trades@<market_id>.

Every message carries a header: mt (message type), optional sid (subscription id), sn (sequence), cid (correlation id), ses (session id).

Relevant server → client message types: 9 MarketStateUpdate, 10 MarketFundingUpdate, 11/12 Candles snapshot/update, 15/16 L2 (level-2) book snapshot/update, 17/18 Trades snapshot/update, 100 Heartbeat (carries sn and h = latest head block).

WebSocket: trading

The /ws/v1/trading endpoint requires authentication.

1

Open the socket

Open the trading socket.

2

Send the sign-in frame first

Send a signed ApiKeySignIn (mt: 29) frame as the very first message.

3

Receive the initial snapshots

Receive snapshots: WalletSnapshot (mt: 19), OrdersSnapshot (mt: 23), PositionsSnapshot (mt: 26).

4

Track sequence numbers

Seed sequence tracking from the WalletSnapshot sn; every Heartbeat (mt: 100) must be sn + 1 (a gap forces a reconnect).

5

Keep the connection alive

Send an application-level Ping (mt: 1) roughly every 30 seconds to keep the connection alive.

The mt: 29 signature covers a four-field canonical string joined by \n: <chain_id>, the literal tag trading-ws-signin, <timestamp_ms>, <nonce>.

Note: A read-scoped key connects and receives all snapshots and updates, but its OrderRequest frames are rejected. Placing orders needs a trade-scoped key. WebSocket close code 3401 means authentication failed — reconnect and re-send a fresh signed mt: 29 frame.

The client below authenticates, tracks the head block and the order sequence, keeps the connection alive on a background thread, and exposes order-placement methods.

Request IDs, idempotency, and retries

rq (Request ID) is an idempotency key scoped per account; the server guarantees at-most-once execution per rq. It must be strictly increasing. The server tracks the last processed value as lfr (last forwarded request id), delivered on WalletSnapshot (mt: 19) and AccountUpdate (mt: 21).

  • On connect, seed a local counter from account.lfr.

  • For each order, rq = max(local_counter, account.lfr) + 1.

  • Submitting rq <= lfr fails with sr: 32 (OrderDescIdTooLow).

Scenario
Action

No status received yet, lb (last valid block) not expired

Retry with the same rq

sr: 32 (OrderDescIdTooLow)

Retry once with a new rq (common with multiple clients/tabs)

Head block ≥ lb, no status received, and no reconnections since posting

Retry with a new rq

Multiple updates can arrive for one rq. Deduplicate on the client: take the first non-failure status (st in 2,3,4,5,8,9,10) as definitive and ignore everything after it; if only failures (st: 7) arrive, process the first one only.

Order fields and enums

OrderRequest (mt: 22) fields:

Field
Meaning

rq

Request ID (idempotency key, strictly increasing)

mkt

Market ID

acc

Account ID

oid

Order ID (for modify / cancel)

t

Order type (see below)

p

Limit price, scaled (0 = market)

s

Size, scaled by size_decimals

a

Amount (for collateral increase)

ms

Max market-order slippage, basis points (bps)

tif

Time-in-force (also defined; lb is the operative field for order validity/expiry)

fl

Flags (see below)

tp / tpc

Trigger price and condition

tr

Linked trigger request ID

lp

Linked position ID

lv

Leverage in hundredths (1000 = 10x)

lb

Last execution block

Order type (t)

Flag (fl)

Trigger condition (tpc)

1

OpenLong

0

GoodTillCancel (GTC)

1

GTELast

2

OpenShort

1

PostOnly

2

LTELast

3

CloseLong

2

FillOrKill (FOK)

3

GTEMark

4

CloseShort

4

ImmediateOrCancel (IOC)

4

LTEMark

5

Cancel

6

IncreasePositionCollateral

7

Change

Note: Trigger orders (a take-profit / stop-loss, TP/SL) must set lb: 0 — they have no expiry block, and the server manages their lifecycle. tp + tpc fire when the market last/mark price crosses the trigger; tr links the trigger to another request; lp links it to a position (cancelled when the position closes or inverts).

Order-reject reasons are delivered as sr (OrderStatusReason) on order updates — a 068 enum. Common values: 1 AmountExceedsAvailableBalance, 13 CrossesBook, 14 ExceedsLastExecutionBlock, 15 ForwardingReverted, 32 OrderDescIdTooLow, 38 OrderSizeExceedsAvailableSize, 53 PerpetualInsolvent.

Enrolling a key programmatically

Most integrations create keys in the web UI. Programmatic enrollment is a one-time, wallet-authorized flow over two endpoints:

1

Generate an Ed25519 keypair locally

Send the public key as raw 32 bytes, 0x-hex.

2

Request an enrollment payload

POST /api/v1/api-key/payload → returns an EIP-712 typed_data payload plus an opaque mac.

3

Sign the payload twice

Produce two signatures over typed_data: (a) a wallet secp256k1 EIP-712 signature proving account ownership, and (b) an Ed25519 proof-of-possession over the digest keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message)).

4

Submit the enrollment request

POST /api/v1/api-key/enroll (echo typed_data + mac, send signature + pop_signature) → returns ApiKeyInfo, whose api_key.api_key is the opaque X-API-Key token. Store it — it is not re-derivable.

Key facts for the flow:

  • Scope bitmask (scope_mask, uint32): 1 = read (1 << 0), 2 = trade (1 << 1, implies read), 3 = both.

  • Origin whitelisting: both enroll endpoints are CORS-enabled and the request Origin must be pre-whitelisted by Perpl; a non-browser client must set Origin explicitly.

  • Delegated accounts: set target_profile to the delegated account address; address stays the signing wallet (owner/operator). Delegation is validated on-chain at enrollment.

  • Enroll error codes: 404 target profile not found; 409 public key already registered (revoked keys cannot be re-enrolled — use a fresh keypair); 423 per-profile key limit reached (max 16 active keys).

  • Listing and revoking keys is done in the web UI (/apikeys), not via the API.

TODO(author): verify a full Python enrollment example end-to-end. The source docs provide only a JavaScript reference (examples/js/enroll_api_key.js); a Python port needs a secp256k1 EIP-712 signer (e.g. eth-account) plus a hand-built proof-of-possession digest (keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message))). The exact request/response JSON field names for POST /api/v1/api-key/payload and POST /api/v1/api-key/enroll are not enumerated in the API reference and should be confirmed against examples/js/enroll_api_key.js before publishing runnable code.

Next steps

  • Networks & Configuration — every endpoint, contract address, chain ID, and market ID for both networks.

  • REST API — full endpoint reference and response types.

  • WebSocket API — all message types, streams, and trading-flow semantics.

  • Authentication — the signing scheme in full, including scopes and validity rules.

Last updated