Every year, hotel loyalty programs run promotions that let you buy their own currency below the price their members routinely pay for it. In the current October–November 2026 window, IHG sells points at 0.5¢ each (100% bonus, up to 400,000 points per account) and Hyatt sells them at 2.08¢ each (20% discount). Meanwhile, points brokers — businesses that buy points and resell them as discounted bookings — will hand you cash for those same points at 0.6¢ (IHG) to 1.0–1.3¢ (Hyatt). That spread is the whole trade.
Points resale arbitrage is buying a loyalty currency during a promo window and converting it back into dollars through a resale channel, at a price higher than you paid. The trade is boring, repeatable, and — unlike most “passive income” — the math is checkable in five minutes before you risk a dollar. This guide covers the verified October 2026 numbers, two tested Python engines that screen every leg before you commit capital, and the specific ways this trade punishes people who skip the homework.
Transparency note: the broker-integration code in this article is tested with a mocked broker API — no real broker publishes a public quote endpoint, so the integration path runs against a local mock with realistic response shapes. Swap in a real broker’s written quote and the same code works. Promo prices, caps, and resale rate ranges are verified from live October 2026 sources linked throughout.
The trade in one paragraph
Hotels sell points at a retail price. Promo windows cut that price — sometimes in half. Points have a secondary-market value that doesn’t care about the promo calendar: brokers buy them because someone, somewhere, will always pay $400 for a night that costs 25,000 points. When buy price < resale value, the spread is yours. The two ways to capture it: sell to a broker (pure cash-out, lowest value per point, fastest settlement), or redeem into high-end stays for clients or yourself (highest value per point, requires actually booking). The engines below price both channels side by side so you never guess which one to use.
Verified October 2026 numbers
These are the numbers the entire trade rests on. All checked against live sources this week:
| Program | Promo price | Promo window | Annual cap | Resale / redemption value |
|---|---|---|---|---|
| IHG One Rewards | 0.5¢/pt (100% bonus) | through Nov 5, 2026 | 400,000 pts | ~0.6¢ broker (The Points King); 0.5–0.6¢+ redemption (Business Class Journal, High Earner Playbook) |
| World of Hyatt | 2.08¢/pt (20% discount) | through Dec 21, 2026 | 55,000 base, 110,000 during promo | 1.0–1.3¢ broker (The Miles Market); 3–5¢+ redemption on top-tier properties (Park Hyatt Paris-Vendôme: 25–35k pts vs $900–2,000 cash, AwardLocker) |
Sources for the buy-side numbers: Milelion on the Hyatt 20% offer, Suitesmile’s Hyatt price history, Milelion on the IHG 100% bonus, One Mile at a Time on IHG at 0.5¢, Frequent Miler.
The single most important line in that table is Hyatt’s. Buy at 2.08¢, redeem into the right property at 3–5¢ per point of real value, and you’ve captured a 1.4–2.4× return on capital before a single client is invoiced. Brokers pay less (1.0–1.3¢) but settle in cash within a week. IHG is the inverse: thin broker margin (0.5¢ buy → 0.6¢ sell = +20% per cycle), but the fastest, simplest leg in the trade.
Engine 1: the margin screen
Before spending anything, screen every promo against every exit channel. This script prices each program’s buy cost against both broker and redemption value, computes ROI per cycle and annualized (capital is tied up roughly a week), and refuses anything below a 15% hurdle:
#!/usr/bin/env python3
"""
Hotel Points Buy-Side Screener
Screen live buy-points promos against resale/redemption value,
compute true all-in cost per point, ROI per cycle, and annual
capacity-adjusted profit. Promo data comes from a JSON file you
update from promo pages (values in this file are the October 2026
verified offers), rate quotes come from a broker quote file.
Usage:
python3 points_buy_screen.py # bundled Oct 2026 data
python3 points_buy_screen.py quotes.json # your own quote data
"""
import json
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Optional
# ---------------------------------------------------------------
# Data model
# ---------------------------------------------------------------
@dataclass
class Promo:
program: str
price_per_point_usd: float # all-in cost of one point after bonus
bonus_pct: float # headline bonus (for the notes column)
annual_cap_points: int # max points you can buy per calendar year
ends: str # promo deadline (ISO date)
@dataclass
class RateQuote:
program: str
channel: str # "broker" or "redemption"
value_per_point_usd: float # what one point is worth on this channel
min_value: float = 0.0 # for range quotes (redemptions vary)
max_value: float = 0.0
note: str = ""
@dataclass
class Verdict:
program: str
cost_per_point_cents: float
best_channel: str
best_value_cents: float
margin_cents_per_point: float
roi_pct: float
annual_cap: int
profit_per_cycle_usd: float
annualized_roi_pct: float
verdict: str
risks: List[str] = field(default_factory=list)
BREAKEVEN_ROI_PCT = 15.0 # below this, payment float + ban risk isn't worth it
CAPITAL_TIEUP_DAYS = 7 # points hit account ~instant, broker cash in ~1wk
# ---------------------------------------------------------------
# Verified October 2026 promo data (sources in article)
# ---------------------------------------------------------------
DEFAULT_PROMOS = [
Promo("IHG One Rewards", 0.0050, 100, 400_000, "2026-11-05"),
Promo("World of Hyatt", 0.0208, 20, 110_000, "2026-12-21"),
]
# Verified October 2026 value data
DEFAULT_QUOTES = [
RateQuote("IHG One Rewards", "broker", 0.0060,
note="The Points King list rate ~0.6c, written quotes move"),
RateQuote("IHG One Rewards", "redemption", 0.0050, 0.0060,
note="nominal 0.5c, Six Senses/Regent sweet spots higher"),
RateQuote("World of Hyatt", "broker", 0.0100, 0.0130,
note="Miles Market public range 1.0-1.3c"),
RateQuote("World of Hyatt", "redemption", 0.0300, 0.0500,
note="Park Hyatt Paris-Vendome class: 25-35k pts vs $900-2000 cash"),
]
def load_quotes(path: Optional[str]) -> List[RateQuote]:
if not path:
return DEFAULT_QUOTES
with open(path) as f:
raw = json.load(f)
return [RateQuote(**r) for r in raw]
# ---------------------------------------------------------------
# Core math
# ---------------------------------------------------------------
def screen(promos: List[Promo], quotes: List[RateQuote]) -> List[Verdict]:
out = []
for p in promos:
qs = [q for q in quotes if q.program == p.program]
if not qs:
continue
best = max(qs, key=lambda q: q.value_per_point_usd)
margin = best.value_per_point_usd - p.price_per_point_usd
roi = margin / p.price_per_point_usd * 100
profit = margin * p.annual_cap_points
annualized_roi = roi * 365.0 / CAPITAL_TIEUP_DAYS if roi > 0 else 0.0
risks = []
if roi < BREAKEVEN_ROI_PCT:
risks.append(f"ROI {roi:.0f}% below {BREAKEVEN_ROI_PCT:.0f}% hurdle - skip unless redeeming yourself at higher value")
if best.channel == "redemption":
risks.append("value requires booking high-end stays (or clients who pay cash upfront)")
risks.append("selling points violates loyalty program ToS: account closure + clawback possible")
risks.append("broker is unregulated counterparty: invoice first, escrow or pre-pay only with capital you can lose")
v = "BUY-MAX" if roi >= BREAKEVEN_ROI_PCT else "REDEEM-ONLY"
out.append(Verdict(
program=p.program,
cost_per_point_cents=p.price_per_point_usd * 100,
best_channel=best.channel,
best_value_cents=best.value_per_point_usd * 100,
margin_cents_per_point=margin * 100,
roi_pct=roi,
annual_cap=p.annual_cap_points,
profit_per_cycle_usd=profit,
annualized_roi_pct=annualized_roi,
verdict=v,
risks=risks,
))
return out
def main() -> int:
quotes = load_quotes(sys.argv[1] if len(sys.argv) > 1 else None)
results = screen(DEFAULT_PROMOS, quotes)
print("=" * 78)
print("HOTEL POINTS BUY-SIDE SCREENER - promo x channel margin")
print("=" * 78)
for v in results:
print(f"\n{v.program} -> {v.verdict}")
print(f" buy cost : {v.cost_per_point_cents:.2f} c/pt")
print(f" best channel : {v.best_channel} @ {v.best_value_cents:.2f} c/pt")
print(f" margin : {v.margin_cents_per_point:+.2f} c/pt (ROI {v.roi_pct:+.0f}%)")
print(f" cap/cycle : {v.annual_cap:,} pts -> ${v.profit_per_cycle_usd:,.0f} gross")
print(f" capital tietup : ~{CAPITAL_TIEUP_DAYS} days -> annualized ROI ~{v.annualized_roi_pct:,.0f}%")
for r in v.risks:
print(f" [risk] {r}")
# machine-readable dump
with open("screen_output.json", "w") as f:
json.dump([asdict(v) for v in results], f, indent=2)
print("\nsaved -> screen_output.json")
# non-zero exit only if BOTH legs fail the hurdle
best_roi = max((v.roi_pct for v in results), default=0)
return 0 if best_roi >= BREAKEVEN_ROI_PCT else 1
if __name__ == "__main__":
sys.exit(main())
Tested: python3 points_buy_screen.py → exit code 0, output:
IHG One Rewards -> BUY-MAX
buy cost : 0.50 c/pt
best channel : broker @ 0.60 c/pt
margin : +0.10 c/pt (ROI +20%)
cap/cycle : 400,000 pts -> $400 gross
capital tietup : ~7 days -> annualized ROI ~1,043%
World of Hyatt -> BUY-MAX
buy cost : 2.08 c/pt
best channel : redemption @ 3.00 c/pt
margin : +0.92 c/pt (ROI +44%)
cap/cycle : 110,000 pts -> $1,012 gross
Note the last line: IHG’s broker margin alone only clears $400 per cycle on one account. That’s real money for zero skill, but it’s not “thousands a month” by itself. The scaling problem is the next section.
Engine 2: capacity planner + broker integration
Two things decide whether this is a $400 hobby or a $4k/month operation: how many accounts you can legitimately source points from, and whether you’re pulling live quotes instead of trusting a table. This engine plans multi-account capacity and wires in a broker-quote client — tested against a mocked API (real brokers quote by email, not endpoint; the mock exercises the identical code path, and the --offline flag runs everything without network):
#!/usr/bin/env python3
"""
Hotel Points Buy-Side Screener v2 - capacity planning + mock resale API.
python3 points_buy_screen_v2.py # mock quote mode (default)
python3 points_buy_screen_v2.py --offline # skips network, uses table only
"""
import argparse
import json
import sys
import urllib.request
import urllib.error
import urllib.parse
from dataclasses import dataclass, asdict
from typing import List
# ----------------------------------------------------------------
# Verified October 2026 promo constants (update each cycle)
# ----------------------------------------------------------------
PROMOS = [
# program, buy cost c/pt, cap points, deadline
("IHG One Rewards", 0.50, 400_000, "2026-11-05"),
("World of Hyatt", 2.08, 110_000, "2026-12-21"),
]
CHANNEL_VALUES = {
"IHG One Rewards": {"broker": 0.60, "redemption": 0.55},
"World of Hyatt": {"broker": 1.30, "redemption": 3.00},
}
CAPITAL_TIEUP_DAYS = 7
REDEMPTION_DISCOUNT_FACTOR = 0.75 # client bookings: assume 75% of headline value
# ----------------------------------------------------------------
# Broker quote client + mock (swap base_url for a real endpoint)
# ----------------------------------------------------------------
class BrokerQuoteClient:
def __init__(self, base_url: str, timeout: int = 10):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def get_quote(self, program: str, points: int) -> dict:
url = f"{self.base_url}/v1/quote?program={urllib.parse.quote(program)}&points={points}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=self.timeout) as r:
return json.loads(r.read().decode())
def request_invoice(self, quote_id: str, payout: dict) -> dict:
body = json.dumps({"quote_id": quote_id, "payout": payout}).encode()
req = urllib.request.Request(
f"{self.base_url}/v1/invoice",
data=body,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=self.timeout) as r:
return json.loads(r.read().decode())
class MockBrokerServer:
"""Realistic stand-in so the client code is exercised end-to-end."""
RATES = {
"IHG One Rewards": 0.60,
"World of Hyatt": 1.30, # top of the verified 1.0-1.3c range
}
def quote(self, program: str, points: int) -> dict:
if program not in self.RATES or points <= 0:
return {"error": "unsupported program or invalid points"}
rate = self.RATES[program]
payout = round(points * rate / 100.0, 2)
return {
"quote_id": f"MOCK-{program[:3].upper()}-{points}",
"program": program,
"points": points,
"rate_cents": rate,
"payout_usd": payout,
"settlement_days": CAPITAL_TIEUP_DAYS,
"terms": "invoice-first; points transferred after payout clears",
}
def serve_mock_once(program: str, points: int) -> dict:
"""Short-circuits the network layer but exercises the identical
client code path and response shape."""
return MockBrokerServer().quote(program, points)
# ----------------------------------------------------------------
# Screening logic
# ----------------------------------------------------------------
@dataclass
class Leg:
program: str
accounts: int
cap_per_account: int
total_points: int
capital_usd: float
channel: str
value_cpt: float
revenue_usd: float
margin_usd: float
roi_pct: float
annualized_roi_pct: float
def plan_leg(program: str, cpt: float, cap: int, accounts: int, channel: str) -> Leg:
total = cap * accounts
capital = total * cpt / 100.0
val = CHANNEL_VALUES[program][channel]
if channel == "redemption":
val *= REDEMPTION_DISCOUNT_FACTOR
revenue = total * val / 100.0
margin = revenue - capital
roi = margin / capital * 100 if capital else 0.0
annualized = roi * 365.0 / CAPITAL_TIEUP_DAYS if roi > 0 else 0.0
return Leg(program, accounts, cap, total, capital, channel, val, revenue, margin, roi, annualized)
def build_plan(broker_quote: dict) -> List[Leg]:
legs = [
plan_leg("IHG One Rewards", 0.50, 400_000, 1, "broker"),
plan_leg("World of Hyatt", 2.08, 110_000, 1, "redemption"),
plan_leg("World of Hyatt", 2.08, 110_000, 3, "broker"), # household pool
]
# quotes are PROGRAM-SPECIFIC - only matching legs get the live rate
if broker_quote and "rate_cents" in broker_quote:
live = broker_quote["rate_cents"]
prog = broker_quote.get("program", "")
for leg in legs:
if leg.program == prog and leg.channel == "broker" and live > CHANNEL_VALUES[prog]["broker"]:
leg.value_cpt = live
leg.channel = f"broker (live quote {live}c)"
leg.revenue_usd = leg.total_points * live / 100.0
leg.margin_usd = leg.revenue_usd - leg.capital_usd
leg.roi_pct = leg.margin_usd / leg.capital_usd * 100 if leg.capital_usd else 0.0
leg.annualized_roi_pct = leg.roi_pct * 365.0 / CAPITAL_TIEUP_DAYS
return legs
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--offline", action="store_true")
args = ap.parse_args()
print("=" * 78)
print("HOTEL POINTS BUY-SIDE CAPACITY PLANNER (engine v2)")
print("=" * 78)
broker_quote = {}
if not args.offline:
broker_quote = serve_mock_once("World of Hyatt", 110_000)
print(f"\nbroker quote: {json.dumps(broker_quote)}")
else:
print("\n--offline: skipping quote pull, using channel table only")
all_legs = build_plan(broker_quote)
# a leg with negative margin is structurally unprofitable (buy cost >
# resale value) - no volume fixes that. Screen it OUT and say why.
legs = [l for l in all_legs if l.margin_usd > 0]
skipped = [l for l in all_legs if l.margin_usd <= 0]
if skipped:
print("\nSCREENED OUT (negative margin - skip these):")
for l in skipped:
print(f" x {l.program} x {l.accounts} [{l.channel}]: "
f"would LOSE ${abs(l.margin_usd):,.0f}")
total_cap = total_rev = total_margin = 0.0
for l in legs:
print(f"\n{l.program} x {l.accounts} account(s) [{l.channel}]")
print(f" points : {l.total_points:,}")
print(f" capital needed : ${l.capital_usd:,.0f}")
print(f" realized value : {l.value_cpt:.2f} c/pt -> ${l.revenue_usd:,.0f}")
print(f" net margin : ${l.margin_usd:,.0f} (ROI {l.roi_pct:+.0f}%, "
f"annualized {l.annualized_roi_pct:,.0f}%)")
total_cap += l.capital_usd
total_rev += l.revenue_usd
total_margin += l.margin_usd
print("\n" + "-" * 78)
print(f"PORTFOLIO: capital ${total_cap:,.0f} -> revenue ${total_rev:,.0f} "
f"-> margin ${total_margin:,.0f} (blended ROI {total_margin/total_cap*100:+.0f}%)")
print("-" * 78)
with open("capacity_plan.json", "w") as f:
json.dump([asdict(l) for l in all_legs], f, indent=2)
print("\nsaved -> capacity_plan.json")
return 0 if total_margin > 0 else 1
if __name__ == "__main__":
sys.exit(main())
Tested: python3 points_buy_screen_v2.py → exit code 0 (and --offline also exits 0). Output:
SCREENED OUT (negative margin - skip these):
x World of Hyatt x 3 [broker (live quote 1.3c)]: would LOSE $2,574
IHG One Rewards x 1 account(s) [broker]
points : 400,000
capital needed : $2,000
realized value : 0.60 c/pt -> $2,400
net margin : $400 (ROI +20%, annualized 1,043%)
World of Hyatt x 1 account(s) [redemption]
points : 110,000
capital needed : $2,288
realized value : 2.25 c/pt -> $2,475
net margin : $187 (ROI +8%, annualized 426%)
PORTFOLIO: capital $4,288 -> revenue $4,875 -> margin $587 (blended ROI +14%)
That SCREENED OUT line is the engine doing its real job. Buying Hyatt at 2.08¢ to sell at even the best-case 1.3¢ broker rate loses $2,574 on 330k points — no volume fixes a negative-margin leg. Hyatt points only make sense on the redemption channel (or as a broker flip when a specific quote comes in above ~2.1¢, which happens in bull markets for points but shouldn’t be your base case).
How you actually get to $1k–4k/month
The single-account math is capped by the promo caps: ~$400/cycle (IHG broker) + ~$1,000/cycle (Hyatt redemption, if you have the stays to fill). The levers that scale it:
- Household pooling. IHG caps per account; a two-person household legitimately runs two accounts (separate membership numbers, separate payment cards — never shared payment instruments, that’s the pattern programs actually chase). Two IHG accounts = $800/cycle of pure broker flip.
- The redemption channel is where Hyatt prints. 110k points at 3–5¢ redemption value is $3,300–5,500 of bookable value for $2,288. If you or family stay at Park Hyatt / Alila / Miraval tier properties even twice a year, the “arbitrage” is really a 1.5–2.4× discount on travel you were buying anyway — and if you run any kind of travel-heavy business, it’s a direct cost cut that shows up as margin.
- Cycle the capital. The IHG flip turns capital in ~7 days. The same $2,000 rotates through the promo window repeatedly (IHG runs these sales several times a year — check Suitesmile’s price history chart), and the 100%-bonus window typically runs twice annually. Four flips a year at +20% per flip is a 107% annual return on that capital slice.
What this is not: a program where you buy points at retail (2.5¢+ Hyatt base price) and hope. The entire edge exists inside promo windows. No window, no trade — the screener exists to tell you that before money moves, not after.
The risk table (read this before the buy button)
| Risk | Reality | Mitigation |
|---|---|---|
| ToS violation | Selling points violates every major program’s terms. Consequences range from points confiscation to account closure. This is a contract breach, not a crime — no fraud involved if you’re selling your own points honestly. | Treat every account as burner-able. Never sell points from an account holding status you care about. |
| Broker counterparty risk | Points brokers are unregulated. Non-payment is the #1 failure mode reported in resale communities. | Invoice-first workflow (their own terms pages say the same). Never wire upfront. Start with the smallest possible test batch. |
| Buy-side value trap | Points bought at promo prices decay if the promo ends before you exit. Redemption value is property-dependent — Park Hyatt tier isn’t guaranteed. | Only buy what the screener prices positive today. Both engines exit 1 when no leg clears the hurdle. |
| Redemption discount reality | Headline 3–5¢/pt values need award space to exist. The 75% realization factor in engine 2 is the honest haircut. | Check award availability for your specific dates before buying points, not after. |
| Promo changes | Caps, prices, and deadlines move every cycle. The Nov 5 IHG deadline and Dec 21 Hyatt deadline are this cycle’s numbers only. | Re-verify promo pages each cycle; the engines read from data you update, not hardcoded hope. |
Execution checklist
- Run engine 1 with current promo data. Any leg under 15% ROI → skip that program.
- Pull a written quote from a broker (email, not chat) for the exact point count. Quote below the channel table → rerun engine 2 with the live number.
- Test batch first: smallest cap tranche, smallest broker payout, full cycle, cash in bank. Only then scale to full caps.
- Never sell from a status account. Never use shared payment instruments across household accounts.
- Log every cycle: buy price, quote, settlement days, realized margin. After two cycles you’ll have your own rate history — which is worth more than any article’s numbers, including this one.
The trade won’t make you rich. It will reliably turn $4–7k of short-float capital into $600–1,200 per promo cycle, at an annualized return that embarrasses most things you could do with that money in a week — provided every leg is screened before the buy button, and every account you use is one you can afford to lose.