On the wire · 04:47:11 UTC Live 550 · Prematch 14,364 · Markets 57,024 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Answer · Postgres · Storage

Store Pinnacle odds in Postgres

Updated · PinnFeed · 2 min read

Answer: keep one append-only table of price ticks keyed by market id, outcome and timestamp, and write every market upsert from the PinnFeed WebSocket into it. Snapshots give you the starting state; delta frames give each change. Add /v1/closing rows later for CLV. Works with any Postgres (self-hosted, Supabase, Neon).

Schema#

create table if not exists odds_ticks (
  ts          timestamptz not null default now(),
  event_id    bigint      not null,
  market_id   text        not null,
  market      text        not null,   -- moneyline | spread | total | team_total
  period      int,
  line        numeric,
  outcome     text        not null,
  decimal     numeric     not null,
  fair        numeric,
  max_stake   numeric,
  primary key (market_id, outcome, ts)
);
create index if not exists odds_ticks_event on odds_ticks (event_id, ts);

Node worker (Builder)#

// npm i ws pg   ·   PINNFEED_KEY=ps_live_... DATABASE_URL=postgres://... node worker.js
import WebSocket from 'ws';
import pg from 'pg';

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const sql = `insert into odds_ticks (event_id, market_id, market, period, line, outcome, decimal, fair, max_stake)
             values ($1,$2,$3,$4,$5,$6,$7,$8,$9) on conflict do nothing`;

async function save(m) {
  for (const o of m.outcomes || []) {
    await db.query(sql, [m.eventId, m.id, m.type, m.period, m.line ?? null, o.name, o.decimal, o.fair ?? null, m.maxStake ?? null]);
  }
}

function connect() {
  const ws = new WebSocket('wss://pinnfeed.io/v1/stream', { headers: { 'x-api-key': process.env.PINNFEED_KEY } });
  ws.on('open', () => ws.send(JSON.stringify({ type: 'subscribe', sports: ['soccer'], markets: ['moneyline', 'total'], main: true })));
  ws.on('message', async (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === 'ping') return ws.send(JSON.stringify({ type: 'pong' }));
    if (msg.type === 'snapshot') for (const m of msg.markets || []) await save(m);
    if (msg.type === 'delta') for (const ch of msg.changes) if (ch.kind === 'market' && ch.op === 'upsert') await save(ch.market);
  });
  ws.on('close', (code) => { console.log('closed', code); setTimeout(connect, 2000); });
}
connect();

If inserts fall behind the stream, the server can close with 4008 slow_consumer; batch inserts (or queue them) and subscribe to fewer markets. Reconnect logic in full: Node reconnect.

Query examples#

-- last price per outcome
select distinct on (market_id, outcome) market_id, outcome, decimal, ts
from odds_ticks order by market_id, outcome, ts desc;

Use the table for backtesting against closing lines and line movement charts.

Plans#

Streaming needs Builder (€99/mo) or Pro; closing-line history is included on Builder. On Free or Hobby you can poll /v1/odds into the same table. Paid plans: 3-day trial; a card is required and it is charged after day 3 unless you cancel.

Educational content for adults (18+). Data for research, not betting advice.

Questions people ask#

Should I store the snapshot?#

Yes, as the first row per outcome. Otherwise the table only holds prices that changed after you connected.

How do I avoid duplicates after a reconnect?#

The snapshot after a reconnect repeats current prices; on conflict do nothing with a timestamp key keeps them as new ticks. If you want only changes, compare with the last stored price before inserting.

TimescaleDB?#

Works the same; make odds_ticks a hypertable on ts.

The WebSocket plan: Builder, €99/mo.

Real-time Pinnacle odds pushed over one WebSocket, all 13 sports, every market, no-vig fair odds and closing lines. 3 days free, cancel in one click.

Form K-1 · Free API key

Get your free API key.

One field. No password, no card: enter your email, click the link we send, and your key is on screen.

By continuing you agree to the Terms and Privacy Policy.

One free key per email. Link valid for 20 minutes.