Contents
API Documentation
Live and prematch odds from Pinnacle, plus a real-time dropping-odds engine — delivered over a REST API that mirrors popular third-party formats, with optional Server-Sent Events streams for push-based clients. Underlying live feed is MQTT WebSocket so updates arrive within ~200 ms.
Introduction
Base URL for this deployment: https://pinnodds.com. Every endpoint below is relative to that host. All responses are JSON.
All requests are authenticated with an API key — your key is your login; there's no username or password. Get a free trial key in one click from the landing page.
/kit/v1/* endpoints mirror the shape of common third-party Pinnacle API formats. Swap the base URL + key in your existing client and it should just work.
/llms-full.txt — full API reference in one plain-text fetch, optimized for LLM context windows. The shorter /llms.txt index is auto-discovered by tools that probe the root.
Authentication
Pass your API key in one of three ways, in priority order:
x-portal-apikeyheader (recommended — matches legacy third-party APIs)x-api-keyheader?key=query parameter (useful for browsers / one-offs)
# Header (recommended) curl -H "x-portal-apikey: YOUR_KEY" \ "https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live" # Query string curl "https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live&key=YOUR_KEY"
Rate limits
Limits are enforced per-key across fixed windows. When you exceed one, you get 429 with a Retry-After header.
| Plan | Price | Second | Minute | Hour | Day | Total | SSE drops |
|---|---|---|---|---|---|---|---|
| Trial | $0 | — | 20 | 100 | 100 | Unlimited | — |
| Stream | $99/mo | — | 20 | 100 | 100 | Unlimited | ✓ Included |
| Pro | $99/mo | 10 req/sec | — | — | — | Unlimited | — |
| Pro + SSE | $149/mo | 10 req/sec | — | — | — | Unlimited | ✓ Included |
| Scale | $229/mo | 30 req/sec | — | — | — | Unlimited | ✓ Included |
Trial is capped at 100 requests per day total — once you exceed it you'll get 429 with window=day until UTC midnight rolls over. Stream ($99/mo) gives you push SSE drops only. Pro ($99/mo) gives you REST 10 req/sec only. Pro + SSE ($149/mo) bundles both — saves $49 vs buying separately.
# 429 response when you hit a limit HTTP/1.1 429 Too Many Requests Retry-After: 41 Content-Type: application/json { "error": "rate_limited", "window": "minute", "limit": 20, "retry_after_ms": 41052 }
Errors
| Status | Error code | When it happens |
|---|---|---|
| 400 | — | Malformed or missing required parameter |
| 401 | missing_key | No API key was sent |
| 401 | invalid_key | Key not in our DB (typo, deleted, or regenerated) |
| 401 | Unauthorized | SSE token missing or rejected (only on /odds-drop* streams) |
| 403 | plan_lacks_sse | Key is valid but the plan doesn't include SSE streams (Stream, Pro + SSE, Scale only) |
| 403 | plan_lacks_ws | Key is valid but the WebSocket add-on isn't active on the account |
| 413 | payload_too_large | Request body over 64 KB (POST endpoints) |
| 429 | rate_limited | Key exceeded its rate limit — honor the Retry-After header. Also sent on connection floods to SSE/WS endpoints |
| 500 | internal | Unexpected server error — safe to retry with backoff |
| 503 | prematch_disabled | Prematch endpoint hit while the prematch ingester is off |
Live odds
Snapshot of every event currently in-play. Updates arrive via the upstream MQTT WebSocket so each call returns fresh prices (typical lag <200 ms).
Returns every event for a sport, with all markets (moneyline, spreads, totals, team totals) across every period (match, 1st half, 2nd half, etc.). Live by default; pass event_type=prematch to switch feeds.
Query parameters
| Name | Type | Description |
|---|---|---|
| sport_id required | integer | Dropping-odds sport id. See Sport IDs. |
| event_type | "live" | "prematch" | Defaults to live. Pass prematch to receive prematch fixtures with the same response envelope. Both flavors return identical event shape — only event_type on each event differs. Equivalent to /kit/v1/prematch/fixtures with that param set. |
| since | integer | Return only events that changed since this last value (incremental polling). |
| include_specials | 1 | nested | Include special-market events (Player Props, Team Props, Exact Scores, futures). 1 = flat extra rows linked by parent_id (delta-friendly); nested = grouped under each parent event's specials array (one row per match). Off by default. See Player props & specials. |
| is_have_odds | 0 | 1 | Legacy compat — accepted and ignored. We always return events with odds. |
event_type on each event differs. Existing client code that iterates events[*].periods.num_0 works for both feeds without branching.
curl -H "x-portal-apikey: $KEY" \
"https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live"
const res = await fetch( `${BASE}/kit/v1/markets?sport_id=2&event_type=live`, { headers: { "x-portal-apikey": KEY } } ); const { events, last } = await res.json();
import requests r = requests.get( f"{BASE}/kit/v1/markets", params={"sport_id": 2, "event_type": "live"}, headers={"x-portal-apikey": KEY}, ) data = r.json()
package main import ( "encoding/json" "net/http" ) req, _ := http.NewRequest("GET", BASE+"/kit/v1/markets?sport_id=2&event_type=live", nil) req.Header.Set("x-portal-apikey", KEY) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() var out struct { Events []map[string]interface{} `json:"events"`; Last int64 `json:"last"` } json.NewDecoder(resp.Body).Decode(&out)
<?php $ch = curl_init(BASE . "/kit/v1/markets?sport_id=2&event_type=live"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-portal-apikey: $KEY"], ]); $body = curl_exec($ch); curl_close($ch); $data = json_decode($body, true);
Response — see Event object
{
"sport_id": 2,
"sport_name": "Tennis",
"last": 141,
"last_call": 141,
"events": [
{ /* see Event object below */ }
]
}
Fetch a single event by ID. Same event shape as /kit/v1/markets.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Arcadia matchup ID from a previous /markets response. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/details?event_id=1628594960"
Prematch odds
Pinnacle's prematch lines for upcoming fixtures. Backed by a separate REST poller (refreshes every 30 s) and stored independently from the live feed — querying it never starves the live snapshot.
/kit/v1/markets for in-play prices, /kit/v1/prematch/fixtures for upcoming-event lines. The two surfaces share the same sport IDs and event-object shape so client code can branch on a single field.
Every prematch fixture for a sport, with its current full markets/periods structure. Mirrors the shape of /kit/v1/markets — only the event_type field differs.
Query parameters
| Name | Type | Description |
|---|---|---|
| sport_id required | integer | Dropping-odds sport id. See Sport IDs. |
| since | integer | Return only events that changed since this last value (incremental polling). |
| include_specials | 1 | nested | Include special-market events (Player Props, Team Props, Exact Scores, futures). 1 = flat extra rows linked by parent_id (delta-friendly); nested = grouped under each parent event's specials array (one row per match). Off by default. See Player props & specials. |
curl -H "x-portal-apikey: $KEY" \
"https://pinnodds.com/kit/v1/prematch/fixtures?sport_id=1"
const r = await fetch( `${BASE}/kit/v1/prematch/fixtures?sport_id=1`, { headers: { "x-portal-apikey": KEY } } ); const { events } = await r.json(); const withOdds = events.filter(e => e.is_have_odds && e.periods?.num_0);
import requests r = requests.get( f"{BASE}/kit/v1/prematch/fixtures", params={"sport_id": 1}, headers={"x-portal-apikey": KEY}, ) events = r.json()["events"] with_odds = [e for e in events if e.get("is_have_odds") and e.get("periods", {}).get("num_0")]
req, _ := http.NewRequest("GET", BASE+"/kit/v1/prematch/fixtures?sport_id=1", nil) req.Header.Set("x-portal-apikey", KEY) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() var out struct { Events []map[string]interface{} `json:"events"` } json.NewDecoder(resp.Body).Decode(&out) // out.Events[i] has periods, money_line, spreads, totals, team_total, etc.
<?php $ch = curl_init(BASE . "/kit/v1/prematch/fixtures?sport_id=1"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-portal-apikey: $KEY"], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); $with_odds = array_filter($data["events"], fn($e) => ($e["is_have_odds"] ?? false) && isset($e["periods"]["num_0"]));
Response — same shape as /kit/v1/markets with event_type: "prematch" on each event:
# → 200 OK — same envelope as /kit/v1/markets, only event_type differs { "sport_id": 1, "sport_name": "Soccer", "last": 523864, "last_call": 523864, "events": [ { "event_id": 1629513753, "sport_id": 1, "league_id": 5488, "league_name": "Italy - Serie A", "home": "Cremonese", "away": "Lazio", "starts": "2026-05-04T16:30:00Z", "start_ts": "2026-05-04T16:30:00Z", // alias of starts (back-compat) "event_type": "prematch", "is_have_odds": true, "is_have_periods": true, "periods": { "num_0": { "number": 0, "description": "Game", "money_line": { "home": 2.45, "draw": 3.30, "away": 2.90 }, "spreads": { "-0.5": { "hdp": -0.5, "home": 2.06, "away": 1.82, "max": 500 } }, "totals": { "2.5": { "points": 2.5, "over": 1.90, "under": 1.98, "max": 500 } }, "team_total": { "home": { "points": 1.5, "over": 1.86, "under": 2.00 } }, "meta": { "number": 0, "max_total": 500, "home_score": 0, "away_score": 0 } } // num_1 (1st half), num_2 (2nd half), etc. when offered }, "status": "pending" } ] }
Full markets payload for a single prematch event — every period, money line, spread, total, team total. Use this once you've picked an event from /prematch/fixtures.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Pinnacle matchup ID. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/markets?event_id=1629513753"
Line view for a single prematch event, optionally narrowed to one market type. Returns the same periods structure (num_0, num_1, …) as /markets; with market_type set, each period carries only that market — a lighter payload, ideal for tickers.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Pinnacle matchup ID. |
| market_type | string | Filter to one market: money_line | spreads | totals | team_total. Omit for all markets. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/lines?event_id=1629513753&market_type=totals"
Player props & specials
Beyond the main match markets, we carry Pinnacle's special markets: Player Props (anytime/first goalscorer, "player X to score", player to be booked), Team Props (HT/FT, winning margin, exact total goals, odd/even, 3-way handicaps, "team to score" …), Exact Scores (correct score), and tournament futures (to reach the final, outrights). Each special is its own event row with its own event_id, linked to the main match by parent_id.
Specials are excluded by default so existing integrations keep their payload size — opt in with include_specials=1 (flat rows, cheapest with ?since polling) or include_specials=nested (specials grouped under each parent event's specials array — one row per match) on /kit/v1/prematch/fixtures or /kit/v1/markets. To fetch a single special's prices, pass its event_id to /kit/v1/prematch/markets — no flag needed there.
Special-row fields (on top of the standard event fields):
| Field | Type | Description |
|---|---|---|
| special | string | Market description, e.g. "Anytime Goalscorer", "Mohamed Salah To Score", "Winning Margin". |
| special_category | string | "Player Props" | "Team Props" | "Exact Scores" | "Futures" | … |
| special_units | string | Units the outcomes are counted in (e.g. "Regular", "Corners", "Runs"). |
| parent_id | integer | The main match's event_id — group a fixture's specials by this. |
| special_markets | object | Named-outcome prices per period: {"num_0": [{type, key, side, max_risk, prices: [{name, participant_id, points, price}]}]}. price is decimal odds; name is the outcome ("Mohamed Salah", "Yes", "2-1", "4+"); max_risk is Pinnacle's max stake for the market. |
Example — player props for Australia vs Egypt (main event 1632164518):
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/fixtures?sport_id=1&include_specials=1" # → rows with parent_id 1632164518 include: # 1632190502 "Anytime Goalscorer" (Player Props) # 1632190468 "First Goalscorer" (Player Props) # 1632278803 "Mohamed Salah To Score" (Player Props) # 1632181240 "Winning Margin" (Team Props) … ~50 more curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/markets?event_id=1632278803"
{
"events": [{
"event_id": 1632278803,
"home": "Australia", "away": "Egypt",
"parent_id": 1632164518,
"special": "Mohamed Salah To Score",
"special_category": "Player Props",
"special_markets": {
"num_0": [{
"type": "moneyline", "max_risk": 1000,
"prices": [
{ "name": "No", "price": 1.33 },
{ "name": "Yes", "price": 3.43 }
]
}]
}
}]
}
/api/drops with participant_name set. Specials are thin markets — check max_risk before sizing. Like all prematch rows, a special's ID dies at kickoff.
Dropping odds
Detect price movements ("drops") in real time across both live and prematch feeds. A drop fires when an outcome's decimal odds fall by ≥ a threshold in a configured window. Each drop carries the from and to price plus full event context.
Pull-style queryable buffer of recent drops. Apply filters and read the full result set — useful for batch jobs, dashboards, or polling clients.
Each drop carries nvp — the vig-removed ("no-vig") fair price for that outcome, the same value emitted on the SSE streams. Use it to gauge edge, e.g. edge = to / nvp - 1.
Query parameters
| Name | Type | Description |
|---|---|---|
| mode | "live" | "prematch" | Which feed to query. Defaults to live. |
| sport_id | integer | Filter to a single sport. Omit for all. |
| min_drop_pct | number | Minimum drop percentage (default 5). Fractional allowed. |
| max_drop_pct | number | Optional cap. |
| max_age_sec | integer | Only return drops fresher than this. Up to ~3 h. |
| markets | csv | Subset of moneyline,spread,total,team_total. |
| periods | csv | Period numbers (0=full match, 1=1st half, etc.). |
| live | 0 | 1 | Live-mode only. 1 = exclude any drops on events not yet started. |
| limit | integer | Max drops to return (default 500). |
# Live drops, soccer only, ≥7%, last 10 min curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/api/drops?mode=live&sport_id=1&min_drop_pct=7&max_age_sec=600" # Prematch drops, all sports, last hour curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/api/drops?mode=prematch&min_drop_pct=2&max_age_sec=3600"
const qs = new URLSearchParams({ mode: "live", sport_id: 1, min_drop_pct: 7, max_age_sec: 600, }); const r = await fetch(`${BASE}/api/drops?${qs}`, { headers: { "x-portal-apikey": KEY }, }); const { total, drops } = await r.json(); for (const d of drops) { const ev = (d.to / d.nvp) - 1; // edge vs no-vig price if (ev > 0.02) console.log("+EV", d.home, d.market, d.from, "→", d.to); }
import requests r = requests.get( f"{BASE}/api/drops", params={ "mode": "live", "sport_id": 1, "min_drop_pct": 7, "max_age_sec": 600, }, headers={"x-portal-apikey": KEY}, ) for d in r.json()["drops"]: ev = (d["to"] / d["nvp"]) - 1 if ev > 0.02: print("+EV", d["home"], d["market"], d["from"], "→", d["to"])
q := url.Values{}
q.Set("mode", "live"); q.Set("sport_id", "1")
q.Set("min_drop_pct", "7"); q.Set("max_age_sec", "600")
req, _ := http.NewRequest("GET", BASE+"/api/drops?"+q.Encode(), nil)
req.Header.Set("x-portal-apikey", KEY)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var out struct {
Total int `json:"total"`
Drops []struct {
Home, Market string; From, To, NVP float64
} `json:"drops"`
}
json.NewDecoder(resp.Body).Decode(&out)
<?php $qs = http_build_query([ "mode" => "live", "sport_id" => 1, "min_drop_pct" => 7, "max_age_sec" => 600, ]); $ch = curl_init(BASE . "/api/drops?$qs"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-portal-apikey: $KEY"], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($data["drops"] as $d) { $ev = ($d["to"] / $d["nvp"]) - 1; if ($ev > 0.02) echo "+EV {$d['home']} {$d['from']} → {$d['to']}\n"; }
# Response { "total": 42, "drops": [ { "event_id": 1629725918, "sport_name": "Soccer", "league": "Australia - NPL Victoria", "home": "Bentleigh Greens", "away": "St Albans Saints", "market": "spread", "period": 0, "side": "home", "points": -0.5, "from": 2.37, "to": 2.25, "nvp": 2.34, "drop_pct": 5.06, "age_s": 12, "is_live": false } ], "meta": { "mode": "live", "events_in_store": 2552, "tracked_outcomes": 122035, "drops_buffered": 968, "source": "ws", "ts": 1783079852368 } }
Note. Drops on special markets (player/team props) carry participant_name — the named outcome that moved (e.g. "Mohamed Salah") — with nvp: null (no two-way devig exists for multi-outcome ladders).
Server-Sent Events stream of live drops as they happen. One TCP connection, push-based — no polling. Available on the Stream, Pro + SSE, and Scale plans. Trial keys and Pro (REST-only) cannot connect.
Two auth methods accepted (use either):
| Name | Type | Description |
|---|---|---|
| key | string | Your SaaS API key, in the x-portal-apikey / x-api-key header or as ?key=. Plan must include SSE (Stream / Pro + SSE / Scale). |
| token | string | Legacy SSE token (separate from the API key) as ?token=. Kept for admin / pre-account customers. |
| min_drop | number | Optional. Drop-percent threshold for THIS subscriber. ?min_drop=7 = only alerts where price fell ≥7%. ?min_drop=1 = receive every drop, including small ones. Default if omitted: 5%. Floor: 1%. No upper cap. |
# With your API key (paid plan) curl -N "https://pinnodds.com/odds-drop?key=$API_KEY" # Or with a legacy SSE token curl -N "https://pinnodds.com/odds-drop?token=$SSE_TOKEN"
// npm i eventsource import EventSource from "eventsource"; const es = new EventSource(`${BASE}/odds-drop?key=${API_KEY}`); es.onmessage = (e) => { const payload = JSON.parse(e.data); if (payload.type === "connected") return; if (payload.type === "error") { console.error(payload.error, payload.message); return; } for (const drop of payload) { const ev = (drop.to_price / drop.nvp) - 1; if (ev > 0.02) console.log("+EV", drop.sport, drop.home, drop.from_price, "→", drop.to_price); } }; es.onerror = (err) => console.error("sse closed", err);
# pip install sseclient-py requests import json, requests from sseclient import SSEClient resp = requests.get(f"{BASE}/odds-drop?key={API_KEY}", stream=True) for ev in SSEClient(resp).events(): if not ev.data: continue payload = json.loads(ev.data) if isinstance(payload, dict) and payload.get("type") in ("connected", "error"): continue for drop in payload: ev_pct = (drop["to_price"] / drop["nvp"]) - 1 if ev_pct > 0.02: print("+EV", drop["sport"], drop["home"], drop["from_price"], "→", drop["to_price"])
package main import ( "bufio"; "encoding/json"; "net/http"; "strings"; "fmt" ) type Drop struct { Home, Sport string; FromPrice, ToPrice, NVP float64 } req, _ := http.NewRequest("GET", BASE+"/odds-drop?key="+API_KEY, nil) req.Header.Set("Accept", "text/event-stream") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } body := strings.TrimPrefix(line, "data: ") var drops []Drop if err := json.Unmarshal([]byte(body), &drops); err != nil { continue } for _, d := range drops { if ev := d.ToPrice/d.NVP - 1; ev > 0.02 { fmt.Println("+EV", d.Sport, d.Home, d.FromPrice, "→", d.ToPrice) } } }
<?php $ch = curl_init(BASE . "/odds-drop?key=$API_KEY"); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Accept: text/event-stream"], CURLOPT_TIMEOUT => 0, CURLOPT_WRITEFUNCTION => function($ch, $chunk) { static $buf = ""; $buf .= $chunk; while (($pos = strpos($buf, "\n\n")) !== false) { $line = substr($buf, 0, $pos); $buf = substr($buf, $pos + 2); if (strpos($line, "data: ") !== 0) continue; $payload = json_decode(substr($line, 6), true); if (!is_array($payload) || isset($payload["type"])) continue; foreach ($payload as $d) { $ev = ($d["to_price"] / $d["nvp"]) - 1; if ($ev > 0.02) echo "+EV {$d['sport']} {$d['home']} → {$d['to_price']}\n"; } } return strlen($chunk); }, ]); curl_exec($ch);
# Stream framing (what arrives over the wire)
data: {"type": "connected", "id": "d648beb8-bde8-..."}
data: [{"home": "Sunshine Coast Phoenix", "away": "Cairns Dolphins",
"league": "Australia - NBL1 Women", "from_price": 2.86,
"to_price": 2.7, "outcome": "Home", "period": 4,
"sect": "Moneyline", "id": 1629729400, "sport": "Basketball",
"interval": 25, "alerted": 1777625196, "sport_id": 3,
"nvp": 3.04, "starts": 1777624200, ...}]
{"type":"error","error":"plan_lacks_sse"} as the only frame. Upgrade to Stream, Pro + SSE, or Scale to access SSE.
Connect throttling. Rapid reconnect loops get a single {"type":"error","error":"rate_limited"} frame with a Retry-After: 60 header — back off instead of hammering the endpoint; a healthy client reconnects with exponential backoff.
Same shape and auth as /odds-drop, but emits drops on the prematch feed only. Useful when you want to trade upcoming-fixture line moves and not be flooded with in-play noise.
Optional query parameter: ?min_drop=N overrides the default 5% drop threshold for this subscriber (same as on /odds-drop). Composes with ?recheck=N. Floor is 1%.
One connection per account — see the same warning on /odds-drop. Live and prematch each count as one SSE slot, so you can run one of each in parallel under the default cap.
curl -N "https://pinnodds.com/odds-drop-prematch?key=$API_KEY"
// Identical to /odds-drop integration — only the URL changes. import EventSource from "eventsource"; const es = new EventSource(`${BASE}/odds-drop-prematch?key=${API_KEY}`); es.onmessage = (e) => { const p = JSON.parse(e.data); if (Array.isArray(p)) for (const d of p) console.log(d.sport, d.home, d.from_price, "→", d.to_price); };
import json, requests from sseclient import SSEClient resp = requests.get(f"{BASE}/odds-drop-prematch?key={API_KEY}", stream=True) for ev in SSEClient(resp).events(): p = json.loads(ev.data) if ev.data else None if isinstance(p, list): for d in p: print(d["sport"], d["home"], d["from_price"], "→", d["to_price"])
req, _ := http.NewRequest("GET", BASE+"/odds-drop-prematch?key="+API_KEY, nil) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } // parse JSON array of drops, same shape as /odds-drop }
<?php // Same WRITEFUNCTION pattern as /odds-drop, just change the URL: $ch = curl_init(BASE . "/odds-drop-prematch?key=$API_KEY"); // ... see the /odds-drop PHP example for the full streaming reader ...
Optional: ?recheck=N stable-price filter
How to use it. Append &recheck=N to the URL where N is the number of seconds you want the server to wait before emitting each drop. The server holds the alert for N seconds, re-checks the current Pinnacle price for that exact line, and only sends it if the drop is still real (price hasn't bounced back). If you want a 30-second stability check:
# Standard prematch feed (instant, default — unchanged) curl -N "https://pinnodds.com/odds-drop-prematch?key=$API_KEY" # Wait 30 seconds, then re-verify before emitting curl -N "https://pinnodds.com/odds-drop-prematch?key=$API_KEY&recheck=30"
// EventSource (Node.js / browser) const es = new EventSource(`${BASE}/odds-drop-prematch?key=${API_KEY}&recheck=30`);
| Name | Type | Description |
|---|---|---|
| recheck | integer (seconds) |
How long to hold each drop before re-checking the current Pinnacle price. Surviving alerts pass the same 5% threshold the scanner used and gain a new rechecked_ms field with the actual wait time. Bounced prices are silently suppressed. Omit (or pass 0) for the default instant feed. No upper cap — the parameter only affects your own connection. Other customers connected without it are unaffected.
|
When to use this. If you place bets manually or your placement loop is >5s, prematch prices that bounce back within seconds create noise. Asking for recheck=30 trades 30s of latency for the confidence that the line was stable enough to be actionable.
Note. Available only on /odds-drop-prematch. The live stream /odds-drop is push-based and sub-second; a recheck window is semantically meaningless there and the parameter is silently ignored.
WebSocket Feed (Add-on)
Forwards raw Pinnacle frames to your client — not drops, not derived metrics. Live MQTT messages and prematch REST responses delivered byte-identical to what we receive ourselves, wrapped in a thin envelope. Power-user feature for customers who want to rebuild Pinnacle's full state machine on their side.
This is NOT the same as /odds-drop SSE. SSE delivers our filtered drop alerts (≥5% by default). This WS delivers every Pinnacle frame including market open/close/settle signals. Output volume is much higher.
Pricing
- $99/month add-on on top of any REST-bearing plan (Pro, Pro + SSE, Scale).
- Stream and Trial plans are not eligible — they have no REST quota.
- New / renewal checkout: $99 (30d), $297 (90d), $594 (180d) bundled with your base plan.
- Mid-cycle upgrade: prorated to your renewal date — pay only for the days remaining. Quote via your dashboard.
Subscribe protocol
Client must send a subscribe message within 5s of connecting. You can subscribe by sport (firehose) or by specific event_ids (narrow filter), or both in one message:
{ "type": "subscribe",
"streams": ["live", "prematch"],
"sport_ids": [1, 2], // 1=Soccer, 2=Tennis — full firehose for the sport
"event_ids": [1631005165] // Pinnacle matchup IDs — narrow filter, just these events
}
Filter rule: a frame is delivered if its sport_id matches a sport-level sub or its event_id matches an event-level sub. So sport_ids:[2], event_ids:[12345] means "all of Tennis plus this one specific event in any sport". Either field can be omitted but at least one is required.
Discovery pattern: customers don't usually know matchup IDs upfront. Subscribe to a sport first → grep the snapshot for the events you care about (by team name, league, start time) → unsubscribe from the sport, subscribe to those specific event_ids. This narrows bandwidth to exactly the matches you're trading.
Per-stream cap: 200 event_ids per connection per stream. Subscribing past the cap returns {type:"error", code:"event_id_cap", ...} and the request is rejected wholesale. The hub auto-clears event_ids from your sub when Pinnacle removes the event (op:"del"), so you don't need to send unsubscribe yourself.
Server-sent messages
Immediately after each subscribe, one snapshot per subscription group:
// Sport-level subscribe → one snapshot per (stream, sport_id) with every // event currently in our store for that sport. { "type": "snapshot", "stream": "live", "sport_id": 1, "events": [ /* full Pinnacle records, untouched */ ], "ts": 1779200000000 } // Event-level subscribe → one snapshot per batch of event_ids, with just // the records we currently have. Missing IDs appear in the stream once // Pinnacle pushes them. { "type": "snapshot", "stream": "live", "event_ids": [1631005165], "events": [ /* full Pinnacle records, untouched (one per matched id) */ ], "ts": 1779200000000 }
Then continuous frames as Pinnacle pushes / we poll:
// Live MQTT — `topic`, `op`, `rec` exactly as we received from Pinnacle. { "type": "live", "sport_id": 1, "topic": "matchups/reg/sp/29/live/ld", "op": "upd", "rec": { /* Pinnacle record verbatim */ }, "ts": 1779200000123 } // Prematch REST poll responses — `data` is the response array verbatim. { "type": "prematch_matchups", "sport_id": 1, "arcadia_sid": 29, "data": [ /* /sports/29/matchups response verbatim */ ], "ts": 1779200005000 } { "type": "prematch_markets", "sport_id": 1, "arcadia_sid": 29, "matchup_id": 1631005165, "data": [ /* /matchups/.../markets/related/straight verbatim */ ], "ts": 1779200005230 } // Heartbeat every 30s — reply with {"type":"pong"} or socket closes after ~75s. { "type": "ping", "ts": 1779200030000 }
Fidelity contract. events, rec, and data are never transformed. We only add envelope fields (type, stream, sport_id, topic, op, ts). Your code can reuse a Pinnacle parser unchanged.
Frame flow on firehose subs. live, prematch_matchups and prematch_markets frames all flow on sport_ids subscriptions exactly the same way they do on event_ids subscriptions. There's no event-id-only mode — the filter is identical, only the criterion (whole sport vs. specific ids) changes.
permessage-deflate on your client —
live frames compress about 5×. This matters for high-volume or geographically distant
clients: without it, a burst of updates can exceed a single connection's throughput and queue up,
so frames arrive with a delayed ts. Browsers negotiate it automatically; in Node's
ws pass { perMessageDeflate: true }, Go's gorilla/websocket
set EnableCompression: true, Python's websockets has it on by default.
Same URL, same JSON — your client decompresses transparently.
Example clients
# npm i -g wscat — quick CLI client for ad-hoc inspection wscat -c "wss://pinnodds.com/ws/feed?key=$API_KEY" # then paste a subscribe frame at the prompt: {"type":"subscribe","streams":["live"],"sport_ids":[1,2]}
// npm i ws import WebSocket from "ws"; // perMessageDeflate: true — compress live frames ~5x (recommended) const ws = new WebSocket(`wss://pinnodds.com/ws/feed?key=${API_KEY}`, { perMessageDeflate: true }); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe", streams: ["live", "prematch"], sport_ids: [1, 2], // firehose Soccer + Tennis event_ids: [1631005165], // plus this specific matchup })); }); ws.on("message", (raw) => { const msg = JSON.parse(raw); switch (msg.type) { case "ping": ws.send(JSON.stringify({type:"pong"})); break; case "snapshot": console.log(`snapshot ${msg.stream}: ${msg.events.length} events`); break; case "live": console.log(`live event=${msg.rec.id} op=${msg.op}`); break; case "prematch_matchups": console.log(`matchups sport=${msg.sport_id} n=${msg.data.length}`); break; case "prematch_markets": console.log(`markets event=${msg.matchup_id} n=${msg.data.length}`); break; case "error": console.error(msg.code, msg.message); break; } }); ws.on("close", (code) => console.error("closed", code));
# pip install websockets import asyncio, json, os, websockets async def main(): url = f"wss://pinnodds.com/ws/feed?key={os.environ['API_KEY']}" async with websockets.connect(url) as ws: await ws.send(json.dumps({ "type": "subscribe", "streams": ["live", "prematch"], "sport_ids": [1, 2], "event_ids": [1631005165], })) async for raw in ws: msg = json.loads(raw) t = msg["type"] if t == "ping": await ws.send(json.dumps({"type": "pong"})) elif t == "snapshot": print(f"snapshot {msg['stream']}: {len(msg['events'])} events") elif t == "live": print(f"live event={msg['rec']['id']} op={msg['op']}") elif t == "prematch_matchups": print(f"matchups sport={msg['sport_id']} n={len(msg['data'])}") elif t == "prematch_markets": print(f"markets event={msg['matchup_id']} n={len(msg['data'])}") elif t == "error": print("error", msg["code"], msg.get("message")) asyncio.run(main())
// go get github.com/gorilla/websocket package main import ( "encoding/json"; "fmt"; "log"; "os" "github.com/gorilla/websocket" ) func main() { url := "wss://pinnodds.com/ws/feed?key=" + os.Getenv("API_KEY") // EnableCompression: true — compress live frames ~5x (recommended) dialer := websocket.Dialer{EnableCompression: true} ws, _, err := dialer.Dial(url, nil) if err != nil { log.Fatal(err) } defer ws.Close() sub := map[string]any{ "type": "subscribe", "streams": []string{"live", "prematch"}, "sport_ids": []int{1, 2}, "event_ids": []int64{1631005165}, } if err := ws.WriteJSON(sub); err != nil { log.Fatal(err) } for { _, raw, err := ws.ReadMessage() if err != nil { log.Fatal(err) } var msg map[string]json.RawMessage if err := json.Unmarshal(raw, &msg); err != nil { continue } var t string; json.Unmarshal(msg["type"], &t) switch t { case "ping": ws.WriteJSON(map[string]any{"type":"pong"}) case "snapshot": fmt.Println("snapshot", string(msg["stream"])) case "live": fmt.Println("live", string(msg["rec"])[:60]) case "error": fmt.Println("error", string(msg["code"])) } } }
// composer require textalk/websocket <?php use WebSocket\Client; $ws = new Client("wss://pinnodds.com/ws/feed?key=" . $_ENV["API_KEY"]); $ws->send(json_encode([ "type" => "subscribe", "streams" => ["live", "prematch"], "sport_ids" => [1, 2], "event_ids" => [1631005165], ])); while (true) { $raw = $ws->receive(); $msg = json_decode($raw, true); switch ($msg["type"]) { case "ping": $ws->send(json_encode(["type"=>"pong"])); break; case "snapshot": echo "snapshot {$msg['stream']}: " . count($msg["events"]) . " events\n"; break; case "live": echo "live event={$msg['rec']['id']} op={$msg['op']}\n"; break; case "error": fwrite(STDERR, "error: {$msg['code']} {$msg['message']}\n"); break; } }
Frame semantics — how to read what we forward
Pinnacle pushes incremental updates over MQTT. Once you start receiving frames, the op, topic, and inner rec/data shape determines how to merge into your local state.
Live frames — type:"live"
| Field | Type | Meaning |
|---|---|---|
| op | string | add = new event or new markets appeared. upd = incremental change (typically just changed markets — merge by composite key, don't replace the whole list). del = event removed by Pinnacle (kicked off, settled, voided) — drop your local copy. |
| topic | string | Source MQTT topic. Format: matchups/reg/sp/{arcadia_sid}/live/{ld|dz|both} for live, matchups/.../pre for prematch.ld = "line drawing": main markets (moneyline, 1X2, primary spread/total). dz = "draft zone": alternative spreads and totals. both = combined push covering both. matchups/spc/... instead of reg means a "special" matchup (props, futures). |
| rec.id | int | Pinnacle matchup ID. Stable across the event's lifetime. |
| rec.markets | array | May be a SUBSET on op:"upd" — Pinnacle only sends what changed. Merge by composite key (m.key if present, otherwise type|period|side|points). A market with m.status !== "open" is a CLOSE signal: drop it from your local list, do not merge it back as if still active. |
| rec.periods | array | Per-period status. periods[n].status === "closed" or "settled" implicitly closes ALL markets for that period — drop those locally too, even if they weren't listed in markets. |
| rec.parentId | int? | If present, this is a CHILD matchup (alt periods, props derived from a parent event). Its full metadata (participants, league) lives on the parent record. |
| rec.startTime, status | string | Event-level metadata. Tennis / Golf / Fights frequently see startTime updates without any market change (reschedules) — track these to surface to your users. |
Prematch frames — prematch_matchups + prematch_markets
| Field | Type | Meaning |
|---|---|---|
| type | string | prematch_matchups = response from /sports/{sid}/matchups. The full per-sport matchup list, fired every 5 s. Includes both prematch and live entries — filter by data[i].isLive === false if you want strict prematch.prematch_markets = response from /matchups/{id}/markets/related/straight. Fired only when Pinnacle bumped matchup.version, so roughly the events that had market activity in the last cycle. |
| data | array | For prematch_matchups: array of full matchup records (event metadata). For prematch_markets: array of market objects scoped to one matchup.This is the authoritative current snapshot from Pinnacle — any market in your local store that's NOT in this list has been closed by Pinnacle. Drop it. |
| matchup_id | int | (prematch_markets only) The matchup this markets array belongs to. |
| mode | string? | Present only when MQTT is down and we fell back to live REST polling. Value: "rest_fallback". Frame shape is otherwise identical, so customers usually ignore this field. |
Merge recipe (pseudocode)
// Maintain a Map<matchupId, MatchupState>. For each incoming frame: if (msg.type === "snapshot") { for (const ev of msg.events) state.set(ev.id, ev); } if (msg.type === "live") { if (msg.op === "del") { state.delete(msg.rec.id); return; } const existing = state.get(msg.rec.id) ?? {}; const merged = { ...existing, ...msg.rec }; if (msg.rec.markets) { const byKey = new Map((existing.markets ?? []).map(m => [marketKey(m), m])); for (const m of msg.rec.markets) { if (m.status && m.status !== "open") byKey.delete(marketKey(m)); // close signal else byKey.set(marketKey(m), m); } merged.markets = [...byKey.values()]; } // Honour period-level closes — wipe all markets for closed periods. if (msg.rec.periods) { const closed = new Set(msg.rec.periods.filter(p => p.status !== "open").map(p => p.period)); if (closed.size) merged.markets = merged.markets.filter(m => !closed.has(m.period ?? 0)); } state.set(msg.rec.id, merged); } if (msg.type === "prematch_markets") { // AUTHORITATIVE snapshot for one matchup — replace, don't merge. const existing = state.get(msg.matchup_id) ?? {}; state.set(msg.matchup_id, { ...existing, markets: msg.data }); } function marketKey(m) { return m.key ?? `${m.type}|${m.period ?? 0}|${m.side ?? ""}|${m.prices?.[0]?.points ?? ""}`; }
Reconnect strategy. Re-subscribe on reconnect. Server-side state is not preserved — the snapshot at the start of every connection is your sync point. Exponential backoff (1s → 2s → 4s → 30s cap) recommended. One connection per account on this endpoint (separate slot from SSE — you can run both; opening a second WS connection evicts the first with close code 1001).
Limits & error frames
| Error / behavior | Trigger | What to do |
|---|---|---|
| sport_id_cap | More than 32 sport_ids in one subscribe | Split into batches (there are ~14 real sports — you'll rarely hit this) |
| event_id_cap | More than 200 tracked event_ids per stream | Unsubscribe finished events; slots auto-free on op:"del" |
| subscribe_rate_limited | More than 30 subscribe messages per 10 s | Batch sports/events into fewer subscribe messages |
| backpressure | Subscribe while your connection is backlogged (>32 MB unsent) | The sport was NOT subscribed — drain, then resubscribe |
| slow_consumer (close) | Unsent buffer exceeds 128 MB — you're reading too slowly | Connection is closed; reconnect and read faster / subscribe narrower |
| rate_limited (HTTP 429) | Connection-open flood on the endpoint | Back off per Retry-After; don't reconnect in a tight loop |
| plan_lacks_ws (HTTP 403) | WS add-on not active on the account | Purchase the add-on in the dashboard |
| invalid_json / invalid_message / unknown_message_type | Malformed client message | Fix the message shape — see subscribe example above |
You can also send {"type":"unsubscribe", "streams":[…], "sport_ids":[…]}; every subscribe/unsubscribe is acknowledged with a {"type":"subscribed"|"unsubscribed", "stream", "sport_id"} frame — treat the ack (plus the snapshot) as confirmation, not the send itself.
SportsMeld — team & event matching Free 30-day trial
SportsMeld is our separate entity-resolution service: it decides whether two team/player names refer to the same real-world entity ("Man Utd" = "Manchester United" ≠ "Man City"), and clusters the same fixture across bookmaker feeds. Use it to join this API's Pinnacle names to any other book you consume — no hand-maintained alias tables. 8 sports, high-precision hybrid ML pipeline, cached lookups in sub-millisecond time.
It runs on its own base URL with its own key (not your pinnodds key):
Authorization: Bearer sportsmeld_... against
https://sportsmeld.arbitragex.pro. Full reference:
sportsmeld.arbitragex.pro/docs.
Name matching — one reference vs candidate names:
POST https://sportsmeld.arbitragex.pro/api/match { "reference": "Manchester United", "names": ["Man Utd", "Man City", "MUFC"], "sport": "soccer" } // → details[]: { name, match: true|false, confidence, ambiguous, method }
Fixture matching — cluster the same game across 2+ books:
POST https://sportsmeld.arbitragex.pro/api/match-events { "sport": "soccer", "sources": [ { "name": "pinnodds", "events": [{ "id": "1632373638", "left": "Manchester United", "right": "Liverpool FC", "timestamp": 1720008000 }] }, { "name": "bet365", "events": [{ "id": "b77", "left": "Man Utd", "right": "Liverpool", "timestamp": 1720008000 }] } ] } // → matched[] clusters (ids echoed back), partial[], unmatched{}, stats // timestamps optional; when both sides have one, matches only within ±12h
Launch offer: every pinnodds user gets a free 30-day trial on the scout tier —
15 req/s, 25 names per /api/match call, 5 sources & 500 events/source on
/api/match-events. Free during launch; paid plans later.
Claim it yourself in your panel (Subscription card → "Get your free key")
— the key is shown once and emailed to your account address, and you can rotate it from the
panel any time (rotation never changes your expiry).
Questions? Telegram @ArbitrageXpro.
Status
Operational status of both feeds. Useful for dashboards and alerts.
{
"status": "healthy",
"source": "ws", // ws | rest | none
"events_in_store": 1554,
"version": 297592,
"ws_messages": 595184,
"rest_polls": 0,
"uptime_s": 11938,
"connected_clients": 21, // live SSE subscribers
"drops_pipeline": "on",
"drops_mode": "event-driven",
"drops_pg": "off",
"prematch": {
"enabled": true,
"events_in_store": 2552,
"version": 51375,
"rest_polls": 132,
"source": "rest",
"connected_clients": 14,
"drops": "on"
}
}
Public liveness probe. Returns ok in plain text. Useful for load balancers and uptime monitors that can't send an auth header.
curl https://pinnodds.com/ping
# ok
Sport IDs
Sport IDs are stable integers — once you've coded against an ID it stays valid. Mapped to Pinnacle's internal sport IDs (e.g. dropping-odds 1 = Pinnacle 29 = Soccer).
| ID | Sport | Pinnacle internal ID |
|---|---|---|
| 1 | Soccer | 29 |
| 2 | Tennis | 33 |
| 3 | Basketball | 4 |
| 4 | Hockey | 19 |
| 5 | Football | 15 |
| 6 | Baseball | 3 |
| 7 | Rugby (Union + League) | 27, 26 |
| 8 | MMA (UFC, Bellator, ONE, etc.) | 22 |
| 9 | Boxing | 6 |
| 10 | Other (Volleyball, Handball) | 34, 18 |
| 11 | Esports (Dota 2, CS2, LoL, etc.) | 12 |
| 12 | Golf (PGA, DP World Tour, LIV — head-to-heads) | 17 |
| 13 | Cricket (T20, ODI, Test — added July 2026) | 8 |
Golf note: Pinnacle exposes Golf as moneyline-only (head-to-head matchups, outright winners). Spreads, totals, and team totals are not provided for Golf — your client should expect spreads, totals, and team_total to be empty objects on Golf events. Drops on Golf will only fire from the Moneyline section.
Market types
Returned inside each event's periods[<num_N>] object.
| Field | Shape | Description |
|---|---|---|
| money_line | { home, away, draw } | 3-way (or 2-way for no-draw sports) match result. Decimal odds. |
| spreads | { "<hdp>": { hdp, home, away, max } } | Handicap markets keyed by the point spread. |
| totals | { "<points>": { points, over, under, max } } | Over/under markets keyed by the total. |
| team_total | { home: {...}, away: {...} } | Primary per-team total line per side. Backward-compatible — only ONE line per side. For all alt lines, use team_totals below. |
| team_totals | { home: { "<points>": {points, over, under, max} }, away: {...} } | ALL alternate team-total lines, keyed by points per side. Use this when Pinnacle exposes multiple lines (e.g. home Over 0.5 / 1.5 / 2.5). |
| meta | object | Per-period meta: current scores, max stakes. |
Event object
{
"event_id": 1628594960,
"sport_id": 1,
"league_id": 2037,
"league_name": "China - Super League",
"starts": "2026-04-17T12:00:00Z",
"last": 1776430365,
"home": "Yunnan Yukun",
"away": "Tianjin Jinmen Tiger",
"event_type": "live",
"live_status_id": 1,
"parent_id": 1628594613,
"is_have_odds": true,
"is_have_periods": true,
"periods": {
"num_0": {
"number": 0,
"description": "Game",
"money_line": { "home": 2.450, "draw": 3.300, "away": 2.900 },
"spreads": { "-0.5": { "hdp": -0.5, "home": 2.060, "away": 1.820, "max": 500 } },
"totals": { "2.5": { "points": 2.5, "over": 1.900, "under": 1.980, "max": 500 } },
"team_total": { "home": { "points": 1.5, "over": 1.860, "under": 2.000 } },
"team_totals": {
"home": {
"0.5": { "points": 0.5, "over": 1.200, "under": 4.500, "max": 100 },
"1.5": { "points": 1.5, "over": 1.860, "under": 2.000, "max": 100 },
"2.5": { "points": 2.5, "over": 3.200, "under": 1.400, "max": 100 }
},
"away": { /* same shape */ }
},
"meta": { "number": 0, "max_total": 500, "home_score": 0, "away_score": 1 }
},
"num_1": { /* first half */ }
},
"state": {
"match": { "state": 1, "minutes": 23 },
"home": { "score": 1, "redCards": 0 },
"away": { "score": 0, "redCards": 0 },
"home_stats": [{ "period": 0 }, { "period": 1 }],
"away_stats": [{ "period": 0 }, { "period": 1 }]
}
}
Special-market rows (returned only with include_specials=1, or when fetched by their own event_id) additionally carry special, special_category, special_units and special_markets, with an empty periods object — see Player props & specials for the shape. BTTS / Draw No Bet rows keep their historical shape (special + special_outcomes with prices in the money_line slots) and appear regardless of the flag.
Live state object
Live events include a state object with in-game state passed through from Pinnacle. Field names are sport-specific and are NOT renamed by us. state is null on prematch events.
Three top-level fields make up the object:
state.match— overall match-level state (clock / current period). Sport-dependent.state.home/state.away— current state for each participant.state.home_stats/state.away_stats— array indexed by period. Currently only theperiodindex is present; per-period stat values are not populated.
| Sport | Match-level fields | Per-participant fields |
|---|---|---|
| Soccer (1) | state (period state code, 1 = 1H, 2 = 2H, etc.), minutes (game clock minute) |
score, redCards |
| Basketball (3) | quarter (1-4 + OT), timeRemainingInQtr (string "MM:SS") |
score |
| Tennis (2) | set (current set number) |
setsWon, gamesBySet (array), points (string: "0"/"15"/"30"/"40"/"Adv"), serving (boolean) |
| Other sports | Varies. Common: period / state |
Always score. Sport-specific extras may appear. |
Note: Per-participant live state currently covers score and redCards (plus the match-level clock/period). Yellow cards and corners are not populated on the main event. Fields appear only when there is a non-default value to publish, so treat every field as optional and code defensively.
Where it lives: the state field is returned by GET /kit/v1/markets?event_type=live only. Prematch endpoints (/kit/v1/prematch/*) do not return state — none of these fields exist before kickoff. The /odds-drop SSE stream does NOT include state; use the REST endpoint when you need live game state.
Affiliate program
Every account gets a unique referral link. Anyone who signs up via your link and becomes a paying customer earns you 30% of every payment they make, for as long as their subscription stays active — first payment, renewals, upgrades, all of it.
| Topic | Detail |
|---|---|
| Your link | Shown in your dashboard. Format: https://pinnodds.com/?ref=<8-char-code>. Copy + share anywhere. |
| Commission | 30% of every USD payment your referrals make. Compounds across renewals and upgrades — not a one-time bounty. |
| Attribution | A signup is attributed if the visitor lands on pinnodds.com with your ?ref= parameter and then signs up + verifies email in the same browser session. We don't use cross-device cookies — straight session-based attribution. |
| Tracking | Your panel shows a live list of every signup attributed to you, whether they're on a paid plan, and how much you've earned (unpaid + paid out). |
| Payouts | Processed manually. Once your unpaid balance reaches $50+ we'll reach out to arrange transfer (crypto, bank transfer, or PayPal — whichever you prefer). Faster payouts are available on request — email [email protected]. |
| Self-referral | Blocked. You can't sign up via your own link. |
| Refunds | If a referred customer's payment is refunded by us (rare), the corresponding commission is voided. We don't claw back already-paid commissions for routine churn — only for actual refunds. |
Privacy: in your panel, referred users' emails are masked (e.g. j***@gmail.com) so you can recognize friends you invited without exposing full addresses if you screenshot the dashboard. Admins see unmasked emails in our internal tooling.