# pip install "pyjwt[crypto]" requests grpcio
#
# Canonical Polymarket US token client. Other pages in this space do:
# from polymarket_auth import PolymarketClient
import time
import uuid
import grpc
import jwt # PyJWT
import requests
# --- Values that vary per partner -------------------------------------------
# From us at your credential gate (see "Generate your keys").
CLIENT_ID = "REPLACE_WITH_YOUR_CLIENT_ID"
# The private half of the keypair you generated (see "Generate your keys"). Never leaves your infra.
PRIVATE_KEY_PATH = "yourfirm-preprod-private.pem"
# Preprod values from "Environments and endpoints". Production:
# https://pmx-prod.us.auth0.com/oauth/token and https://api.prod.polymarketexchange.com
TOKEN_ENDPOINT = "https://pmx-preprod.us.auth0.com/oauth/token"
API_BASE_URL = "https://api.preprod.polymarketexchange.com"
# Preprod gRPC target from "Environments and endpoints". Production:
# grpc-api.prod.polymarketexchange.com:443
GRPC_TARGET = "grpc-api.preprod.polymarketexchange.com:443"
# ---------------------------------------------------------------------------
ASSERTION_TTL_SECONDS = 300 # exp may be at most 5 minutes after iat
REFRESH_BUFFER_SECONDS = 30 # re-mint this long before expires_in elapses
class PolymarketClient:
def __init__(self, client_id=CLIENT_ID, private_key_path=PRIVATE_KEY_PATH,
token_endpoint=TOKEN_ENDPOINT, api_base_url=API_BASE_URL,
grpc_target=GRPC_TARGET):
self.client_id = client_id
self.token_endpoint = token_endpoint
self.api_base_url = api_base_url
self.grpc_target = grpc_target
with open(private_key_path, "rb") as fh:
self.private_key = fh.read()
self._token = None
self._expires_at = 0.0
def _client_assertion(self):
now = int(time.time())
return jwt.encode(
{
"iss": self.client_id,
"sub": self.client_id,
# aud is the TOKEN ENDPOINT, not the API base URL.
"aud": self.token_endpoint,
"iat": now,
"exp": now + ASSERTION_TTL_SECONDS,
"jti": str(uuid.uuid4()), # unique per assertion
},
self.private_key,
algorithm="RS256",
)
def access_token(self):
if self._token and time.time() < self._expires_at:
return self._token
resp = requests.post(
self.token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_assertion_type":
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": self._client_assertion(),
# audience is the API BASE URL. Do not send `scope`.
"audience": self.api_base_url,
},
timeout=10,
)
resp.raise_for_status() # never swallow a token failure
body = resp.json()
self._token = body["access_token"]
# Honour expires_in minus a 30s buffer. Never hardcode 180.
self._expires_at = time.time() + int(body["expires_in"]) - REFRESH_BUFFER_SECONDS
return self._token
def auth_metadata(self):
"""Header pair for REST headers. gRPC calls use metadata() below."""
return (("authorization", f"Bearer {self.access_token()}"),)
# --- gRPC -----------------------------------------------------------------
def channel(self):
"""Secure channel to the gRPC target. TLS only — auth travels in metadata.
Use it as a context manager: with client.channel() as ch: ...
"""
return grpc.secure_channel(self.grpc_target, grpc.ssl_channel_credentials())
def metadata(self, participant_id=None):
"""Per-call gRPC metadata.
access_token() re-mints at expires_in minus 30 s, so every call gets a
live token. Pass participant_id ONLY on account-scoped calls.
"""
md = [("authorization", f"Bearer {self.access_token()}")]
if participant_id:
md.append(("x-participant-id", participant_id))
return tuple(md)
def get(self, path):
resp = requests.get(
f"{self.api_base_url}{path}",
headers={"authorization": f"Bearer {self.access_token()}"},
timeout=10,
)
resp.raise_for_status()
return resp.json()
if __name__ == "__main__":
client = PolymarketClient()
print(client.get("/v1/whoami"))
// npm i jose @grpc/grpc-js (Node 18+ — global fetch)
//
// Canonical Polymarket US token client. Other pages in this space do:
// import { PolymarketClient } from "./polymarketAuth";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { SignJWT, importPKCS8 } from "jose";
import { Channel, ChannelCredentials, Metadata } from "@grpc/grpc-js";
// --- Values that vary per partner -------------------------------------------
// From us at your credential gate (see "Generate your keys").
const CLIENT_ID = "REPLACE_WITH_YOUR_CLIENT_ID";
// The private half of the keypair you generated (see "Generate your keys").
// importPKCS8 needs a "-----BEGIN PRIVATE KEY-----" file. If yours says
// "BEGIN RSA PRIVATE KEY", convert it once:
// openssl pkcs8 -topk8 -nocrypt -in in.pem -out out.pem
const PRIVATE_KEY_PATH = "yourfirm-preprod-private.pem";
// Preprod values from "Environments and endpoints". Production:
// https://pmx-prod.us.auth0.com/oauth/token and https://api.prod.polymarketexchange.com
const TOKEN_ENDPOINT = "https://pmx-preprod.us.auth0.com/oauth/token";
const API_BASE_URL = "https://api.preprod.polymarketexchange.com";
// Preprod gRPC target from "Environments and endpoints". Production:
// grpc-api.prod.polymarketexchange.com:443
const GRPC_TARGET = "grpc-api.preprod.polymarketexchange.com:443";
// ---------------------------------------------------------------------------
const ASSERTION_TTL_SECONDS = 300; // exp may be at most 5 minutes after iat
const REFRESH_BUFFER_SECONDS = 30; // re-mint this long before expires_in elapses
export class PolymarketClient {
private token: string | null = null;
private expiresAtMs = 0;
constructor(
private clientId = CLIENT_ID,
private privateKeyPath = PRIVATE_KEY_PATH,
private tokenEndpoint = TOKEN_ENDPOINT,
private apiBaseUrl = API_BASE_URL,
private grpcTarget = GRPC_TARGET,
) {}
private async clientAssertion(): Promise<string> {
const pem = readFileSync(this.privateKeyPath, "utf8");
const key = await importPKCS8(pem, "RS256");
const now = Math.floor(Date.now() / 1000);
return new SignJWT({ jti: randomUUID() }) // unique per assertion
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuer(this.clientId)
.setSubject(this.clientId)
// aud is the TOKEN ENDPOINT, not the API base URL.
.setAudience(this.tokenEndpoint)
.setIssuedAt(now)
.setExpirationTime(now + ASSERTION_TTL_SECONDS)
.sign(key);
}
async accessToken(): Promise<string> {
if (this.token && Date.now() < this.expiresAtMs) return this.token;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientId,
client_assertion_type:
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: await this.clientAssertion(),
// audience is the API BASE URL. Do not send `scope`.
audience: this.apiBaseUrl,
});
const res = await fetch(this.tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
throw new Error(`token endpoint ${res.status}: ${await res.text()}`);
}
const json = (await res.json()) as { access_token: string; expires_in: number };
this.token = json.access_token;
// Honour expires_in minus a 30s buffer. Never hardcode 180.
this.expiresAtMs = Date.now() + (json.expires_in - REFRESH_BUFFER_SECONDS) * 1000;
return this.token;
}
/** Header pair for REST headers. gRPC calls use metadata() below. */
async authMetadata(): Promise<Record<string, string>> {
return { authorization: `Bearer ${await this.accessToken()}` };
}
// --- gRPC ------------------------------------------------------------------
/** Secure channel to the gRPC target. TLS only — auth travels in metadata. */
channel(): Channel {
return new Channel(this.grpcTarget, ChannelCredentials.createSsl(), {});
}
/**
* Per-call gRPC metadata. accessToken() re-mints at expires_in minus 30 s,
* so every call gets a live token. Pass participantId ONLY on
* account-scoped calls.
*/
async metadata(participantId?: string): Promise<Metadata> {
const md = new Metadata();
md.set("authorization", `Bearer ${await this.accessToken()}`);
if (participantId) md.set("x-participant-id", participantId);
return md;
}
async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.apiBaseUrl}${path}`, {
headers: await this.authMetadata(),
});
if (!res.ok) throw new Error(`GET ${path} ${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
}
if (process.argv[1]?.endsWith("polymarketAuth.ts")) {
new PolymarketClient().get("/v1/whoami").then(console.log, (e) => {
console.error(e);
process.exit(1);
});
}
#!/usr/bin/env bash
# Requires: openssl, curl, jq. No other dependencies.
set -euo pipefail
# --- Values that vary per partner -------------------------------------------
# From us at your credential gate (see "Generate your keys").
CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID"
# The private half of the keypair you generated (see "Generate your keys").
PRIVATE_KEY="yourfirm-preprod-private.pem"
# Preprod values from "Environments and endpoints". Production:
# https://pmx-prod.us.auth0.com/oauth/token and https://api.prod.polymarketexchange.com
TOKEN_ENDPOINT="https://pmx-preprod.us.auth0.com/oauth/token"
API_BASE_URL="https://api.preprod.polymarketexchange.com"
# ---------------------------------------------------------------------------
ASSERTION_TTL_SECONDS=300 # exp may be at most 5 minutes after iat
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
NOW=$(date +%s)
JTI=$(openssl rand -hex 16) # unique per assertion
HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
# aud is the TOKEN ENDPOINT, not the API base URL.
PAYLOAD=$(printf '{"iss":"%s","sub":"%s","aud":"%s","iat":%s,"exp":%s,"jti":"%s"}' \
"$CLIENT_ID" "$CLIENT_ID" "$TOKEN_ENDPOINT" "$NOW" "$((NOW + ASSERTION_TTL_SECONDS))" "$JTI" \
| b64url)
SIGNING_INPUT="${HEADER}.${PAYLOAD}"
SIGNATURE=$(printf '%s' "$SIGNING_INPUT" | openssl dgst -sha256 -sign "$PRIVATE_KEY" | b64url)
ASSERTION="${SIGNING_INPUT}.${SIGNATURE}"
# --fail-with-body: non-2xx exits non-zero and still prints the error body.
TOKEN_RESPONSE=$(curl -sS --fail-with-body -X POST "$TOKEN_ENDPOINT" \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
--data-urlencode "client_assertion=${ASSERTION}" \
--data-urlencode "audience=${API_BASE_URL}") # audience is the API BASE URL; no `scope`
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -re .access_token)
EXPIRES_IN=$(printf '%s' "$TOKEN_RESPONSE" | jq -re .expires_in)
# Honour expires_in minus a 30s buffer. Never hardcode 180.
echo "re-mint after $((EXPIRES_IN - 30))s" >&2
# stdout is exactly the token, so every other snippet in this space can do:
# TOKEN="$(./token.sh)"
printf '%s\n' "$ACCESS_TOKEN"
# Demo REST call. On stderr, so it never contaminates the token on stdout.
curl -sS --fail-with-body "${API_BASE_URL}/v1/whoami" \
-H "authorization: Bearer ${ACCESS_TOKEN}" >&2
// go get github.com/golang-jwt/jwt/v5 github.com/google/uuid google.golang.org/grpc
//
// Canonical Polymarket US token client. Other pages in this space do:
// import "example.com/yourfirm/pmauth"
package pmauth
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
// --- Values that vary per partner -------------------------------------------
const (
// From us at your credential gate (see "Generate your keys").
ClientID = "REPLACE_WITH_YOUR_CLIENT_ID"
// The private half of the keypair you generated (see "Generate your keys").
PrivateKeyPath = "yourfirm-preprod-private.pem"
// Preprod values from "Environments and endpoints". Production:
// https://pmx-prod.us.auth0.com/oauth/token and https://api.prod.polymarketexchange.com
TokenEndpoint = "https://pmx-preprod.us.auth0.com/oauth/token"
APIBaseURL = "https://api.preprod.polymarketexchange.com"
// Preprod gRPC target from "Environments and endpoints". Production:
// grpc-api.prod.polymarketexchange.com:443
GRPCTarget = "grpc-api.preprod.polymarketexchange.com:443"
)
// ---------------------------------------------------------------------------
const (
assertionTTL = 5 * time.Minute // exp may be at most 5 minutes after iat
refreshBuffer = 30 * time.Second // re-mint this long before expires_in elapses
)
type Client struct {
mu sync.Mutex
token string
expiresAt time.Time
HTTP *http.Client
}
func New() *Client {
return &Client{HTTP: &http.Client{Timeout: 10 * time.Second}}
}
func (c *Client) clientAssertion() (string, error) {
pem, err := os.ReadFile(PrivateKeyPath)
if err != nil {
return "", fmt.Errorf("read private key: %w", err)
}
key, err := jwt.ParseRSAPrivateKeyFromPEM(pem)
if err != nil {
return "", fmt.Errorf("parse private key: %w", err)
}
now := time.Now()
return jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": ClientID,
"sub": ClientID,
// aud is the TOKEN ENDPOINT, not the API base URL.
"aud": TokenEndpoint,
"iat": now.Unix(),
"exp": now.Add(assertionTTL).Unix(),
"jti": uuid.NewString(), // unique per assertion
}).SignedString(key)
}
func (c *Client) AccessToken() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.expiresAt) {
return c.token, nil
}
assertion, err := c.clientAssertion()
if err != nil {
return "", err
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {ClientID},
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
"client_assertion": {assertion},
// audience is the API BASE URL. Do not send "scope".
"audience": {APIBaseURL},
}
resp, err := c.HTTP.Post(TokenEndpoint,
"application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint %d: %s", resp.StatusCode, body)
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", err
}
c.token = out.AccessToken
// Honour expires_in minus a 30s buffer. Never hardcode 180.
c.expiresAt = time.Now().Add(time.Duration(out.ExpiresIn)*time.Second - refreshBuffer)
return c.token, nil
}
// AuthMetadata is the header pair for REST headers. gRPC calls use Metadata.
func (c *Client) AuthMetadata() (string, string, error) {
tok, err := c.AccessToken()
return "authorization", "Bearer " + tok, err
}
// --- gRPC -------------------------------------------------------------------
// Channel dials the gRPC target over TLS. Auth travels in metadata, not on the
// channel. Close it when you are done with it.
func (c *Client) Channel() (*grpc.ClientConn, error) {
return grpc.NewClient(GRPCTarget, grpc.WithTransportCredentials(
credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12})))
}
// Metadata returns the per-call gRPC metadata. AccessToken re-mints at
// expires_in minus 30 s, so every call gets a live token. Pass participantID
// ONLY on account-scoped calls; pass "" everywhere else.
func (c *Client) Metadata(participantID string) (metadata.MD, error) {
tok, err := c.AccessToken()
if err != nil {
return nil, err
}
md := metadata.Pairs("authorization", "Bearer "+tok)
if participantID != "" {
md.Set("x-participant-id", participantID)
}
return md, nil
}
func (c *Client) Get(path string) ([]byte, error) {
tok, err := c.AccessToken()
if err != nil {
return nil, err
}
req, _ := http.NewRequest(http.MethodGet, APIBaseURL+path, nil)
req.Header.Set("authorization", "Bearer "+tok)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s %d: %s", path, resp.StatusCode, body)
}
return body, nil
}
Token response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLiJ9...",
"expires_in": 180,
"token_type": "Bearer"
}
expires_in, not on a constant.
GET /v1/whoami response
{
"ep3_account_firm_name": "firms/20260821-examplefirminc-api-clearing-member"
}
whoami returns your clearing-member firm, not your participant firm. Your end users live under
the participant firm. Do not build a participant ID from this value — it raises a security alert
against your own account. Read Firms, participants and accounts before you use
this response for anything.The complete field list for this response is not yet published; branch only on
ep3_account_firm_name.API reference: private_key_jwt flow. Opens on the public documentation site in a new tab.
Where it disagrees with this page, this page is authoritative for the partner surface.
The gRPC channel and the per-call metadata
Most of this space is gRPC, so the same module publishes one channel helper and one metadata helper. Every gRPC snippet on every later page imports these two and nothing else:| Language | Channel | Metadata |
|---|---|---|
| Python | PolymarketClient.channel() | PolymarketClient.metadata(participant_id=None) |
| TypeScript | PolymarketClient.channel() | PolymarketClient.metadata(participantId?) |
| Go | pmauth.Client.Channel() | pmauth.Client.Metadata(participantID string) |
| bash | no channel — grpcurl dials | -H flags, token from token.sh |
authorization: Bearer <access_token>
x-participant-id: <participantId> # only when the call is account-scoped
access_token() on every invocation, and access_token() re-mints at expires_in minus 30 seconds — so a long-lived channel never carries a stale token. Build metadata per call, never once at start-up.
x-participant-id is a per-call argument, not a client setting, because the same client makes calls at all three scopes: account-scoped reads require it, CreateVendorOrder does not use it, and CashMovementService must not receive it. Pass the participant ID only where the table on Firms, participants and accounts says to. Legacy drop-copy and balance-ledger examples that build metadata with only authorization return 403 as published.
The gRPC targets are the real ones — grpc-api.preprod.polymarketexchange.com:443 and grpc-api.prod.polymarketexchange.com:443. The form grpc-preprod.polymarketexchange.com on the legacy environments page does not resolve.
curl cannot speak gRPC
There is no REST equivalent for most of this surface, so the command-line tab on later pages is grpcurl, not curl. Two things it needs:
-Hflags for the metadata, one per header:-H "authorization: Bearer ${TOKEN}"and, on account-scoped calls,-H "x-participant-id: ${PARTICIPANT_ID}".-import-pathand-protopointing at your local proto copy. Server reflection is entitlement-gated and returnsPermissionDenied: method not permittedwithout the grant, so-use-reflectionfails for most partners. See Protos and SDKs.
token.sh above is the token source: it prints the access token on stdout, so every grpcurl snippet in this space starts with TOKEN="$(./token.sh)". It re-mints on every run, which is correct for a shell one-liner and wrong for a service — use one of the three long-running clients in a process that stays up.
TOKEN="$(./token.sh)" # the script above; stdout is exactly the token
SERVICE="..." # fully-qualified service name, read from your own proto copy
METHOD="..." # the RPC on it, read from your own proto copy
grpcurl \
-import-path ./polymarket-protos \
-proto polymarket/v1/order.proto \
-H "authorization: Bearer ${TOKEN}" \
-d '{}' \
grpc-api.preprod.polymarketexchange.com:443 \
"${SERVICE}/${METHOD}"
[VERIFY] Fully-qualified service and method names are not published for most of this surface,
and reflection cannot list them for you without the entitlement.Read them out of the proto copy your integration lead sent you.