To get an Amazon Buy Box lost alert in Slack, poll /amazon-product-offers for each of your ASINs, compare buyBox.sellerId with your own seller ID, and post to a Slack incoming webhook only when the same reading shows up on two checks in a row. The script below sends one message per run, tells a lost Buy Box apart from a suppressed one, and costs one credit per ASIN per check.
Key Takeaways
- buyBox is the featured offer: its sellerId is the seller who holds the Buy Box, the literal "Amazon" when Amazon does.
- An empty buyBox means two different things: buyBoxSuppressed true (offers exist, Amazon features none) or no offers at all.
- Alert only when two checks in a row agree: an offer page can come back without parsable offers, and one reading should not page your team.
- Slack incoming webhooks answer 200 with the text ok and allow about one message per second, so the script sends one combined message per run.
- Each check costs 1 credit per ASIN; 502/503 retries are billed per attempt, a 500 is refunded, and a free 401/403 stops the run with one STOP message.
What does the offers endpoint tell you about who holds the Buy Box?
/amazon-product-offers reads one page of the live offer listing. buyBox is the featured offer with sellerId, sellerName and totalPrice; Amazon itself appears as the literal "Amazon" with its own ID in amazonSellerId. When buyBox is an empty object, buyBoxSuppressed says why: true means offers exist but Amazon shows no featured offer, false with an empty offers list means there is nothing to feature. The script maps every response to one of four readings; any holder other than you is the same lost reading, so a Buy Box that rotates between two other sellers still confirms a loss. Field details are in the Offer Listing API guide.
| Response | Reading | Slack message |
|---|---|---|
| buyBox.sellerId is your sellerId | mine | BACK, after a confirmed loss |
| buyBox.sellerId is any other seller or "Amazon" | lost | LOST, with holder, price and your offer |
| buyBox is {} and buyBoxSuppressed is true | suppressed | NONE |
| buyBox is {} and offers is [] | no-offers | EMPTY |
How do I build the Amazon Buy Box lost alert?
read_buy_box() turns one offers response into a reading, check() keeps a streak per ASIN in buybox_state.json and queues a message once a new reading has been seen CONFIRM times in a row, and post_to_slack() sends all queued messages as one webhook call. get_with_retry comes from retry.py and ApiError and new_session from sellermagnet.py, both from the Python quickstart. Malformed ASINs are skipped before any call, because the offers endpoint bills a typo like any other request. MY_SELLER_ID has no default on purpose: without it every ASIN would read as lost, so the script refuses to start. A LOST message names the holder, its fulfillment type and total price, and your own offer when it is on the first offer page, which is all the endpoint returns.
buybox_slack_alert.py - offers check, 2-check confirmation, one Slack message per run
import json
import os
import re
import requests
from retry import get_with_retry
from sellermagnet import ApiError, new_session
MARKETPLACE_ID = "ATVPDKIKX0DER" # amazon.com
MY_SELLER_ID = os.environ["MY_SELLER_ID"] # your sellerId as it appears in offers[]; no default on purpose
ASINS = ["B0CL61F39H", "B0EXAMPLE2", "B0EXAMPLE3"]
CONFIRM = 2 # same reading on 2 checks in a row before Slack hears about it
STATE_FILE = "buybox_state.json"
ASIN_RE = re.compile(r"^[A-Z0-9]{10}$") # offers calls are billed even for a typo, so filter first
def read_buy_box(session, asin: str) -> tuple[str, str]:
"""('mine' | 'lost' | 'suppressed' | 'no-offers', detail for the message)."""
data = get_with_retry(session, "amazon-product-offers", asin=asin, marketplaceId=MARKETPLACE_ID)
box = data.get("buyBox") or {}
if not box:
# {} has two causes: offers exist but Amazon features none, or no offer at all (or none parsable)
return ("suppressed", "") if data.get("buyBoxSuppressed") else ("no-offers", "")
holder = box.get("sellerId") or "unknown"
if holder == MY_SELLER_ID:
return "mine", ""
currency = (data.get("currency") or {}).get("code", "")
detail = f"{box.get('sellerName', '?')} ({holder}, {box.get('fulfillmentType', '?')})"
if isinstance(box.get("totalPrice"), (int, float)): # price + shipping as floats, not rounded by the API
detail += f" at {box['totalPrice']:.2f} {currency}"
mine = [o["totalPrice"] for o in data.get("offers") or []
if o.get("sellerId") == MY_SELLER_ID and isinstance(o.get("totalPrice"), (int, float))]
detail += f", your offer {min(mine):.2f}" if mine else ", your offer: not on the first offer page"
return "lost", detail # any other holder is the same reading: a rotation still confirms
def message(asin: str, key: str, detail: str) -> str:
if key == "mine":
return f"BACK {asin}: you hold the Buy Box again"
if key == "suppressed":
return f"NONE {asin}: offers exist but Amazon features none (Buy Box suppressed)"
if key == "no-offers":
return f"EMPTY {asin}: no offers on the listing"
return f"LOST {asin}: Buy Box held by {detail}"
def post_to_slack(webhook_url: str, lines: list[str]) -> bool:
"""One message per run. Slack answers 200 with the plain text 'ok' on success."""
if not lines:
return True
try:
resp = requests.post(webhook_url, json={"text": "\n".join(lines)}, timeout=10)
except requests.RequestException as err:
print(f"slack unreachable: {type(err).__name__}") # never print the URL: it is the secret
return False
if resp.status_code == 200 and resp.text == "ok":
return True
print(f"slack refused the message: {resp.status_code} {resp.text[:60]}")
return False
def load_state() -> dict:
try:
with open(STATE_FILE, encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
return {}
def save_state(state: dict) -> None:
tmp = STATE_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(state, fh, indent=1, sort_keys=True)
os.replace(tmp, STATE_FILE) # a crash mid-write keeps the last good state
def check(asins: list[str], webhook_url: str) -> None:
session, state = new_session(), load_state()
alerts, problems, checked, stopped = [], [], 0, ""
for asin in dict.fromkeys(a.strip().upper() for a in asins): # dedupe, keep order
if not ASIN_RE.match(asin):
problems.append(f"{asin}: not an ASIN, skipped")
continue
try:
key, detail = read_buy_box(session, asin)
except ApiError as err:
if err.status in (401, 403):
stopped = str(err) # bad key or no credits: every further call fails too
problems.append(f"stopped: {stopped}")
break
problems.append(f"{asin}: {err}") # 404 = listing gone (billed); state stays as it was
continue
except requests.RequestException as err:
problems.append(f"{asin}: {type(err).__name__} after retries, state kept")
continue
checked += 1
s = state.setdefault(asin, {"last": "mine", "streak": 0, "reported": "mine"})
s["streak"] = s["streak"] + 1 if key == s["last"] else 1
s["last"] = key
if s["streak"] >= CONFIRM and key != s["reported"]:
alerts.append((asin, key, message(asin, key, detail)))
lines = [text for _, _, text in alerts]
if stopped and state.get("_stopped") != stopped:
lines.append(f"STOP checks stopped: {stopped}") # said once, not on every run
sent = post_to_slack(webhook_url, lines)
if sent:
for asin, key, _ in alerts:
state[asin]["reported"] = key # only after Slack said ok, else it is sent again next run
if stopped:
state["_stopped"] = stopped
if not stopped:
state.pop("_stopped", None)
save_state(state)
for line in problems:
print(line)
print(f"{checked} checked, {len(alerts)} alert(s) {'sent' if sent else 'NOT sent, retried next run'}")
if __name__ == "__main__":
check(ASINS, os.environ["SLACK_WEBHOOK_URL"])
Four runs of the script (illustrative ASINs and sellers; [slack] = the message Slack received)
--- run 1 (illustrative ASINs and sellers)
3 checked, 0 alert(s) sent
--- run 2, one check later
[slack] LOST B0EXAMPLE2: Buy Box held by Example Outlet (A2OTHERSELLER0, FBA) at 18.49 USD, your offer 19.99
3 checked, 1 alert(s) sent
--- run 3
3 checked, 0 alert(s) sent
--- run 4
[slack] BACK B0EXAMPLE2: you hold the Buy Box again
3 checked, 1 alert(s) sent
Trimmed offers response for a suppressed Buy Box (example values)
{
"success": true,
"data": {
"asin": "B0EXAMPLE3",
"buyBox": {},
"buyBoxSuppressed": true,
"currency": {"code": "USD", "symbol": "$", "name": "US Dollar"},
"offers": [{"sellerId": "A2OTHERSELLER0", "condition": "New", "totalPrice": 21.99}]
}
}

Why wait for a second check before posting to Slack?
Because a single reading can be wrong in a way that looks real. When an offer page comes back with offer blocks that cannot be parsed, the response is a 200 with an empty offers list, the same shape as a listing with no offers. With CONFIRM = 2 the script alerts on the second check that sees the same reading, so a loss reaches Slack one interval later than the first sighting and a one-off reading never does. A failed call (a billed 404, a 502 or 503 after four attempts, a timeout) neither confirms nor resets the streak, and an alert that Slack did not accept is sent again on the next run while the reading still holds. A recovery needs two mine readings too, so a Buy Box that flips back for one check does not produce a BACK message.
How do I set up the Slack incoming webhook?
- Create a Slack appIn Slack's app management page, create an app for the workspace that should get the alerts.
- Activate incoming webhooksOpen Incoming Webhooks in the app settings and switch Activate Incoming Webhooks on.
- Add the webhookClick Add New Webhook to Workspace, pick the channel and authorize. Slack shows a URL that starts with https://hooks.slack.com/services/.
- Store the URL as a secretPut the URL into SLACK_WEBHOOK_URL in an environment file that never goes into version control.
Environment file and a cron entry for a 15-minute check (flock -n prevents overlapping runs)
# .env (keep it out of git: the webhook URL is a secret)
export SELLERMAGNET_API_KEY="your-key-here"
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
export MY_SELLER_ID="A1MYSELLER0001" # your sellerId as it appears in offers[]
# crontab -e: every 15 minutes, 3 ASINs = 288 credits a day; flock -n skips a run while the last one still runs
*/15 * * * * cd /opt/buybox && . ./.env && flock -n .lock .venv/bin/python buybox_slack_alert.py >> alert.log 2>&1
Slack's documentation (docs.slack.dev, checked 2026-10-23) describes the contract the script relies on: you POST a JSON body with a text field to the webhook URL, and a successful post answers HTTP 200 with the plain text ok. Failures return an error status with the error string in the body, for example invalid_payload (400), action_prohibited (403), channel_not_found (404) or channel_is_archived (410 Gone, per Slack's 2016 changelog on webhook errors), and no_service means the webhook was disabled or removed. The script treats every answer other than 200 ok as not delivered. Incoming webhooks allow one message per second with short bursts, and an exceeded limit returns 429 with a Retry-After header. The URL itself is the secret: Slack says it actively searches out and revokes leaked ones, so it belongs in an environment variable, and the script never prints it. Channel, name and icon come from the app configuration and cannot be overridden in the payload.
What should you check when a LOST alert fires?
The alert tells you who holds the Buy Box and at what total price, not why Amazon featured that offer. Amazon's US help page Becoming the Featured Offer (Seller Central, checked 2026-10-23) lists what it weighs: the total price including shipping, a fast and specific delivery promise, free shipping, free returns and seller performance, and an out-of-stock offer cannot be featured. Its Featured Offer guide on sell.amazon.com adds that fulfilling orders yourself can be just as effective as FBA. Start with the gap between the holder's price and your offer in the message, then your delivery date and stock. The Buy Box price gap guide shows why the lowest price alone does not decide it.
How many credits does a Buy Box alert cost?
One offers call per ASIN per check, so the cost is the number of ASINs times the number of checks. With a 2-check confirmation the worst-case delay before Slack hears about a loss is about two intervals. Errors add to the bill in different ways: a 404 is billed once and not retried, a 502 or 503 is billed on every attempt, a 500 is refunded, and the free gate errors (400 for a missing parameter, 401, 403, 429) cost nothing. The Buy Box monitoring interval guide compares intervals in more detail, and Pricing lists the plans.
| Check interval | Credits per day | Credits per 30 days | Worst-case alert delay |
|---|---|---|---|
| Every 15 minutes | 96 | 2,880 | about 30 minutes |
| Every hour | 24 | 720 | about 2 hours |
| Every 4 hours | 6 | 180 | about 8 hours |
| Once a day | 1 | 30 | about 2 days |

Frequently Asked Questions
How soon after a loss does the Slack alert arrive?
After the second check that sees it. The script polls the live offer listing on your own schedule, so with checks every 15 minutes a loss reaches Slack 15 to 30 minutes after it happens.
Why not alert on the first reading?
An offer page can come back without parsable offers, which looks like a stock-out. Waiting for the same reading on two checks in a row filters one-off readings at the cost of one interval.
What is the difference between NONE and LOST?
LOST means another seller, or Amazon, holds the featured offer. NONE means offers exist but Amazon features none of them (buyBoxSuppressed is true), so nobody holds the Buy Box.
What happens if Slack is down when a loss is confirmed?
The alert is not marked as sent, so the next run posts it again as long as the reading still holds. The state file records an alert only after Slack answered 200 with the text ok.
How do I find my own seller ID for MY_SELLER_ID?
Use the sellerId of your own offer in the offers[] list of one of your ASINs. That is the exact value buyBox.sellerId carries when you hold the Buy Box.
What happens when my credits run out?
The API answers 401 (or 403 during a burst), which is not billed. The script stops instead of calling the remaining ASINs, posts one STOP line to Slack and does not repeat it until a run gets through without a 401 or 403.
Bottom line: compare buyBox.sellerId with your own seller ID, keep a streak per ASIN, and post to Slack only when two checks agree, in one message per run. To see who held the Buy Box and for how long after the fact, use the Buy Box history API; a free account includes 150 credits to try the alert.