Skip to content

API Authentication

Protected endpoints on the CLOB API enforce a two-tier credential model: L1 (EIP-712 wallet signature) for bootstrapping credentials, and L2 (HMAC API key) for day-to-day trading. Builder integrations follow a parallel header scheme described below.


  • Open (no credentials needed): All Gamma API endpoints, all Data API endpoints, and CLOB read paths such as order book snapshots, price queries, and spread lookups.
  • Protected (L2 headers required): CLOB write paths — order submission, cancellation, and heartbeat — which require all five OPENFISH_* HTTP headers.

L1 proves wallet ownership by having the caller sign an EIP-712 typed-data message with their private key. This mechanism is used exclusively for:

  • Generating new API credentials (POST /auth/api-key)
  • Recovering existing API credentials (GET /auth/derive-api-key)
  • Locally signing order payloads before submission

Openfish is currently invite-only. When creating your first API key via POST /auth/api-key, you must include a valid invitation code in the OPENFISH_INVITATION_CODE header.

How to get a code:

  1. Join the waitlist by calling POST /waitlist with your email (no auth required).
  2. Once approved, you’ll receive an invitation code (format: XXXX-XXXX, e.g. AF3K-X9M2).
  3. Include it as a header: OPENFISH_INVITATION_CODE: AF3K-X9M2

Codes are single-use and expire after a set period. After your first API key is created, subsequent keys (via different nonces) and key recovery (GET /auth/derive-api-key) do not require an invitation code.

HeaderDescription
OPENFISH_ADDRESSPolygon signer address
OPENFISH_SIGNATUREEIP-712 signature
OPENFISH_TIMESTAMPCurrent UNIX timestamp
OPENFISH_NONCENonce (default: 0)

The OPENFISH_SIGNATURE is produced by signing the following EIP-712 typed-data structure:

{
"domain": {
"name": "ClobAuthDomain",
"version": "1",
"chainId": 137
},
"types": {
"ClobAuth": [
{ "name": "address", "type": "address" },
{ "name": "timestamp", "type": "string" },
{ "name": "nonce", "type": "uint256" },
{ "name": "message", "type": "string" }
]
},
"value": {
"address": "<signer_address>",
"timestamp": "<unix_timestamp>",
"nonce": 0,
"message": "This message attests that I control the given wallet"
}
}
{
"apiKey": "550e8400-e29b-41d4-a716-446655440000",
"secret": "base64EncodedSecretString",
"passphrase": "randomPassphraseString"
}

Once you hold API credentials from the L1 flow, all subsequent trading requests are authenticated with HMAC-SHA256.

  1. Build the message string: {timestamp}{METHOD}{path}{body}
    • timestamp: current unix epoch seconds as string
    • METHOD: HTTP method in uppercase (GET, POST, DELETE)
    • path: request path with query string (e.g. /order, /data/orders?market=0x...)
    • body: raw JSON body string, or empty string "" for requests without body
  2. Decode the secret (returned from credential creation) from base64url to raw bytes
  3. Compute HMAC-SHA256 using decoded bytes as key, message string as input
  4. Encode the HMAC output as base64url (with = padding) — this is the signature
HeaderDescription
OPENFISH_ADDRESSPolygon signer address
OPENFISH_SIGNATUREbase64url(HMAC-SHA256(base64url_decode(secret), message))
OPENFISH_TIMESTAMPCurrent UNIX timestamp (seconds)
OPENFISH_API_KEYUser’s API key (UUID)
OPENFISH_PASSPHRASEUser’s API passphrase

Node.js:

const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const message = timestamp + method + path + body;
// IMPORTANT: secret must be decoded from base64url FIRST — do NOT use the raw string
const key = Buffer.from(secret, 'base64url');
const raw = crypto.createHmac('sha256', key).update(message).digest();
// IMPORTANT: use base64url (- and _), NOT standard base64 (+ and /)
// Node.js base64url omits padding, but the server requires '=' padding
const b64 = raw.toString('base64url');
const signature = b64 + '='.repeat((4 - b64.length % 4) % 4);

Python:

import hmac, hashlib, base64, time
timestamp = str(int(time.time()))
message = timestamp + method + path + body
# IMPORTANT: decode secret from base64url first — do NOT use the raw string
key = base64.urlsafe_b64decode(secret)
# base64.urlsafe_b64encode produces base64url WITH padding (correct)
signature = base64.urlsafe_b64encode(
hmac.new(key, message.encode(), hashlib.sha256).digest()
).decode()

Common mistakes:

  • Using the secret string directly as the HMAC key (must decode from base64url first)
  • Using standard base64 encoding (+, /) instead of base64url (-, _)
  • Missing = padding on the signature output

Test vector: secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, timestamp=1, method=GET, path=/, body="" → signature=eHaylCwqRSOa2LFD77Nt_SaTpbsxzN8eTEI3LryhEj4=


Builder accounts authenticate through a dedicated set of headers. These apply to builder-accessible endpoints such as GET /data/orders and GET /data/trades.

HeaderDescription
OPENFISH_BUILDER_API_KEYBuilder API key
OPENFISH_BUILDER_PASSPHRASEBuilder passphrase
OPENFISH_BUILDER_SIGNATUREHMAC signature for builder auth
OPENFISH_BUILDER_TIMESTAMPUNIX timestamp for builder auth

Signature TypeValueDescription
EOA0Standard Ethereum wallet. Funder is the EOA address.
OPENFISH_PROXY1Custom proxy wallet for Magic Link users.
GNOSIS_SAFE2Gnosis Safe multisig proxy wallet (most common).

Every authenticated request carries an OPENFISH_TIMESTAMP header containing the current UNIX epoch. The server will reject any request whose timestamp drifts more than 30 seconds from the server’s own clock. This check guards against replay attacks.

If you encounter a 401 Unauthorized response citing a timestamp mismatch, synchronize your system clock (or query GET /time for the server’s current epoch).

On receipt of an order via POST /order, the server performs ecrecover against the EIP-712 signature embedded in the payload. The address recovered must equal the signer field (falling back to maker when signer is empty). A mismatch produces a 400 Bad Request rejection.

The EIP-712 domain used for order signatures contains:

  • name: "Openfish CTF Exchange"
  • version: "1"
  • chainId: 137 (Polygon mainnet) or 80002 (Amoy testnet)
  • verifyingContract: CTF Exchange contract address

Any address that has been banned is permanently blocked from placing new orders. Submitting from a banned address returns 403 Forbidden. You can confirm ban status through GET /auth/ban-status/closed-only.

The platform operates under one of three modes at any given time:

ModeEffect
NormalAll operations allowed
Cancel-onlyNew orders rejected with 503 Service Unavailable; cancellations still work
Trading disabledAll trading operations rejected with 503 Service Unavailable
  • Keep private keys in environment variables or a secrets manager. Never check them into source control.
  • Handle all request signing server-side. API secrets must never appear in browser-facing code.
  • Emit heartbeats every 5 seconds to avoid automatic cancellation of your open orders.