Imported from tradeparadigm/dt-apps (
apps/paradex/skills/paradex-api/SKILL.md). Install upstream withnpx skills add tradeparadigm/dt-apps --skill paradex-api. Copyright stays with the author.
Paradex
Paradex is a Starknet-based perpetual futures exchange. This skill covers the calls you will actually make and where the stored credential fits into each one.
Paradex is not Paradigm
These trip over each other constantly, so check which one you are being asked for before going further.
Paradex is an exchange. It has an order book, you hold a Paradex credential,
and you call api.*.paradex.trade directly. That is this skill.
Paradigm is a different product — an institutional RFQ platform that
negotiates block trades and routes them to a venue for settlement. Paradex is
one of the venues it can route to, which is the whole source of the confusion.
It has its own API and its own credentials, and it is covered by the
paradigm-rfq-trader skill.
If the request mentions an RFQ, a block trade, quoting, or crossing a quote, it is Paradigm — even when the venue is Paradex. Stop and use that skill instead. If it is an order on the book, a position, a fill, or market data, it is Paradex, and you are in the right place.
Your AGENTS.md already explains the placeholder mechanism in general — how
CRED_<NAME> and CRED_<NAME>_META work, what cred- and sign- prefixes
mean, and how to name the X-Dime-Sign- header. This file assumes that and
covers only what is specific to Paradex.
Hosts
| Environment | REST | WebSocket |
|---|---|---|
| Mainnet | https://api.prod.paradex.trade/v1 |
wss://ws.api.prod.paradex.trade/v1 |
| Testnet | https://api.testnet.paradex.trade/v1 |
wss://ws.api.testnet.paradex.trade/v1 |
| Nightly | https://api.nightly.paradex.trade/v1 |
wss://ws.api.nightly.paradex.trade/v1 |
Check which one the credential is scoped to before assuming mainnet.
Which credential you hold, and what it can do
An account holds one of these per environment, never both. Read the placeholder's prefix to tell them apart.
Read-only token — placeholder starts with cred-
A Paradex account token. Write the placeholder where the bearer token goes:
Authorization: Bearer cred-paradex-testnet-jwt-8Kq2mQx9vBn4rTz8
You can read everything the token's scope allows — account, balance, positions, fills, order history. You cannot place orders, because an order needs a STARK signature and this credential has no key to produce one.
CRED_..._META carries account, the account address.
One surprise worth knowing: this credential is scoped to the whole host with no path narrowing, which means every request to that host expects the placeholder, including public market-data endpoints that do not need authentication. Write it on all of them. A request that matches the credential's scope without carrying the placeholder is refused by the proxy before it reaches Paradex.
Signing key — placeholder starts with sign-
A Starknet private key. You cannot read it, and the proxy signs on your behalf. This is the credential that can trade.
CRED_..._META carries account (the account address) and public_key. You
need both — neither is derivable from the other on your side, and the auth URL
is addressed to the public key.
It is scoped to exactly three endpoints:
POST /v1/auth/*POST /v1/onboardingPOST /v1/orders
Everything else on the host is forwarded untouched and needs no placeholder. That includes all market data, all account reads, and cancelling orders.
1. Authenticate: turn the signing key into a JWT
This is the first thing to do with a signing credential, and after it almost everything else is an ordinary bearer-token request.
Get the chain id first. Public, no credential:
GET /v1/system/config
Read starknet_chain_id from the response.
Build the SNIP-12 message. SNIP-12 is Starknet's typed-data standard, the equivalent of EIP-712. The auth message is:
{
"domain": { "name": "Paradex", "chainId": "<starknet_chain_id as hex>", "version": "1" },
"primaryType": "Request",
"types": {
"StarkNetDomain": [
{ "name": "name", "type": "felt" },
{ "name": "chainId", "type": "felt" },
{ "name": "version", "type": "felt" }
],
"Request": [
{ "name": "method", "type": "felt" },
{ "name": "path", "type": "felt" },
{ "name": "body", "type": "felt" },
{ "name": "timestamp", "type": "felt" },
{ "name": "expiration", "type": "felt" }
]
},
"message": {
"method": "POST",
"path": "/v1/auth",
"body": "",
"timestamp": <seconds since epoch>,
"expiration": <timestamp + a short window, e.g. 1800>
}
}
path is the literal string /v1/auth, even though you POST to
/v1/auth/<public_key>. Signing the URL you are actually calling produces a
signature Paradex rejects, and this is the single most common way this call
fails.
Hash it, and have the proxy sign the hash. Compute the SNIP-12 message hash — that needs no secret, so it is your job — and serialise it as exactly 32 bytes. Base64 those bytes into the sign header.
POST /v1/auth/0x4f3c... HTTP/1.1
Host: api.testnet.paradex.trade
PARADEX-STARKNET-ACCOUNT: 0x1a2b...
PARADEX-TIMESTAMP: 1758153600
PARADEX-SIGNATURE-EXPIRATION: 1758155400
X-Dime-Sign-paradex-testnet-priv-key: <base64 of the 32-byte message hash>
PARADEX-STARKNET-SIGNATURE: sign-paradex-testnet-priv-key-7Kj2mQx9vBn4rTz8
The proxy replaces the placeholder with the signature as a JSON array of two
decimal strings, ["<r>","<s>"], and strips the X-Dime-Sign- header so
Paradex never sees what you asked to be signed.
The PARADEX-TIMESTAMP and PARADEX-SIGNATURE-EXPIRATION headers must carry
the same values you put in the signed message. A mismatch is a rejection with no
useful explanation.
Keep the token. The response carries jwt_token. Use it as
Authorization: Bearer <jwt_token> on everything below. Re-auth when it
expires rather than re-signing every request — the signing credential is scoped
to three endpoints and the token is what reaches the rest of the API.
If the account has never traded, POST /v1/onboarding comes first. It uses the
same signing pattern and is idempotent, so calling it when already onboarded is
harmless.
2. Market data
All public. No JWT, and no placeholder unless you hold the read-only token credential (see its surprise above).
| Call | Returns |
|---|---|
GET /v1/markets |
Every instrument and its parameters |
GET /v1/markets/summary?market=ETH-USD-PERP |
Mark price, 24h volume, open interest |
GET /v1/bbo/{market} |
Best bid and offer |
GET /v1/orderbook/{market} |
Order book depth |
GET /v1/markets/klines |
Candles |
GET /v1/trades?market=ETH-USD-PERP |
Recent public trades |
GET /v1/funding/data |
Funding rate history |
Market symbols look like ETH-USD-PERP. Get exact names from /v1/markets
rather than constructing them.
3. Account data
Bearer JWT. No signature, no placeholder with a signing credential — these endpoints are outside its scope and pass straight through.
| Call | Returns |
|---|---|
GET /v1/account |
Account value, margin, leverage |
GET /v1/balance |
Token balances |
GET /v1/positions |
Open positions |
GET /v1/orders |
Open orders |
GET /v1/orders-history |
Past orders |
GET /v1/fills |
Executions |
GET /v1/funding/payments |
Funding paid and received |
4. Place an order
An order needs two credentials' worth of material in one request: the JWT you minted in step 1, and a fresh STARK signature over the order itself.
Build the order message and sign it the same way as auth — SNIP-12, hash to 32 bytes, base64 into the sign header:
{
"domain": { "name": "Paradex", "chainId": "<starknet_chain_id as hex>", "version": "1" },
"primaryType": "Order",
"types": {
"StarkNetDomain": [ ... as above ... ],
"Order": [
{ "name": "timestamp", "type": "felt" },
{ "name": "market", "type": "felt" },
{ "name": "side", "type": "felt" },
{ "name": "orderType", "type": "felt" },
{ "name": "size", "type": "felt" },
{ "name": "price", "type": "felt" }
]
},
"message": {
"timestamp": <milliseconds since epoch, also the nonce>,
"market": "ETH-USD-PERP",
"side": "1",
"orderType": "LIMIT",
"size": "<size scaled to 8 decimals>",
"price": "<price scaled to 8 decimals, or 0 for a market order>"
}
}
sideis1for BUY and2for SELL in the signed message, while the JSON body below uses the stringsBUYandSELL. They are genuinely different encodings of the same field.sizeandpriceare quantum values with 8 decimals — multiply by 1e8 and take the integer.timestampis milliseconds and doubles as the nonce.
Then send it:
POST /v1/orders HTTP/1.1
Host: api.testnet.paradex.trade
Authorization: Bearer <jwt_token>
Content-Type: application/json
X-Dime-Sign-paradex-testnet-priv-key: <base64 of the 32-byte order hash>
{
"market": "ETH-USD-PERP",
"side": "BUY",
"type": "LIMIT",
"size": "0.1",
"price": "3000",
"instruction": "GTC",
"client_id": "my-order-1",
"signature": "sign-paradex-testnet-priv-key-7Kj2mQx9vBn4rTz8",
"signature_timestamp": 1758153600000
}
signature_timestamp must equal the timestamp you put in the signed
message. The proxy substitutes the placeholder in the body — it scans the JSON
body as well as headers for this credential — so the signature field arrives
at Paradex as a real ["<r>","<s>"] pair.
Order types: MARKET, LIMIT, STOP_LIMIT, STOP_MARKET,
TAKE_PROFIT_LIMIT, TAKE_PROFIT_MARKET, STOP_LOSS_LIMIT,
STOP_LOSS_MARKET. price is omitted for market orders. flags takes
["REDUCE_ONLY"] when closing.
5. Cancel an order
No signature. Bearer JWT alone, and the request passes straight through the proxy untouched.
DELETE /v1/orders/{order_id}
DELETE /v1/orders/by_client_id/{client_id}
DELETE /v1/orders
The last one cancels everything; narrow it with a market query parameter.
6. WebSocket
You cannot put a per-frame signature on a WebSocket connection — but you do not need to. Authentication is a JWT on the first frame, and you can mint that JWT with the signing key using step 1. So a signing credential gives you full WebSocket access.
Connect to wss://ws.api.{env}.paradex.trade/v1, then send JSON-RPC 2.0:
{"jsonrpc": "2.0", "id": 1, "method": "auth", "params": {"bearer": "<jwt_token>"}}
Send this before subscribing to any private channel. Then:
{"jsonrpc": "2.0", "id": 2, "method": "subscribe", "params": {"channel": "bbo.ETH-USD-PERP"}}
Channels:
| Channel | Scope |
|---|---|
bbo.{market} |
Public |
trades.{market} |
Public |
markets_summary.{market} |
Public |
funding_data.{market} |
Public |
order_book.{market}.{feed_type}@15@{refresh_rate}@{price_tick} |
Public |
orders.{market} |
Private |
fills.{market} |
Private |
positions |
Private |
account |
Private |
balance_events |
Private |
transfers, tradebusts |
Private |
Subscribing to the same channel twice is an error. Errors come back as JSON-RPC error objects; codes from -32768 to -32000 are the standard JSON-RPC set.
When the proxy refuses
A 403 from the DIME proxy is not a Paradex error — Paradex never saw the
request. The body says what was missing. The usual causes here:
- The placeholder absent from a request to one of the three scoped endpoints.
- The
X-Dime-Sign-header missing, or named from the placeholder instead of from theCRED_<NAME>variable. - A sign payload that is not exactly 32 bytes, which means you sent something other than a SNIP-12 message hash.
Read the body and correct the request. Do not retry it unchanged.
Anything not covered here
Full API reference: https://docs.paradex.trade — REST under /api,
WebSocket under /ws. The official Python SDK at
https://github.com/tradeparadex/paradex-py is the authoritative source for
typed-data construction if a signature is being rejected and you cannot see why.
This file is more specific than those docs in one way only: it says where the credential comes from and what you write instead of it. For anything about Paradex's own behaviour — instruction types, margin rules, fee tiers, the full channel list — the docs are correct and this file may lag.