Developer guide · Odds alert filters

Odds range filter API for Pinnacle alerts

Filter Pinnacle odds alerts by the exact decimal price you care about. PinnWire gives you the live and prematch data, drop percentage controls, SSE delivery, no-vig context, and freshness fields; your client applies the inclusive price band safely.

Short answer: filter on the post-move decimal price. The current public PinnWire API does not have a server-side min_odds/max_odds parameter. Query PinnWire's /api/drops or /v1/drops with its real server-side movement and recency filters, then keep rows whose post-drop price is inclusively between your bounds: REST to, or SSE to_price. Validate decimal values, preserve the pre-move price too, and check freshness before forwarding an alert.

What an odds range filter actually selects

An odds range is a price-band rule, not a movement-size rule. If the band is 1.20 through 3.50, the range filter selects decimal prices at both boundaries and every valid price between them. It does not mean “a 1.20% to 3.50% drop.” Keep these two ideas separate:

Price range

Which decimal quote is acceptable after or before the movement. This is the client-side band.

Drop threshold

How large the price fall must be for PinnWire to record or push a drop. REST uses min_drop_pct; SSE uses min_drop.

Freshness range

How old a detection may be before your worker rejects it. REST offers max_age_sec; still verify age_s locally.

Post-move is the useful default

Most alert systems want to know whether the price that remains after the drop is inside a usable model or execution band. For that policy, select to in REST or to_price in SSE. A move from 3.70 to 3.40 is accepted by a 1.00–3.50 post-move range because 3.40 is inside it.

minDecimal ≤ selectedPrice ≤ maxDecimal

Pre-move is a different question

Use from or from_price when your rule is “only alert if the quote started inside this band.” Keep both prices in the normalized record so you can report the movement clearly. Never silently switch from pre-move to post-move filtering; it changes which alerts qualify.

Boundary policy: this guide uses inclusive bounds. A price exactly equal to minDecimal or maxDecimal passes. Document that policy in your worker and test it with values at, just below, and just above both edges.

Choose the right PinnWire surface

NeedPinnWire surfaceWhere the range runs
Inspect recent dropsGET /api/drops or /v1/dropsServer filters movement, sport, market, period and age; your client filters decimal price.
Receive qualifying drops/odds-drop or /odds-drop-prematch SSEServer filters min_drop and prematch recheck; your client filters price, sport, market and any local policy.
Read current market state/kit/v1/markets or prematch fixturesFetch the current decimal prices, then filter each normalized selection in your application.
Track every updateOptional raw WebSocketMerge the market book, validate its decimal prices, and apply the same explicit range rule.

PinnWire is especially strong for this workflow because the alert data and current reference market share a clear decimal-first contract. Use REST to tune a range and recover after a disconnect, SSE to push detected drops, and the optional raw WebSocket when you need every reprice rather than only downward moves.

Server-side filter boundary: the current public REST drop query has no decimal price-range parameter. Parameters such as min_odds, max_odds, min_price, or max_price are not part of the contract. Do not assume an unknown parameter filtered the response; apply the range to returned rows and log how many survive.

Try a PinnWire odds range query

The public demo key can inspect REST data without signup. It is a shared, capped taster; use a free emailed trial key for development, scheduled jobs, or repeated tests.

curlBroad prematch sample for local filtering
curl "https://pinnwire.com/api/drops?mode=prematch&min_drop_pct=1&max_age_sec=900&markets=moneyline,spread,total&periods=0&limit=100&key=demo&fresh=range-guide-1"

This asks PinnWire for recent prematch drop rows and narrows by real server-side fields. The decimal band is intentionally absent from the URL: the returned from/to values are filtered in your process.

REST response shapeDecimal prices
{
  "total": 1,
  "generated_at": "2026-08-26T10:05:12.412Z",
  "drops": [{
    "event_id": 1629725918,
    "sport_name": "Soccer",
    "home": "Bentleigh Greens",
    "away": "St Albans Saints",
    "market": "spread",
    "period": 0,
    "side": "home",
    "points": -0.5,
    "from": 2.37,
    "to": 2.25,
    "drop_pct": 5.06,
    "nvp": 2.21,
    "starts": "2026-08-26T11:00:00Z",
    "is_live": false,
    "alerted": 1787738712,
    "age_s": 12
  }]
}

With a post-move range of 2.00–2.50, this row passes because to is 2.25. With a pre-move range of 2.40–3.00, it fails because from is 2.37. The drop percentage is a separate condition.

Normalize the two drop payloads correctly

PinnWire's REST and SSE contracts describe the same movement with transport-appropriate names. Normalize them once at your boundary, then let the rest of your application use from, to, nvp, and a common identity.

MeaningREST dropsSSE drop frames
Event identityevent_idid
Market labelmarketmarket_type or stream market field
Outcomesideoutcome
Pre-move decimal pricefromfrom_price
Post-move decimal pricetoto_price
Movement sizedrop_pctdrop_pct
No-vig referencenvpnvp when available

Do not read from_price from a REST row or to from an SSE row and hope the missing value becomes correct. A silent undefined can make a range filter appear to work while accepting nothing. Normalize by transport and reject missing prices.

Use decimal validation before comparison

Decimal odds must be finite and greater than 1. Reject empty strings, NaN, infinities, zero, one, and malformed feed values. Validate the configured range too: both bounds must be finite decimals, the lower bound must not exceed the upper bound, and the selected price must pass >= and <=.

function decimal(value) {
  const n = Number(value);
  return Number.isFinite(n) && n > 1 ? n : null;
}

function inInclusiveRange(value, min, max) {
  const price = decimal(value);
  const lo = decimal(min);
  const hi = decimal(max);
  if (price === null || lo === null || hi === null || lo > hi) return false;
  return price >= lo && price <= hi;
}

Runnable client-side odds range filter

This Node.js 18+ example uses PinnWire REST drops, enforces a fresh response, preserves both sides of the move, and defaults to the post-move to price. Set PRICE_POINT=from when your policy is about the pre-move quote. It has no dependencies.

filter-drops.mjsNode.js 18+ · zero dependencies
import { randomUUID } from "node:crypto";

const config = {
  mode: process.env.MODE ?? "prematch",             // live | prematch
  pricePoint: process.env.PRICE_POINT ?? "to",      // to | from
  minDecimal: Number(process.env.MIN_DECIMAL ?? "1.20"),
  maxDecimal: Number(process.env.MAX_DECIMAL ?? "3.50"),
  minDropPct: Number(process.env.MIN_DROP_PCT ?? "3"),
  maxAgeSec: Number(process.env.MAX_AGE_SEC ?? "900"),
  market: process.env.MARKET ?? "moneyline,spread,total"
};

const key = process.env.PINNWIRE_KEY ?? "demo";

function decimal(value) {
  const n = Number(value);
  return Number.isFinite(n) && n > 1 ? n : null;
}

function validConfig() {
  const lo = decimal(config.minDecimal);
  const hi = decimal(config.maxDecimal);
  if (lo === null || hi === null || lo > hi) {
    throw new Error("MIN_DECIMAL and MAX_DECIMAL must be finite decimals > 1, with min ≤ max");
  }
  if (![config.minDropPct, config.maxAgeSec].every(Number.isFinite)) {
    throw new Error("MIN_DROP_PCT and MAX_AGE_SEC must be finite numbers");
  }
  if (!new Set(["from", "to"]).has(config.pricePoint)) {
    throw new Error("PRICE_POINT must be from or to");
  }
  return { lo, hi };
}

function responseAgeSeconds(iso) {
  const ms = Date.parse(iso);
  if (!Number.isFinite(ms)) throw new Error("PinnWire response has no valid generated_at");
  return (Date.now() - ms) / 1000;
}

function normalizeRestDrop(row) {
  const from = decimal(row.from);
  const to = decimal(row.to);
  const nvp = decimal(row.nvp);
  return { ...row, from, to, nvp };
}

const { lo, hi } = validConfig();
const query = new URLSearchParams({
  mode: config.mode,
  min_drop_pct: String(Math.max(1, config.minDropPct)),
  max_age_sec: String(Math.max(1, Math.floor(config.maxAgeSec))),
  markets: config.market,
  limit: "500",
  key,
  fresh: randomUUID()
});

const response = await fetch(`https://pinnwire.com/api/drops?${query}`, {
  headers: { accept: "application/json" },
  cache: "no-store"
});
if (!response.ok) {
  throw new Error(`PinnWire HTTP ${response.status}: ${await response.text()}`);
}

const payload = await response.json();
const age = responseAgeSeconds(payload.generated_at);
if (age < -5 || age > config.maxAgeSec) {
  throw new Error(`Rejecting stale or future response: ${age.toFixed(1)} seconds old`);
}

const matches = (payload.drops ?? [])
  .map(normalizeRestDrop)
  .filter(row => {
    const selected = row[config.pricePoint];
    const rowAge = Number(row.age_s);
    return selected !== null &&
      rowAge >= 0 && rowAge <= config.maxAgeSec &&
      selected >= lo && selected <= hi;
  });

console.log(JSON.stringify({
  source: "PinnWire",
  mode: config.mode,
  price_point: config.pricePoint,
  range: { min_decimal: lo, max_decimal: hi, inclusive: true },
  response_age_seconds: Number(age.toFixed(2)),
  total_matches: matches.length,
  drops: matches.map(row => ({
    event_id: row.event_id,
    market: row.market,
    period: row.period,
    side: row.side,
    points: row.points,
    from: row.from,
    to: row.to,
    drop_pct: row.drop_pct,
    nvp: row.nvp,
    fair_probability: row.nvp ? 1 / row.nvp : null,
    age_s: row.age_s,
    is_live: row.is_live
  }))
}, null, 2));

Run it with node filter-drops.mjs. For a pre-move policy, run PRICE_POINT=from MIN_DECIMAL=2 MAX_DECIMAL=4 node filter-drops.mjs; for the usual post-move policy, leave PRICE_POINT=to. The script deliberately treats an invalid price as a rejected row, not as a match.

Normalize SSE before applying the same rule

SSE is the best PinnWire surface when your service should receive drops as they happen. The first frame is a connection object; subsequent drop frames are JSON arrays. Normalize each array item using the stream names, then apply the identical inclusive comparison:

SSE client helperUse inside your stream reader
function normalizeSseDrop(raw) {
  const from = decimal(raw.from_price);
  const to = decimal(raw.to_price);
  const nvp = decimal(raw.nvp);
  return {
    ...raw,
    event_id: raw.id,
    market: raw.market_type,
    side: raw.outcome,
    from,
    to,
    nvp
  };
}

function acceptsSsePrice(raw, minDecimal, maxDecimal, pricePoint = "to") {
  const row = normalizeSseDrop(raw);
  const price = row[pricePoint];
  return price !== null &&
    price >= minDecimal &&
    price <= maxDecimal;
}

// Example policy: accept only post-move 1.20–3.50 prices.
if (acceptsSsePrice(drop, 1.20, 3.50, "to")) {
  // Store row.event_id, row.market, row.period, row.points and row.side.
  // Then apply your own cooldown before notifying downstream consumers.
}

Connect with curl -N "https://pinnwire.com/odds-drop?key=YOUR_KEY&min_drop=3" or use a streaming client. SSE has server-side min_drop; price range, sport, market, period, and local age rules remain your consumer's responsibility.

Three decimal odds range profiles

There is no universal “best” range. Use these as transparent starting profiles, measure volume and downstream usefulness, and change one condition at a time. They describe alert workload and price coverage, not a promise of profit or a betting instruction.

LOWER NOISE

Post-move core band

PRICE_POINT=to MIN_DECIMAL=1.20 MAX_DECIMAL=2.50 MIN_DROP_PCT=5 MAX_AGE_SEC=300

A tighter band and stronger drop threshold for a smaller review queue.

BALANCED

General alert monitor

PRICE_POINT=to MIN_DECIMAL=1.20 MAX_DECIMAL=3.50 MIN_DROP_PCT=3 MAX_AGE_SEC=900

A broad default for measuring how price and movement interact across common markets.

DISCOVERY

Wide model intake

PRICE_POINT=to MIN_DECIMAL=1.01 MAX_DECIMAL=8 MIN_DROP_PCT=1 MAX_AGE_SEC=1800

Capture more long-price candidates, then let your model and exact-market checks narrow them.

A narrow range reduces rows by price, not necessarily by risk or model error. A wide range increases coverage and can increase review work. Keep the profile in configuration, record it with every alert, and evaluate it on your own data.

Post-move and pre-move profiles are not interchangeable

PolicySelected fieldMeaning
Finish inside the bandREST to · SSE to_priceThe current post-drop decimal quote falls between the bounds.
Start inside the bandREST from · SSE from_priceThe previous quote was in the band before the detected fall.
Cross the bandKeep both fieldsAlert only when from and to land on opposite sides of a boundary; useful for explicit transition rules.

Keep nvp and freshness beside the range

A price band says whether a quote is in scope. It does not say whether the quote is fair, current, or matched to the exact contract you intended. PinnWire gives you the context to make those checks explicit.

nvp is a fair-price reference, not the range field

When a drop has at least two priced outcomes, the REST row can carry nvp, PinnWire's no-vig decimal reference for the moved outcome. Its reciprocal, 1 / nvp, is the corresponding fair implied probability estimate. Use the actual post-move to price for the range; use nvp to add fair-value context.

function fairContext(row) {
  const nvp = decimal(row.nvp);
  const to = decimal(row.to);
  if (nvp === null || to === null) {
    return { fair_probability: null, estimated_edge: null };
  }
  return {
    fair_probability: 1 / nvp,
    estimated_edge: to / nvp - 1
  };
}

console.log(fairContext({ to: 2.25, nvp: 2.21 }));
// { fair_probability: 0.4524..., estimated_edge: 0.0180... }
Keep the terms precise: nvp is a reference derived from the PinnWire market at detection time. It is not a guarantee, a model probability, or a replacement for matching the event, period, market, points, side, and current price.

Use machine-readable freshness

  • Check REST generated_at and compare it with your maximum response age.
  • Check each drop's age_s again before sending a notification or feeding a model.
  • Use /v1/health and last_odds_update_seconds_ago as a feed-health signal.
  • If a tool or intermediary returns an unexpectedly old response, re-fetch with a unique fresh query value.
  • Fetch the matching current market before a consequential downstream action; the recent drops buffer is not a permanent history.

Match the market before trusting a price band

An odds range can be mathematically correct and still be applied to the wrong selection. Build a stable identity from event_id, market, period, points, and side. Include participant_name for special markets when it is present. Team names are display text, not a safe primary key.

1

Confirm the event

Use event_id from REST or normalize the SSE id. Parent and child matchup records can exist, so use PinnWire details when your workflow needs one canonical fixture.

2

Keep period and line

A full-game spread at -0.5 is not a first-half spread or a full-game spread at -1.0. Preserve period and points alongside the decimal price.

3

Apply the selected field

Use to/to_price for post-move classification or from/from_price for pre-move classification. Store both in the alert record.

4

Verify current state

Use REST snapshots for a current check and retain your receipt time. PinnWire's drop buffer is recent by design, not a long-term archive.

Ship a predictable filter

  • Define whether the band is pre-move or post-move; default to post-move only when that is the intended policy.
  • Use inclusive comparisons and test exact lower and upper boundaries.
  • Validate configured bounds and every incoming decimal price as finite and greater than 1.
  • Normalize REST from/to and SSE from_price/to_price before shared logic.
  • Keep drop percentage, price range, recency, market identity, and nvp as separate fields.
  • Use REST's real server filters before local filtering; do not claim an unknown odds parameter narrowed the response.
  • Log profile name, bounds, selected price point, response age, and rejected-row counts.
  • Respect rate limits, use SSE for push delivery when eligible, and use REST for recovery after a disconnect.

Odds range filter API questions

Does PinnWire have a server-side odds range filter?

The current public PinnWire API does not expose min_odds or max_odds query parameters. Use server-side drop, sport, market, period, live, and age filters to reduce the response, then apply an inclusive decimal price filter in your client. REST drops use from and to; SSE uses from_price and to_price.

How do I filter Pinnacle odds alerts by price?

Choose the state first. For post-move filtering, read REST to or SSE to_price and keep rows where the decimal value is greater than or equal to the lower bound and less than or equal to the upper bound. Use from or from_price when the pre-move quote is the state you want.

What is the correct decimal odds range filter logic?

Validate both bounds and the selected price as finite decimal numbers greater than 1, make sure the lower bound is not above the upper bound, and use price >= minDecimal && price <= maxDecimal. Reject missing and malformed values rather than coercing them into a quote.

Should an odds range use the price before or after the drop?

Use the post-move price by default when you want alerts that finish inside a target band: REST to and SSE to_price. Use from or from_price when you want to classify the starting quote. Keep both prices so the choice is auditable.

Can I use nvp with a PinnWire odds range filter?

Yes. PinnWire REST drop rows can include nvp, the no-vig decimal reference for the moved outcome when enough outcomes are priced. Treat 1 / nvp as a fair implied probability estimate and keep it as context; the selected range still runs on from or to.

How do I make sure a filtered PinnWire alert is fresh?

Check generated_at on the REST response, age_s on the drop row, and last_odds_update_seconds_ago from /v1/health. Enforce your own maximum age, add a unique fresh query value if a cache is suspected, and fetch the current matching market before a consequential action.

Why use PinnWire for a Pinnacle odds range alert API?

PinnWire is purpose-built for real-time Pinnacle odds workflows. It gives developers decimal live and prematch snapshots, a recent detected-drops REST buffer, eligible-plan SSE alert streams, optional raw WebSocket updates, nvp context, machine-readable freshness, a public demo key, and a free trial path.

Build your Pinnacle price-band filter with PinnWire

Start with the public demo, tune a transparent inclusive decimal range against real drop rows, then use a free trial key for your application. PinnWire keeps the source prices, movement fields, no-vig context, and freshness visible so your filter stays auditable.