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:
Which decimal quote is acceptable after or before the movement. This is the client-side band.
How large the price fall must be for PinnWire to record or push a drop. REST uses min_drop_pct; SSE uses min_drop.
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.
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.
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
| Need | PinnWire surface | Where the range runs |
|---|---|---|
| Inspect recent drops | GET /api/drops or /v1/drops | Server filters movement, sport, market, period and age; your client filters decimal price. |
| Receive qualifying drops | /odds-drop or /odds-drop-prematch SSE | Server 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 fixtures | Fetch the current decimal prices, then filter each normalized selection in your application. |
| Track every update | Optional raw WebSocket | Merge 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.
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.
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.
{
"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.
| Meaning | REST drops | SSE drop frames |
|---|---|---|
| Event identity | event_id | id |
| Market label | market | market_type or stream market field |
| Outcome | side | outcome |
| Pre-move decimal price | from | from_price |
| Post-move decimal price | to | to_price |
| Movement size | drop_pct | drop_pct |
| No-vig reference | nvp | nvp 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.
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:
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.
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.
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.
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
| Policy | Selected field | Meaning |
|---|---|---|
| Finish inside the band | REST to · SSE to_price | The current post-drop decimal quote falls between the bounds. |
| Start inside the band | REST from · SSE from_price | The previous quote was in the band before the detected fall. |
| Cross the band | Keep both fields | Alert 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... }
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_atand compare it with your maximum response age. - Check each drop's
age_sagain before sending a notification or feeding a model. - Use
/v1/healthandlast_odds_update_seconds_agoas a feed-health signal. - If a tool or intermediary returns an unexpectedly old response, re-fetch with a unique
freshquery 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.
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.
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.
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.
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/toand SSEfrom_price/to_pricebefore 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.