Skip to main content
The Polymarket Exchange API uses Private Key JWT authentication with RSA keys. You sign a JWT with your RSA private key and exchange it for an access token.

Generate Your Key Pairs

Generate an RSA key pair for each environment you need access to. You will share only the public keys with Polymarket during onboarding.
# Replace 'acme' with your company name

# Development
openssl genrsa -out acme_dev_private_key.pem 2048
openssl rsa -in acme_dev_private_key.pem -pubout -out acme_dev_public_key.pem

# Pre-production
openssl genrsa -out acme_preprod_private_key.pem 2048
openssl rsa -in acme_preprod_private_key.pem -pubout -out acme_preprod_public_key.pem

# Production
openssl genrsa -out acme_prod_private_key.pem 2048
openssl rsa -in acme_prod_private_key.pem -pubout -out acme_prod_public_key.pem
Keep your private keys secure. Never share them with anyone.

Environments

EnvironmentAuth DomainAPI Domain
Developmentpmx-dev01.us.auth0.comapi.dev01.polymarketexchange.com
Pre-productionpmx-preprod.us.auth0.comapi.preprod.polymarketexchange.com
Productionpmx-prod.us.auth0.comapi.prod.polymarketexchange.com
Use https://[API Domain] for both the JWT audience claim and API base URL.
Each environment requires separate onboarding. Your pre-production credentials will not work in production.

How It Works

Authentication follows these steps:
  1. Create a signed JWT assertion - Sign a JWT with your private key
  2. Exchange for API access token - Send the assertion to the token endpoint
  3. Call API with access token - Include the token in your API requests

Prerequisites

After completing Onboarding, you will have:
You HaveFrom Onboarding
Private key fileGenerated by you (keep secure!)
Client IDProvided by Polymarket via clientid.txt in your shared Google Drive folder
Auth DomainSee Environments
API AudienceSee Environments

Create Client Assertion JWT

Create a JWT with these claims, signed with your private key using RS256:
{
  "iss": "YOUR_CLIENT_ID",
  "sub": "YOUR_CLIENT_ID",
  "aud": "https://pmx-preprod.us.auth0.com/oauth/token",
  "iat": 1703270400,
  "exp": 1703270700,
  "jti": "unique-random-uuid"
}
ClaimDescription
issYour client ID (issuer)
subYour client ID (subject)
audToken endpoint URL
iatIssued at time (Unix timestamp)
expExpiration time (max 5 minutes from iat)
jtiUnique token ID (prevents replay attacks)

Request Access Token

curl --request POST \
  --url "https://pmx-preprod.us.auth0.com/oauth/token" \
  --header "content-type: application/json" \
  --data '{
    "client_id": "YOUR_CLIENT_ID",
    "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    "client_assertion": "YOUR_SIGNED_JWT_ASSERTION",
    "audience": "https://api.preprod.polymarketexchange.com",
    "grant_type": "client_credentials"
  }'

Token Response

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIs...",
  "token_type": "Bearer",
  "expires_in": 180
}

Complete Python Example

import jwt
import uuid
import time
import requests
from cryptography.hazmat.primitives import serialization

class AuthClient:
    def __init__(self, domain: str, client_id: str, audience: str, private_key_path: str):
        self.domain = domain
        self.client_id = client_id
        self.audience = audience
        self.private_key_path = private_key_path
        self.token = None
        self.token_expiry = None

    def _load_private_key(self):
        """Load the RSA private key from file."""
        with open(self.private_key_path, 'rb') as f:
            return serialization.load_pem_private_key(f.read(), password=None)

    def _create_client_assertion(self) -> str:
        """Create a signed JWT for client authentication."""
        private_key = self._load_private_key()
        now = int(time.time())

        claims = {
            "iss": self.client_id,
            "sub": self.client_id,
            "aud": f"https://{self.domain}/oauth/token",
            "iat": now,
            "exp": now + 300,  # 5 minutes
            "jti": str(uuid.uuid4()),
        }

        return jwt.encode(claims, private_key, algorithm="RS256")

    def get_token(self) -> str:
        """Get a valid access token, refreshing if necessary."""
        if self._is_token_valid():
            return self.token

        # Create client assertion
        assertion = self._create_client_assertion()

        # Request access token
        response = requests.post(
            f"https://{self.domain}/oauth/token",
            json={
                "client_id": self.client_id,
                "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                "client_assertion": assertion,
                "audience": self.audience,
                "grant_type": "client_credentials"
            },
            headers={"content-type": "application/json"}
        )
        response.raise_for_status()

        data = response.json()
        self.token = data["access_token"]
        # Set expiry with 30-second buffer
        self.token_expiry = time.time() + data["expires_in"] - 30

        return self.token

    def _is_token_valid(self) -> bool:
        """Check if current token is still valid."""
        if not self.token or not self.token_expiry:
            return False
        return time.time() < self.token_expiry


# Usage
auth_client = AuthClient(
    domain="pmx-preprod.us.auth0.com",
    client_id="YOUR_CLIENT_ID",
    audience="https://api.preprod.polymarketexchange.com",
    private_key_path="/path/to/my_private_key.pem"
)

token = auth_client.get_token()
Required packages:
pip install PyJWT cryptography requests

Complete Go Example

package main

import (
    "crypto/rsa"
    "crypto/x509"
    "encoding/json"
    "encoding/pem"
    "fmt"
    "net/http"
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
)

func getAccessToken(domain, clientID, audience, privateKeyPath string) (string, error) {
    // Load private key
    keyData, err := os.ReadFile(privateKeyPath)
    if err != nil {
        return "", fmt.Errorf("read key file: %w", err)
    }

    block, _ := pem.Decode(keyData)
    privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
    if err != nil {
        return "", fmt.Errorf("parse private key: %w", err)
    }

    // Create client assertion JWT
    now := time.Now()
    claims := jwt.MapClaims{
        "iss": clientID,
        "sub": clientID,
        "aud": fmt.Sprintf("https://%s/oauth/token", domain),
        "iat": now.Unix(),
        "exp": now.Add(5 * time.Minute).Unix(),
        "jti": uuid.New().String(),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    assertion, err := token.SignedString(privateKey)
    if err != nil {
        return "", fmt.Errorf("sign assertion: %w", err)
    }

    // Request access token
    // (implement HTTP POST to token endpoint)
    // ...

    return accessToken, nil
}

Using the Access Token

Include the access token in the Authorization header for all API requests:

REST API

curl -X GET "https://api.preprod.polymarketexchange.com/v1/whoami" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "x-participant-id: firms/ISV-Participant-YourISV/users/your-user"

gRPC

import grpc

# Create metadata with token
metadata = [
    ('authorization', f'Bearer {access_token}')
]

# Make gRPC call with metadata
response = stub.SomeMethod(request, metadata=metadata)

Key Rotation

You can rotate your keys at any time:
  1. Generate a new key pair
  2. Complete a new Onboarding submission with the new public key
  3. We add the new key to your application
  4. Update your systems to use the new private key
  5. Notify us to remove the old public key

Troubleshooting

Common Errors

ErrorCauseSolution
invalid_clientJWT signature verification failedVerify private key matches registered public key
invalid_client_assertionMalformed JWT or wrong claimsCheck JWT claims (iss, sub, aud, exp)
401 UnauthorizedInvalid or expired access tokenRequest a new access token
403 ForbiddenIP not allowlistedContact support to add your IP

Debugging JWT Claims

If authentication fails, verify your client assertion JWT contains correct claims:
{
  "iss": "YOUR_CLIENT_ID",
  "sub": "YOUR_CLIENT_ID",
  "aud": "https://pmx-preprod.us.auth0.com/oauth/token",
  "iat": 1703270400,
  "exp": 1703270700,
  "jti": "550e8400-e29b-41d4-a716-446655440000"
}
Common mistakes:
  • Wrong aud (must be the token endpoint, not the API)
  • Expired JWT (exp in the past)
  • Reused jti (must be unique per request)

API Scopes

Your application is granted specific scopes that control which API endpoints you can access. Scopes are included in your access token and validated by the API.

Available Scopes

ScopeDescription
read:marketdataBBO (best bid/offer) and market data subscriptions (including BiDirectionalStreamMarketData)
read:l2marketdataL2 orderbook depth (premium)
read:instrumentsRefData, instrument listings and metadata
read:ordersView open orders, preview orders, order subscriptions
write:ordersInsert / cancel / replace / modify orders
read:reportsSearch/download orders, trades, executions, and incentives earnings
read:positionsPosition queries, balance queries, position ledger, balance ledger, and position valuations (including historical via as_of_time / as_of_date)
read:dropcopyDrop copy subscriptions
read:accountsView users (/v1/whoami, /v1/users) and account info
read:fundingView funding sources and transactions
write:fundingUpdate funding, create deposits and withdrawals
read:kycView KYC status and referral codes
write:kycStart KYC verification, manage webhooks
Strict scope enforcement. Calls that are missing a required scope fail with 403 Forbidden (REST) / PERMISSION_DENIED (gRPC) and the message permission denied: missing required scope <scope>. Balance ledger endpoints use read:positions (not read:funding) to stay consistent with the existing balance-query endpoints (GetAccountBalance, ListAccountBalances).

Scope Requirements by Endpoint

EndpointMethodRequired Scope
/v1/trading/ordersPOSTwrite:orders
/v1/trading/orders/cancelPOSTwrite:orders
/v1/trading/orders/openGETread:orders
/v1/report/orders/searchPOSTread:reports
/v1/report/trades/searchPOSTread:reports
/v1/incentives/earningsGETread:reports  (disabled in preprod)
/v1/positionsGETread:positions
/v1/positions/balancePOSTread:positions
/v1/positions/balancesPOSTread:positions
/v1/positions/ledgerGETread:positions
/v1/positions/ledger/downloadGETread:positions
/v1/funding/balance-ledgerGETread:positions
/v1/funding/balance-ledger/downloadGETread:positions
CreateBalanceLedgerSubscription (gRPC)read:positions
/v1/valuations/positionsGETread:positions
/v1/valuations/positions/downloadGETread:positions
/v1/valuations/accounts/statement/downloadPOSTread:positions
/v1/orderbook/{symbol}GETread:l2marketdata
/v1/orderbook/{symbol}/bboGETread:marketdata
BiDirectionalStreamMarketData (gRPC)read:marketdata
CreateMarketDataSubscription (gRPC)read:marketdata
/v1/refdata/symbolsPOSTread:instruments
/v1/refdata/instrumentsPOSTread:instruments
/v1/refdata/metadataPOSTread:instruments
/v1/whoamiGETread:accounts
/v1/usersGETread:accounts
/v1/funding/accountsGETread:funding
/v1/aeropay/depositsPOSTwrite:funding
/v1/checkout/depositsPOSTwrite:funding
/v1/kyc/statusGETread:kyc
/v1/kyc/verifyPOSTwrite:kyc
/v1/healthGET(no auth required)
gRPC streams (BiDirectionalStreamMarketData, CreateMarketDataSubscription, CreateBalanceLedgerSubscription) run on a separate ALB (grpc-api.{env}.polymarketexchange.com:443) that bypasses the API Gateway and its 30-second idle timeout. Scope validation for these streams happens at the application layer rather than at the load balancer, but the resulting PERMISSION_DENIED behavior is identical to REST endpoints.
/v1/incentives/earnings is currently disabled in preprod and returns a route-not-found error there. The endpoint is live in dev01 and prod. Users (auto-created via KYC approval) are not exposed via a POST /v1/users endpoint — there is no write API for users. See Users for the auto-provisioning flow.

Permission Denied Response

When a request’s token is missing the required scope:
{
  "code": 7,
  "message": "permission denied: missing required scope read:positions"
}
SurfaceStatus / Code
REST403 Forbidden
gRPCPERMISSION_DENIED (code 7)
If you receive this error, update your Auth0 application to include the missing scope and request a fresh access token.

Checking Your Scopes

Your granted scopes are included in your access token. You can decode the token to see them:
import base64
import json

# Decode the payload (middle part of JWT)
payload = access_token.split('.')[1]
payload += '=' * (4 - len(payload) % 4)  # Add padding
claims = json.loads(base64.urlsafe_b64decode(payload))

print("Granted scopes:", claims.get("scope", ""))
If you receive a 403 Forbidden error, check that your application has been granted the required scope for that endpoint. Contact support to request additional scopes.

Additional Resources

For more details on Private Key JWT authentication:

Next Steps

Health Check

Verify API connectivity

REST API

Explore the REST API endpoints

gRPC Streaming

Set up real-time data streams

Connection Options

Review connection methods