API Authentication
API Authentication
Use Ed25519 request signatures to access private PangoPerps API endpoints. Public market data does not require these authentication headers.
Create a signed request
Each authenticated request includes the account ID, the public half of a registered API key, a current timestamp, and a signature made with that key’s private half.
1. Add the account ID
After the account has been registered, include its account ID in the orderly-account-id request header.
2. Add the API public key
Put the registered Ed25519 public key in the orderly-key header. Encode the key in Base58 and include the ed25519: key-type prefix, for example:
ed25519:<base58-public-key>The secret key is used locally to sign the request. Never send the secret key to the API.
3. Set the request timestamp
Read the current time in Unix milliseconds and send it in orderly-timestamp. The server rejects requests whose timestamp is more than 300 seconds away from server time.
4. Build the signing message
Concatenate the following fields, without separators:
- Timestamp in milliseconds, such as
1649920583000. - HTTP method in uppercase, such as
POST. - Request path and query string, without the base URL, such as
/v1/orders?symbol=PERP_BTC_USDC. - If the request has a body, append its JSON string.
For example:
1649920583000POST/v1/order{"symbol":"PERP_ETH_USDC","order_type":"LIMIT","order_price":1521.03,"order_quantity":2.11,"side":"BUY"}The serialized body used to create the signature must be the same body sent with the request. Include the query string in the same order and form used in the final URL.
5. Sign the message
Sign the UTF-8 bytes of the normalized message with the Ed25519 private key. Encode the signature using Base64 URL-safe encoding and send it in orderly-signature.
6. Set the content type
Use the content type that matches the HTTP method:
7. Send the request
Once the signature is ready, send the request with the required headers below. The timestamp, HTTP method, request path, query string, and serialized body must match the values used to create the signature.
Content-Type — use application/x-www-form-urlencoded for GET and DELETE requests, and application/json for POST and PUT requests.
- orderly-account-id — the registered account ID.
- orderly-key — the registered public API key.
- orderly-signature — the Ed25519 signature encoded with Base64 URL-safe encoding.
- orderly-timestamp — the request timestamp in Unix milliseconds.
The public key string may include the ed25519: prefix, as in ed25519:<base58-public-key>. When a signer helper expects raw key bytes, pass the key without that display prefix. Keep the private key local; never send it with the request.
Security checks
Every authenticated request must pass all four checks below.
Request timestamp
The request is rejected when orderly-timestamp differs from the API server time by more than 300 seconds.
Signature verification
orderly-signature must be a valid Ed25519 signature of the normalized request content, created with the private key associated with the registered public key.
API key validity
The orderly-key value must identify a key that has been registered, is associated with the account in orderly-account-id, and has not expired.
Scope authorization
The key must include the scope required by the endpoint. Use read for private account-data endpoints and trading for order and trading endpoints. These permissions are independent: read does not grant trading access, and trading does not grant read access. Integrations that need both private account data and trading actions should register a key with read,trading.
Minimal TypeScript example
This single-file example signs and sends a testnet market order. Store a real secret securely; the placeholder below is for illustration only.
import { ed25519 } from "@noble/curves/ed25519";
import { base58 } from "@scure/base";
async function main() {
const orderlyAccountId = "YOUR_ACCOUNT_ID";
const orderlySecret = "YOUR_ORDERLY_SECRET"; // base58-encoded secret key
const baseUrl = "https://testnet-api.orderly.org";
const url = new URL(baseUrl + "/v1/order");
const method = "POST";
const body = JSON.stringify({
symbol: "PERP_ETH_USDC",
order_type: "MARKET",
order_quantity: 0.01,
side: "BUY"
});
const timestamp = Date.now();
const privateKey = base58.decode(orderlySecret);
const message = String(timestamp) + method + url.pathname + url.search + body;
const signatureBytes = ed25519.sign(new TextEncoder().encode(message), privateKey);
const publicKeyBytes = ed25519.getPublicKey(privateKey);
const response = await fetch(url, {
method,
body,
headers: {
"Content-Type": "application/json",
"orderly-account-id": orderlyAccountId,
"orderly-key": "ed25519:" + base58.encode(publicKeyBytes),
"orderly-timestamp": String(timestamp),
"orderly-signature": Buffer.from(signatureBytes).toString("base64url")
}
});
const data = await response.json();
console.log(data);
}
main();| Method | Content-Type |
|---|---|
GET, DELETE |
application/x-www-form-urlencoded |
POST, PUT |
application/json |
7. Send the request headers
| Header | Value |
|---|---|
Content-Type |
Content type for the selected method. |
orderly-account-id |
Account ID associated with the request. |
orderly-key |
Registered public key, Base58 encoded with the ed25519: prefix. |
orderly-signature |
Ed25519 signature of the normalized request, Base64 URL-safe encoded. |
orderly-timestamp |
Request timestamp in milliseconds. |
TypeScript example
This example signs and sends a market order from a PangoPerps account. Store the secret outside source code, such as in a server-side environment variable.
import { ed25519 } from "@noble/curves/ed25519";
import { base58 } from "@scure/base";
async function main() {
const accountId = process.env.PANGOPERPS_ACCOUNT_ID!;
const secretKey = base58.decode(process.env.PANGOPERPS_API_SECRET!);
const baseUrl = "https://testnet-api.orderly.org";
const url = new URL("/v1/order", baseUrl);
const method = "POST";
const body = JSON.stringify({
symbol: "PERP_ETH_USDC",
order_type: "MARKET",
order_quantity: 0.01,
side: "BUY"
});
const timestamp = Date.now();
const message = `${timestamp}${method}${url.pathname}${url.search}${body}`;
const signature = ed25519.sign(new TextEncoder().encode(message), secretKey);
const publicKey = ed25519.getPublicKey(secretKey);
const response = await fetch(url, {
method,
body,
headers: {
"Content-Type": "application/json",
"orderly-account-id": accountId,
"orderly-key": `ed25519:${base58.encode(publicKey)}`,
"orderly-timestamp": String(timestamp),
"orderly-signature": Buffer.from(signature).toString("base64url")
}
});
console.log(await response.json());
}
main();Python example
This example uses requests, base58, and the cryptography package. Compact JSON is used so the body included in the signing message is exactly the body sent over HTTP.
import base64
import json
import os
import time
from urllib.parse import urlparse
import base58
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
BASE_URL = "https://testnet-api.orderly.org"
ACCOUNT_ID = os.environ["PANGOPERPS_ACCOUNT_ID"]
SECRET_KEY = Ed25519PrivateKey.from_private_bytes(
base58.b58decode(os.environ["PANGOPERPS_API_SECRET"])
)
def signed_request(method: str, url: str, body: dict | None = None):
timestamp = str(int(time.time() * 1000))
parsed = urlparse(url)
path_and_query = parsed.path
if parsed.query:
path_and_query += "?" + parsed.query
body_text = "" if body is None else json.dumps(body, separators=(",", ":"))
message = timestamp + method.upper() + path_and_query + body_text
signature = base64.urlsafe_b64encode(
SECRET_KEY.sign(message.encode("utf-8"))
).decode("ascii")
public_key = SECRET_KEY.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
headers = {
"orderly-timestamp": timestamp,
"orderly-account-id": ACCOUNT_ID,
"orderly-key": "ed25519:" + base58.b58encode(public_key).decode("ascii"),
"orderly-signature": signature,
"Content-Type": (
"application/x-www-form-urlencoded"
if method.upper() in ("GET", "DELETE")
else "application/json"
),
}
return requests.request(
method.upper(),
url,
headers=headers,
data=body_text if body_text else None,
)
order = {
"symbol": "PERP_ETH_USDC",
"order_type": "MARKET",
"order_quantity": 0.01,
"side": "BUY",
}
response = signed_request("POST", BASE_URL + "/v1/order", order)
print(response.json())Java example
The example below shows the main steps with OkHttp and the net.i2p.crypto.eddsa Ed25519 implementation. The API key secret is read from the environment. Use a Base58 utility supported by your Java project to decode the secret and encode the public key.
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import net.i2p.crypto.eddsa.EdDSAEngine;
import net.i2p.crypto.eddsa.EdDSAPrivateKey;
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable;
import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
public class PangoPerpsAuthenticationExample {
public static void main(String[] args) throws Exception {
String baseUrl = "https://testnet-api.orderly.org";
String accountId = System.getenv("PANGOPERPS_ACCOUNT_ID");
byte[] secretBytes = Base58.decode(System.getenv("PANGOPERPS_API_SECRET"));
var parameters = EdDSANamedCurveTable.getByName(
EdDSANamedCurveTable.ED_25519
);
var privateKeySpec = new EdDSAPrivateKeySpec(secretBytes, parameters);
EdDSAPrivateKey privateKey = new EdDSAPrivateKey(privateKeySpec);
String path = "/v1/order";
String method = "POST";
String body = "{\"symbol\":\"PERP_ETH_USDC\","
+ "\"order_type\":\"MARKET\","
+ "\"order_quantity\":0.01,\"side\":\"BUY\"}";
String timestamp = Long.toString(Instant.now().toEpochMilli());
String message = timestamp + method + path + body;
EdDSAEngine signer = new EdDSAEngine();
signer.initSign(privateKey);
byte[] signatureBytes = signer.signOneShot(
message.getBytes(StandardCharsets.UTF_8)
);
String signature = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(signatureBytes);
String publicKey = Base58.encode(privateKey.getAbyte());
OkHttpClient client = new OkHttpClient();
RequestBody requestBody = RequestBody.create(
body,
MediaType.parse("application/json")
);
Request request = new Request.Builder()
.url(baseUrl + path)
.header("Content-Type", "application/json")
.header("orderly-account-id", accountId)
.header("orderly-key", "ed25519:" + publicKey)
.header("orderly-timestamp", timestamp)
.header("orderly-signature", signature)
.post(requestBody)
.build();
try (Response response = client.newCall(request).execute()) {
System.out.println(response.body().string());
}
}
}Base58.decode and Base58.encode above stand for the Base58 codec used by your Java application.
cURL request template
Generate the timestamp, public key, and signature with your signing code, then insert them into the request. A signature is specific to the request contents and timestamp, so the placeholder below is not a reusable signature.
curl --request POST \
--url https://testnet-api.orderly.org/v1/order \
--header 'Content-Type: application/json' \
--header 'orderly-account-id: <pango-account-id>' \
--header 'orderly-key: ed25519:<base58-public-key>' \
--header 'orderly-signature: <base64url-signature>' \
--header 'orderly-timestamp: <timestamp-in-milliseconds>' \
--data '{
"symbol": "PERP_ETH_USDC",
"order_type": "MARKET",
"order_quantity": 0.01,
"side": "BUY"
}'Request checks
An authenticated request is accepted only when each of these checks succeeds:
Timestamp
The value in orderly-timestamp must be within 300 seconds of the API server’s current time.
Signature
The orderly-signature value must be a valid Ed25519 signature of the normalized request string, created with the private key paired with the supplied public key.
Key status
The public key sent in orderly-key must be registered to the account and must not have expired or been revoked.
Scope
The API key must include the scope needed by the endpoint. Use read for private account data and trading for trading actions. The permissions are independent: one does not imply the other. An integration that reads private account data and also trades needs both read and trading scopes.