Smart filtering & export
Filter on score, brand, eligibility, BSR, price. Export winning rows to SellerAmp or CSV in one click.
ADAW pulls live data from Amazon's SP-API and Keepa, computes margin per ASIN, and ranks brands by how many of their listings you can profitably sell. Sign up, run a scan, pick a product.
No Keepa subscription, no SP-API setup, no per-seat token fees. Pick a plan, hit Scan, review winners.
Stripe checkout, takes 30 seconds. Cancel from your Account page anytime.
One button kicks off the Top Sellers scan across 29 root categories. The scanner pulls Amazon's bestseller feed via Keepa and dedupes against the shared pool.
Discovery → Enrichment → Scoring run in parallel. SP-API pulls eligibility + fees, Keepa fills 90-day history, the scorer ranks each ASIN 0–10.
Highest-scored products bubble to the top. Drill in for Keepa charts and the full fee breakdown, then SAS-export or send the ASIN to your sourcing pipeline.
Filter on score, brand, eligibility, BSR, price. Export winning rows to SellerAmp or CSV in one click.
Auto-detect every brand in your scan. Track listings, sort by margin, export for wholesale outreach.
The scanner DMs your Discord webhook the moment it scores a new Green-light opportunity. No polling.
Most sellers stitch together two or three tools. ADAW replaces the discovery layer with one autonomous scan — and plays well with what you already use.
| Capability | Recommended for Amazon arbitrage ADAW Scanner | SellerAmp | Tactical Arbitrage | Manual research |
|---|---|---|---|---|
| Discovery | ||||
| Autonomous Top Sellers discovery | Yes | — | Retailer scrape only | — |
| Retailer-scraping breadth | — | — | 1,400+ stores | Manual |
| Shared catalog cache (instant first scan) | Yes | — | — | — |
| Validation & scoring | ||||
| Composite 0–10 Green/Yellow/Red score | Yes | Pass / Fail | ROI rank | — |
| FBA eligibility & gating check | Yes | Yes | Limited | — |
| 90-day Keepa history per ASIN | Included | Add-on / paid Keepa sub | Add-on / paid Keepa sub | Paid Keepa sub |
| Workflow & alerts | ||||
| Real-time Discord alerts on new Greens | Yes | — | — | — |
| FBA arbitrage sweet-spot filter | Yes | Per-lookup only | ROI threshold | — |
| One-click SellerAmp / SAS export | Yes | Native | — | — |
| Browser extension on the Amazon listing page | — | Yes | — | — |
| Pricing & onboarding | ||||
| Monthly cost | $49 – $179 | $35 – $50 | $59 – $129 | Hours per ASIN |
Comparisons reflect each tool's primary positioning based on public documentation as of May 2026. Not affiliated with or endorsed by SellerAmp, Tactical Arbitrage, or any other tool listed.
Sign up, point it at a category, and let it work. First results land in minutes — the shared catalog already has 90 days of Keepa history on most ASINs.
For new sellers running their first sourcing batches.
For serious FBA sellers scaling their sourcing pipeline.
For teams and power sellers who need every signal.
It takes one signup and one click. You'll have ranked products in your dashboard before you finish your coffee.
Point ADAW at Amazon's top sellers and it'll pull live SP-API + Keepa data, score every ASIN's profit potential, and rank them for you.
Products you've marked as purchased. Use this to validate scoring over time — did the Greens you bought actually return ROI?
No purchases recorded yet. Click Purchased on an opportunity card to start tracking outcomes.
Pick the recency window. We'll pull top sellers fresh from the catalog and score every product.
Discord is the fastest way to see green-light products in real time. Optional — you can add this later in Account.
Here's what we'll do when you click below.
| ASIN | Product | Price | Sellers | Buy Box | Category | Sales/Mo | Score | Max Buy 30% | Max Buy 50% |
|---|---|---|---|---|---|---|---|---|---|
| Category | Parent | Discovered | Checked | Eligible | Hit Rate | Pages | Scanned At | ||
|---|---|---|---|---|---|---|---|---|---|
|
No categories scanned yet
Run a Top Sellers scan to populate this list with the categories Amazon ranked.
| |||||||||
| ASIN | Product | Price | Score |
|---|
| ASIN | Product | Price | Score |
|---|
| ASIN | Product | Before | After | Change |
|---|
| ASIN | Product | Price | Sellers | Category | Sales/Mo | Score |
|---|
A guided 10-step walkthrough highlights every key feature — perfect for first-time users or anyone returning after a redesign.
ADAW Scanner is an Amazon FBA product intelligence tool. It pulls a shared pool of current Top-Seller ASINs, scores each one against your seller eligibility, and surfaces the profitable resale opportunities.
Live scan state: status, pool size (live, shared across all tenants), your checked count, and your eligible vs. not-eligible split. During an active scan, a dedicated progress panel surfaces throughput (ASINs/min), ETA, and the last 5 Greens discovered this session.
Tracks how well the shared pool is enriched with Keepa data — the percentage of the pool that has fresh (< 7 day) sales/BSR data, plus an ETA to full coverage at the current throughput.
Appears once you have eligible products. Shows aggregate analytics:
A sortable, searchable table of eligible products, plus the instant lookup bar at the top. Click any row for the full product modal.
Click any product row to open the detail modal. It shows:
Keepa provides 90-day historical pricing, sales rank trends, and competition data. This data helps you understand whether a product's current metrics are typical or unusual.
How Keepa Enrichment Works
A single background worker owns all Keepa calls. It continuously refreshes the shared pool at the 60 tokens/minute ceiling — one enrichment covers every tenant. You never need to trigger enrichment manually; the Data Quality card on the Overview shows the live coverage % and ETA to full coverage.
The Green Light Score uses a weighted 0–10 scale across five components:
ROI Bonus (up to +0.5): Products with high Max Buy Cost (30% ROI) receive a small bonus.
BSR Safety Penalty: Products with BSR > 500,000 receive a penalty (very slow-moving items).
Private Label Risk: Products with fewer than 2 historic sellers (90-day Keepa avg) receive a -0.5 penalty for possible IP/PL risk.
Amazon Buy Box Override: If Amazon holds the Buy Box, the product is automatically scored Red (0) regardless of other criteria.
Click the Export CSV button (top of the Eligible Products table) to download all eligible products as a spreadsheet. The export includes all columns visible in the table plus the Green Light score.
ADAW LLC is an e-commerce distribution and fulfillment company headquartered in the San Francisco Bay Area, California. We specialize in Amazon FBA wholesale distribution and online arbitrage, using proprietary technology and data-driven sourcing to identify profitable product opportunities across Amazon's marketplace.
To democratize Amazon product research through automation and data intelligence. Our proprietary platform, DistroTrack (the scanner you are using right now), transforms what was once hours of manual product research into an automated, comprehensive analysis pipeline that scans Amazon's entire catalog.
We source products from authorized distributors and brands at wholesale prices, selling through Amazon's FBA program for margin-positive returns.
Our DistroTrack platform scans Amazon's top-selling products to identify ungated, profitable, low-competition opportunities automatically.
We use Amazon's FBA infrastructure for storage, shipping, and customer service, enabling scalable operations without physical warehouse overhead.
Fully licensed for distribution across all 50 states with comprehensive insurance coverage and zero compliance violations on record.
| Company Name | ADAW LLC |
| Headquarters | San Francisco Bay Area, California |
| Industry | E-Commerce Distribution & Fulfillment |
| Specialization | Amazon FBA Wholesale & Online Arbitrage |
| Technology Platform | DistroTrack (Proprietary Product Intelligence) |
| Brand Partnerships | 32+ Authorized Brands |
| Licensing | All 50 U.S. States |
Direct sourcing from authorized distributors and brand partners. We maintain relationships with 32+ brands across multiple product categories, purchasing at wholesale pricing for resale on Amazon. Our distribution network covers health & household, beauty & personal care, grocery, home & kitchen, and more.
End-to-end fulfillment through Amazon's FBA program — from product listing and prep to shipping, customer service, and returns handling. We rely on Amazon's logistics network for 1-2 day delivery nationwide with Prime eligibility.
Our proprietary product-intelligence tool that scans Amazon's Top Sellers against a shared, continuously enriched Keepa catalog of 500K+ ASINs. Features include automated discovery, per-tenant eligibility checks, Green Light scoring (0–10 with confidence-tiered velocity), 90-day Keepa history, instant single-ASIN lookup, and CSV / SAS exports.
Full regulatory compliance across all 50 states. We maintain distribution licenses, tax registrations, W-9 documentation, and comprehensive business insurance. Our compliance-first approach ensures zero violations and sustainable operations.
Data-driven category analysis, pricing strategy, and competition monitoring. Integrated Keepa historical data provides 90-day price trends, sales-rank trajectories, and seller-count changes. The Insights tab surfaces category yield rates, price-band analysis, and brand-level performance across the shared catalog.
While Amazon FBA is our primary channel, our data infrastructure and product intelligence capabilities extend across e-commerce platforms. Category-level insights and brand relationships position ADAW for expansion into additional marketplaces.
ADAW LLC maintains authorized distribution agreements with 32+ brands across multiple product categories including health & household, beauty & personal care, grocery, and home & kitchen.
Direct wholesale agreements with brand owners or their authorized distributors, ensuring authentic products and competitive pricing at scale.
Our Amazon seller account is approved (ungated) to sell in restricted brand categories, giving us access to products most sellers cannot list.
Portfolio spans multiple Amazon categories — reducing risk through diversification and enabling us to capitalize on seasonal trends across niches.
Our business development team actively pursues new brand partnerships, with a focus on high-margin, low-competition product lines identified by DistroTrack.
When the scanner discovers eligible products, it automatically detects and tracks the brand associated with each listing. This data feeds into the Brands tab, which provides:
ADAW LLC maintains comprehensive compliance across all operational jurisdictions, ensuring legal and regulatory adherence for all distribution and sales activities.
| Credential | Status | Details |
|---|---|---|
| State Distribution Licenses | Active — All 50 States | Licensed for wholesale distribution in every U.S. state |
| Compliance Record | Zero Violations | Clean compliance record with no violations, citations, or penalties |
| W-9 Tax Documentation | On File | Current W-9 form on file with all distribution partners and platforms |
| Business Insurance | Active | Comprehensive general liability and product liability coverage |
| Amazon Seller Account | Professional — Good Standing | Active Professional seller account with full FBA enrollment |
| Business Entity | Active LLC | Registered Limited Liability Company in the state of California |
| Resale Certificates | Active | Valid resale certificates for tax-exempt wholesale purchasing |
We actively monitor regulatory changes across all 50 states to ensure continued compliance with distribution and sales requirements.
All licenses, permits, and compliance documents are tracked with renewal dates and maintained in a centralized system.
Full adherence to Amazon's seller policies, restricted product guidelines, and intellectual property requirements.
All products sold are sourced through authorized channels with proper documentation to prevent counterfeit or unauthorized listings.
ADAW Scanner is a fully automated Amazon FBA product-intelligence tool for wholesale and online-arbitrage sellers. A background worker continuously enriches a shared catalog of 500K+ Top Seller ASINs with Keepa data; when a tenant runs a scan, the system checks each ASIN for the seller's specific eligibility, pulls live SP-API pricing, fees, and offers, scores the result, and surfaces ranked Green-light winners.
Unlike manual product research (clicking through Amazon listings one by one), ADAW Scanner automates the entire pipeline. It has one scan mode — Top Sellers — backed by the shared catalog:
The end result is a ranked list of eligible products with margin, fees, competition, sales-velocity estimates, and investment recommendations — usually delivered overnight while you sleep.
Alerts & notifications: Discord webhooks fire for high-quality Green Light products (score 7.0+ with 100+ monthly sales). Browser notifications fire on scan completion and Green-light batch discoveries.
Top Sellers is the only scan mode (April 2026 pivot). Each scan runs against a shared, pre-enriched catalog of 500K+ ASINs that the enrichment worker maintains in PostgreSQL — the scanner itself never calls Keepa during a scan. The pipeline runs as four phases per tenant:
When a tenant clicks Scan, _populate_scan_from_shared_catalog() in finder.py copies a slice of the shared catalog into that tenant's scan_asins table. ASINs already checked recently are skipped. Zero Keepa tokens are spent here — everything the scanner needs (BSR, monthly_sold, drops, trends, prices) was already enriched in shared_catalog.
_scan_phase_eligibility() pulls 100 ASINs at a time from scan_asins and runs Amazon's getListingsRestrictions for each one with a 0.2 second gap (the SP-API rate limit). Roughly 92% of ASINs come back ineligible (gated, restricted brands, excluded categories) and skip the rest of the pipeline. The remaining ~8% continue.
For each eligible ASIN, two SP-API calls run in parallel via a 2-worker ThreadPoolExecutor:
Both endpoints are rate-limited at 0.5 req/sec, so wave 1 takes ~0.5 s per ASIN.
Wave 2 fires the two most expensive SP-API endpoints in parallel:
These are limited to 0.5 req/sec each, so wave 2 takes ~2 s per ASIN. After wave 2, the result is scored and written to checked_asins.
Wave 2 is skipped for ASINs that clearly won't profit, saving ~50% of the SP-API call budget on bad listings:
| Trigger | Threshold | What Happens |
|---|---|---|
| Low price | Sale price < $12 | Skip fee estimate (margin will be negative); offers still fetched for Amazon-on-listing detection |
| Oversaturated | Seller count > 15 | Skip fee estimate; offers still fetched for Buy Box detection |
| Ineligible | getListingsRestrictions rejected | Skip both waves entirely; record reason in eligibility_cache |
Even early-skipped products still get basic data stored: estimated_monthly_sales, amazon_on_listing, fba_seller_count, buy_box_price, buy_box_seller. Realistic throughput is 30–55 ASINs/minute depending on the eligibility rate.
Monthly-sales estimates come from the shared_catalog data the enrichment worker already populated. Each estimate carries a confidence tier that's used as a multiplier on the Sales Velocity score component:
| Tier | Source | Multiplier | When It Fires |
|---|---|---|---|
| HIGH | Keepa Amazon Badge (validated) | 1.0× | Badge value matches 30-day BSR drops within a 0.3–3.0 ratio — both signals agree. |
| MEDIUM | Keepa Amazon Badge (unvalidated) | 0.85× | Badge present but no recent drops to cross-check against, or drops not yet enriched. |
| LOW | 30-day BSR Drops | 0.35× | No badge, but Keepa observed BSR rank drops (each drop ≈ 1 sale). |
| NONE | BSR Formula (no Keepa) | 0.1× | Pure category-calibrated math while enrichment is still pending. Last resort. |
When neither badge nor drops are available, estimate_monthly_sales(bsr, category) uses a power-curve calibrated against SellerAmp SAS ground truth:
Category-specific (factor, exponent) pairs are defined for ~27 categories (e.g., Home & Kitchen: 114069 × BSR-0.77; Electronics: 237841 × BSR-0.75). The formula requires the root category BSR from Keepa — subcategory BSRs from SP-API produce wildly inaccurate numbers.
shared_catalog is pre-warmed.
Every eligible product is scored using the Green Light Score, a composite rating based on real-world arbitrage viability criteria derived from extensive SellerAmp SAS analysis.
This condition immediately disqualifies a product with a score of 0 (Red), regardless of all other criteria:
| Condition | Why |
|---|---|
| Amazon Holds Buy Box | FBA sellers cannot compete with Amazon on the Buy Box. Amazon will always win the sale. Products where Amazon is the Buy Box holder receive an automatic score of 0 and grade F (Avoid). |
The total theoretical maximum is ~11.0 points, clamped to 10.0. Products without Keepa enrichment are rescaled from 0–9.5 to 0–10.0 so they aren't unfairly capped below the Green threshold while their Keepa data is still being fetched.
| Component | Max Points | What It Measures |
|---|---|---|
| Profit Quality | 2.5 | Fee-margin curve gated by net-profit floor (uses PREP_COST = $0.20 constant). Margin tiers: 60%+ = 2.5, 50% = 2.0, 40% = 1.5, 30% = 1.0, 20% = 0.5, 15% = 0.3. Net profit below $1 zeroes the component; $1–$3 scales it down. |
| ROI Signal | 2.0 | Based on max_buy_cost_30_roi (maximum buy cost that still yields 30% ROI). >$20 = 2.0, >$15 = 1.5, >$10 = 1.0, >$7 = 0.7, >$5 = 0.4, >$3 = 0.2. Bonus +0.2 if 50% ROI is also achievable. |
| Sales Velocity | 2.5 | Continuous curve over estimated_monthly_sales: ≥5K = 2.5, ≥2K = 2.3, ≥1K = 2.2, ≥500 = 2.0, ≥300 = 1.7, ≥200 = 1.4, ≥100 = 1.0, ≥50 = 0.8, ≥30 = 0.5, ≥10 = 0.2. Multiplied by a confidence factor: high = 1.0×, medium = 0.85×, low = 0.35×, none = 0.1× (see Sales Estimation Tiers below). |
| Competition | 2.0 | Amazon absent on listing = +1.0. FBA seller count: 0 = 0, 1 = −0.2, 2–3 = +0.5, 4–7 = +1.0, 8–15 = +0.8, 16–25 = +0.5, >25 = +0.2. If FBA count is unavailable, falls back to total seller count: ≤1 = −0.5, ≤3 = +0.4, ≤10 = +0.7, ≤20 = +0.5, >20 = +0.2. |
| Keepa Trends | 1.5 | Price stability (coefficient of variation): <0.10 = +0.6, <0.15 = +0.45, <0.25 = +0.2. Sales rank trend: improving = +0.55, stable = +0.25. Competition trend: decreasing = +0.35, stable = +0.2. Only available once enrichment has run. |
| Price Point | 0.5 | Light bonus for higher-priced items that absorb Amazon fees better: ≥$50 = +0.5, ≥$30 = +0.35, ≥$20 = +0.2, ≥$12 = +0.1. |
The confidence factor on Sales Velocity comes from where the monthly-sales number was sourced:
| Tier | Source | Multiplier | When It's Used |
|---|---|---|---|
| HIGH | Keepa Amazon Badge (validated) | 1.0× | Badge value is consistent with 30-day BSR drops (ratio between 0.3 and 3.0). Highest trust. |
| MEDIUM | Keepa Amazon Badge (unvalidated) | 0.85× | Badge present but no recent drops to cross-check, or drops data not yet enriched. |
| LOW | 30-day BSR Drops | 0.35× | No badge, but Keepa observed BSR improvements (each drop ≈ 1 sale). |
| NONE | BSR Formula (no Keepa) | 0.1× | Pure category-calibrated math. Last-resort estimate while Keepa enrichment is still pending. |
| Modifier | Points | What It Measures |
|---|---|---|
| BSR Safety | −0.5 to −1.0 | BSR > 500,000 = −0.5; BSR > 1,000,000 = −1.0. Extremely slow-moving products get penalized regardless of other signals. |
| Private Label Risk | −0.5 | Triggered when Keepa's 90-day average offer count is < 2.0. Products with historically few sellers may be private-label — risky for resale due to potential IP complaints. |
| Demand Floor | cap at 6.9 | If estimated monthly sales < 100 OR confidence is "none", the score is capped at Yellow (6.9) regardless of other signals. Green requires meaningful demand evidence. |
Products must pass ALL of these filters to appear in Top Opportunities:
| Gate | Condition | Why |
|---|---|---|
| No Amazon on listing | amazon_on_listing = false | Amazon as seller kills buy-box share for 3P sellers |
| No Amazon Buy Box | buy_box_seller ≠ "Amazon" | Even if Amazon isn't "on listing," they can hold Buy Box |
| Healthy competition | seller_count ≥ 5 | 5+ sellers = IP-safe distributed listing, healthy 3P presence |
| FBA seller present | fba_seller_count ≥ 1 | At least 1 FBA seller confirms the product is suitable for FBA arbitrage |
| Not Red score | score_label ≠ "Red" | Only Yellow and Green products pass (some viable criteria met) |
| Minimum profit | profit_per_unit ≥ $3.00 | Below $3 profit, FBA prep costs and shipping eat into margins |
Products that pass all quality gates are ranked by a composite opportunity score:
If Keepa data is available, the base score is multiplied by trend factors:
| Factor | Condition | Multiplier |
|---|---|---|
| Sales rank trend | Improving (growing demand) | × 1.2 |
| Sales rank trend | Declining (shrinking demand) | × 0.8 |
| Price stability | CV < 0.15 (stable pricing) | × 1.1 |
| Price stability | CV > 0.25 (volatile pricing) | × 0.9 |
| Competition trend | Increasing (more sellers entering) | × 0.9 |
The top 10 products by opportunity score are displayed on the Overview tab. Scoring is continuous (0–10) plus the Green / Yellow / Red label — there is no letter-grade overlay; ranking is by raw opportunity score.
After running a Top Sellers scan, the Insights tab shows category-level analytics including hit rates, average margins, competition levels, and price sweet spots. Use this data to understand which product categories offer the best opportunities.
| Requirement | Version | Why |
|---|---|---|
| Python | 3.11+ | Runtime for the backend (Flask, scanner engine, Keepa client) |
| Git | Any | Clone the repository, push deploys |
| GitHub Account | — | Host the repository, trigger Railway auto-deploys |
| Amazon Professional Seller Account | — | Required for SP-API access (Individual plan won't work) |
| Amazon SP-API Developer Registration | — | API credentials for product data, eligibility, fees |
The scanner uses Amazon's Selling Partner API for all product data. You need 6 credentials:
sellercentral.amazon.com → Apps & Services → Develop Apps → Register as developer. Choose "Self-authorization" (you're accessing your own seller account).sts:AssumeRole permission. Save the Access Key ID and Secret Access Key.execute-api:Invoke on arn:aws:execute-api:*:*:*. Set the trust policy to allow your IAM user to assume the role. Copy the Role ARN.You now have these 6 values:
Also set your marketplace and seller:
Supabase provides user authentication (login/logout, JWT tokens, password reset). It's free for small projects.
supabase.com and create a free account.Project URL → this is your SUPABASE_URLanon / public key → this is your SUPABASE_ANON_KEYJWT Secret (under "JWT Settings") → this is your SUPABASE_JWT_SECRETSUPABASE_JWT_SECRET empty, all authentication is skipped. Useful for local testing without a Supabase account.Keepa provides 90-day historical data (price trends, sales rank, competition changes). Without it, the scanner still works but scores max at 8/10 instead of 10/10 (the Keepa Trends component adds up to 2.0 points).
keepa.com and create an account.Token budget: Each product query costs 1 token. At 20 tokens/min you can enrich ~1,200 products/hour.
The requirements.txt includes: flask, python-amazon-sp-api, gunicorn, PyJWT, keepa, numpy, python-dotenv, psutil.
Create a .env file in the project root with all your credentials:
Open http://localhost:5000 in your browser. If SUPABASE_JWT_SECRET is empty, you'll be logged in automatically.
The SQLite database (checked_asins.db) is auto-created on first run with all required tables and schema migrations.
railway.app and create a new project. Choose "Deploy from GitHub repo" and select your repository./data. Set DB_PATH=/data/scanner.db so the database survives redeploys.SUPABASE_JWT_SECRET).main. The Procfile tells Railway how to start:
| Check | How | Expected |
|---|---|---|
| Dashboard loads | Visit your URL | Login screen or dashboard appears |
| Login works | Enter Supabase user credentials | Dashboard loads with "Start Scan" button |
| Scan starts | Click "Start Scan" | Status shows "RUNNING", categories begin appearing |
| Products appear | Wait 5-10 min, check Products tab | Eligible products populate with prices, scores |
| Keepa enrichment | Click a product → "Refresh Live" | Keepa Insights section fills with trend data |
| Database persists | Redeploy (push a commit) | Products tab still shows previous scan data |
| Variable | Required | Description |
|---|---|---|
SP_API_REFRESH_TOKEN | Yes | OAuth refresh token from self-authorization in Seller Central |
SP_API_LWA_APP_ID | Yes | Login With Amazon app client ID |
SP_API_LWA_CLIENT_SECRET | Yes | Login With Amazon app client secret |
SP_API_AWS_ACCESS_KEY | Yes | IAM user access key with sts:AssumeRole |
SP_API_AWS_SECRET_KEY | Yes | IAM user secret key |
SP_API_ROLE_ARN | Yes | IAM role ARN with execute-api:Invoke permission |
SELLER_ID | Yes | Your Amazon Merchant Token / Seller ID |
MARKETPLACE_ID | No | Amazon marketplace (default: ATVPDKIKX0DER = US) |
SUPABASE_URL | For auth | Supabase project URL |
SUPABASE_ANON_KEY | For auth | Supabase public/anon key (safe to expose) |
SUPABASE_JWT_SECRET | For auth | JWT signing secret. Leave empty to disable auth (local dev) |
ALLOWED_EMAILS | No | Comma-separated email allowlist. Empty = all authenticated users allowed |
KEEPA_API_KEY | No | Keepa API key. Without it, historical data unavailable but scanner still works |
DB_PATH | No | SQLite database file path. Default: checked_asins.db in script directory |
DISCORD_WEBHOOK_URL | No | Discord webhook URL for Green Light product alerts (score 7.0+ with 100+ sales) |
ENRICHMENT_MODE | No | Set to 1 on the enrichment worker to route Procfile to python enrichment.py |
ENRICHMENT_INTERVAL_HOURS | No | Hours between enrichment cycles (default: 4). Controls how often the enrichment service refreshes stale catalog entries. |
ENRICHMENT_DAILY_BUDGET | No | Maximum Keepa tokens the enrichment service can spend per day |
Top Sellers is the only scan mode (April 2026 pivot). It runs against the global shared_catalog — a pre-enriched pool of 500K+ Amazon best-selling ASINs that the enrichment worker keeps fresh independently of any tenant's scan. When you click Scan, the system seeds a per-tenant queue from the shared pool and runs SP-API eligibility checks. The scanner itself spends zero Keepa tokens.
_populate_scan_from_shared_catalog() copies a slice of shared_catalog into your tenant's scan_asins queue. Already-checked ASINs are skipped. Zero Keepa cost.getListingsRestrictions runs serially per ASIN (0.2s gap, 100 ASINs per batch). Roughly 92% are filtered out as ineligible.getCompetitivePricing + getCatalogItem in parallel (2-worker thread pool).getMyFeesEstimate + listOffersByASIN in parallel. Skipped for sale price < $12 or seller count > 15.checked_asins, fire Discord webhook for new Greens.| Phase | Keepa tokens | SP-API calls | Notes |
|---|---|---|---|
| Seed from shared catalog | 0 | 0 | Pure DB read; data is pre-enriched |
| Eligibility (per ASIN) | 0 | 1 | getListingsRestrictions, 0.2s rate limit |
| Wave 1 (per eligible ASIN) | 0 | 2 | Pricing + catalog, parallel |
| Wave 2 (per qualifying ASIN) | 0 | 2 | Fees + offers, parallel; skipped for cheap or oversaturated listings |
| Total per scan | 0 | 1–5 per ASIN | Keepa cost is amortized into the global enrichment budget (1 token/ASIN, 1-hour cycle) |
tenant_id on every query.shared_catalog is empty (rare; happens on first SQLite/dev boot), the scanner falls back to the live-Keepa bestseller path. This is dev-only; production always has a populated catalog.ADAW Scanner is built with a modern, lightweight stack designed for reliability and speed:
| Layer | Technology | Purpose |
|---|---|---|
| Backend | Python 3.12 + Flask | Web server, API endpoints, scan orchestration |
| WSGI Server | Gunicorn (2 workers, 120s timeout) | Production HTTP server with max-requests recycling (50 + jitter 10) |
| Database | PostgreSQL (Railway) / SQLite (local dev) | Multi-tenant persistent storage. db.py auto-translates SQL between both dialects. |
| Amazon API | SP-API (python-amazon-sp-api) | Official Amazon Selling Partner API for eligibility, pricing, fees, offers, catalog |
| Historical Data | Keepa API | 90-day price/BSR trends, sales rank drops, monthly sold badge, competition tracking |
| Keepa Cache | keepa_cache table (global) | Persistent tenant-independent Keepa data cache. Enrichment worker processes stale entries via queue. |
| Authentication | Supabase Auth (JWT) | User login, token management, session security |
| Payments | Stripe | Scout ($49), Pro ($99), Enterprise ($179) subscriptions with webhooks |
| Frontend | Vanilla HTML/CSS/JS + Chart.js | Single-page dashboard with no build step. 8 tabs, product modal, real-time polling. |
| Hosting | Railway | Cloud deployment with auto-deploy on git push to main |
| Version Control | Git + GitHub | Source code management, CI/CD trigger for Railway auto-deploy |
dashboard.py) as inline HTML/CSS/JS. This means zero build tools, zero npm dependencies, and instant deploys. The dashboard is served as a single HTML page by Flask. This makes the system extremely portable and easy to maintain.
The system consists of several core files:
| File | Lines | Responsibility |
|---|---|---|
dashboard.py | ~13,400 | Flask web server: API endpoints, embedded HTML/CSS/JS frontend, Supabase auth, sales intelligence, scan control, scoring, all dashboard UI (8 tabs + help docs) |
finder.py | ~3,900 | Scanner engine: SP-API integration, browse tree discovery, ASIN scanning, eligibility checking, fee calculations, BSR-to-sales estimation, DB schema & migrations, ThreadPoolExecutor concurrency |
keepa_client.py | ~820 | Keepa API wrapper: historical pricing, sales rank trends, competition tracking, monthly sold extraction, token budget system, bug #221 monkey-patch |
db.py | ~700 | Database connection layer: pooled psycopg2 connections with stale-conn retry; SQL is written native PostgreSQL throughout. A thin SQLite shim (test-mode only) maps a small set of PG-isms (%s placeholders, ILIKE, ::int casts) to SQLite syntax. Multi-tenant support with tenant_id columns. |
stripe_config.py | ~160 | Stripe subscription plans (Scout/Pro/Enterprise), price IDs, plan limits, feature gates |
The web service spawns finder.py as a subprocess when a scan starts. The scanner reads pre-enriched data from shared_catalog, runs SP-API eligibility per ASIN, and writes results back to PostgreSQL. The dashboard polls the DB on an adaptive interval. The enrichment worker is a separate Railway service running the same image with ENRICHMENT_MODE=1; it owns every Keepa API call and writes to shared_catalog independently.
| Process | How It Runs | Purpose |
|---|---|---|
| Top Sellers scan | Subprocess: python finder.py bestsellers (alias: scan) | Per-tenant pipeline: seed from shared_catalog → SP-API eligibility → wave-1 (pricing + catalog parallel) → wave-2 (fees + offers parallel) → score |
| Enrichment worker | Separate Railway service: python enrichment.py (ENRICHMENT_MODE=1) | Sole Keepa API consumer. Refreshes shared_catalog on a 1-hour cycle (50 ASINs/batch, 1 token/ASIN). Triggers Discord webhook on Green discoveries. |
| Dashboard | Gunicorn: dashboard:app · 2 workers · 120s timeout | Flask web service: serves the SPA, ~66 API endpoints, manages scan lifecycle, applies security headers + gzip + immutable static cache |
A background enrichment service (enrichment.py) runs as a separate Railway worker, maintaining a global shared_catalog table with Keepa sales data for 500K+ ASINs. All tenants share this data — ADAW absorbs the Keepa API cost so customers don't need their own Keepa subscription. The service refreshes on a configurable interval (default 1 hour, ENRICHMENT_INTERVAL_HOURS) at 1 token/ASIN with a default 80,000-token daily budget.
dashboard.py via Gunicorn (serves UI + API + scanner subprocess)enrichment.py (background Keepa loop, sole token spender)Production serves two minified bundles built by node build.js: app.min.js (~200 KB raw, ~55 KB gzipped) and app.min.css (~234 KB raw, ~41 KB gzipped). Development serves the unminified modules separately so source-mapped debugging works. Both modes append ?v=<mtime> for cache-busting; production also serves them with Cache-Control: immutable for one-year browser caching.
Two Railway services share one PostgreSQL database. The web service runs the dashboard and scanner. The enrichment service is the sole Keepa consumer and refreshes the shared catalog independently.
dashboard.py Flask API + embedded SPAfinder.py scan engine (subprocess)shared_catalog Keepa enrichmentchecked_asins per-scan resultsscan_sessions + scan_asins queuetenant_id
ENRICHMENT_MODE=1enrichment.py background loopTop Sellers is now the only scan mode. When a tenant starts a scan, the system progresses through these phases:
scan_asins from shared_catalog (zero Keepa tokens)ListingsRestrictions per ASIN, batched 100, 0.2s gapGetCompetitivePricing + GetCatalogItemGetMyFeesEstimate + ListOffersByASINThreadPoolExecutor. Ineligible ASINs (~92% of the catalog) skip Wave 2 entirely. Enrichment data comes pre-warmed from shared_catalog — the scanner never calls Keepa.
The enrichment service runs independently on a 4-hour cycle, fetching fresh Keepa data for products in the shared catalog:
When displaying estimated monthly sales, the system uses verified Keepa data in priority order:
Amazon's SP-API enforces strict rate limits per endpoint. The scanner is designed to operate at maximum efficiency within these constraints:
| API Endpoint | Rate Limit | Used For |
|---|---|---|
| Catalog Search | 2 req/s | Discovering ASINs in categories (Phase 2) |
| Product Eligibility | 5 req/s | Checking if you can sell a product |
| Competitive Pricing | 2 req/s | Getting current listing price |
| Catalog Items | 2 req/s | Product details (name, BSR, brand) |
| Product Fees | 0.5 req/s | Amazon referral + FBA fees (slowest endpoint) |
| Item Offers | 0.5 req/s | Buy Box holder, offer count |
The Flask backend exposes 50+ authenticated API endpoints. All require a valid JWT token via the Authorization: Bearer header (except the public waitlist POST and /health endpoint).
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/stats | Aggregate scan statistics (products, categories, score breakdown) |
| GET | /api/stats/data-quality | Diagnostic: count products with missing fields |
| GET | /api/analysis | Product intelligence for dashboard charts and Top Opportunities |
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/products | Paginated list of eligible products (with search, filters, sorting) |
| GET | /api/product/<asin> | Single product detail for the product modal |
| POST | /api/product/<asin>/refresh-keepa | On-demand Keepa refresh for a single product |
| GET | /api/opportunities | Paginated top opportunities (sorted by composite score) |
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/insights/categories | Per-browse-tree category intelligence (hit rates, scores, averages) |
| GET | /api/insights/price-analysis | Price sweet spots — product count & avg margin per price band |
| GET | /api/insights/keepa-trends | Keepa trend analysis for enriched eligible products |
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/scan/start | Start Top Sellers scan (also aliased as /api/scan/start-bestsellers) |
| POST | /api/scan/stop | Stop the active scan |
| GET | /api/scan/log | Last 200 lines of scan.log |
| GET | /api/categories/list | Distinct category names from eligible products |
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/scan/sessions | All scan sessions, newest first |
| GET | /api/scan/sessions/<id>/products | Products from a specific scan session |
| POST | /api/scan/session/finalize | Finalize current scan session |
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/keepa/status | Keepa API token balance & enriched product count |
| GET | /api/keepa/estimate | Token cost estimates: unenriched/stale/fresh counts & estimated time |
| POST | /api/keepa/reenrich | Smart re-enrichment with modes: stale_only, unenriched, green_first |
| GET | /api/keepa/reenrich/progress | Re-enrichment progress polling (status, done/total, percent) |
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/export/sas | ASINs as newline-separated text (for SAS batch lookup) |
| GET | /api/export/sas-csv | Full CSV with scores, pricing, competition data |
| GET | /api/brands | Auto-detected brand data with stats from scanned products |
| GET | /api/export/brands-csv | Export all brand listings as CSV |
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/waitlist | Public: collect email for SaaS waitlist (no auth required) |
| GET | /health | Health check: returns {"status":"ok","db":"connected"} (no auth required) |
| GET | /api/diagnostic/shared-catalog | Shared catalog health: total ASINs, enrichment coverage %, freshness |
| GET | /api/watchlist | List user's watchlisted ASINs |
| POST | /api/watchlist/<asin> | Add ASIN to watchlist |
| DELETE | /api/watchlist/<asin> | Remove ASIN from watchlist |
| GET | /api/waitlist | Admin: view all waitlist signups |
Production runs PostgreSQL on Railway; the test suite uses SQLite (in-memory or on-disk file). All SQL is written in native PostgreSQL syntax (%s placeholders, ON CONFLICT, ILIKE, ::int casts). db.py contains a small SQLite shim that maps these PG-isms to SQLite equivalents in test mode only; production paths go straight to psycopg2 unchanged. All tables include a tenant_id column for multi-tenant isolation. The keepa_cache table is the exception — it is global (shared across all tenants) to avoid re-fetching the same ASIN data per user. Schema migrations run automatically on startup.
The main products table. Every ASIN that has been evaluated is stored here, regardless of eligibility.
| Column | Type | Description |
|---|---|---|
asin | TEXT PK | Amazon Standard Identification Number |
product_name | TEXT | Product title from catalog |
is_eligible | BOOLEAN | Whether seller can list this ASIN |
restriction_reason | TEXT | Why ineligible (NOT_ELIGIBLE, APPROVAL_REQUIRED, ASIN_NOT_FOUND) |
sale_price | REAL | Lowest New listing price from CompetitivePricing API |
total_amazon_fees | REAL | Referral + FBA fulfillment fee estimate at sale_price |
max_buy_cost_30_roi | REAL | Max buy cost for 30% ROI: (price - fees - $0.20) / 1.30 |
max_buy_cost_50_roi | REAL | Max buy cost for 50% ROI: (price - fees - $0.20) / 1.50 |
seller_count | INTEGER | Number of New condition offers on listing |
category | TEXT | Amazon browse-tree category display name |
bsr | INTEGER | Best Seller Rank (top-level category rank) |
estimated_monthly_sales | REAL | Monthly sales estimate (from 3-tier hierarchy) |
sales_data_source | TEXT | Which method estimated sales: "Amazon Badge", "BSR Drops", or "BSR Estimate" |
amazon_on_listing | BOOLEAN | Amazon is a seller on this listing (True/False/NULL) |
fba_seller_count | INTEGER | Count of FBA-fulfilled sellers specifically |
buy_box_price | REAL | Current Buy Box winner price |
buy_box_seller | TEXT | "Amazon" if Amazon holds Buy Box, else raw seller ID |
bsr_formula_version | INTEGER | BSR estimation model version (2 = current Books-calibrated) |
scan_session_id | INTEGER | FK to scan_sessions.session_id |
checked_at | TIMESTAMP | When this ASIN was last evaluated |
| Keepa Enrichment Columns (populated by Keepa API) | ||
keepa_avg_price_30 | REAL | 30-day average price (dollars) |
keepa_avg_price_90 | REAL | 90-day average price (dollars) |
keepa_price_stability | REAL | Price coefficient of variation (0-1, lower = more stable) |
keepa_sales_rank_avg_30 | REAL | 30-day average BSR |
keepa_sales_rank_avg_90 | REAL | 90-day average BSR |
keepa_sales_rank_trend | TEXT | "improving", "stable", or "declining" (recent vs historical BSR) |
keepa_offer_count_avg | REAL | 90-day average seller count |
keepa_competition_trend | TEXT | "increasing", "stable", or "decreasing" (seller count trend) |
keepa_buy_box_price | REAL | Latest Buy Box price from Keepa stats |
keepa_monthly_sold | INTEGER | Amazon "bought in past month" badge value |
keepa_sales_rank_drops_30 | INTEGER | BSR drops in last 30 days (~1 sale per drop) |
keepa_sales_rank_drops_90 | INTEGER | BSR drops in last 90 days |
keepa_sales_rank_drops_180 | INTEGER | BSR drops in last 180 days |
keepa_last_updated | TEXT | When Keepa data was last refreshed (ISO timestamp) |
| Column | Type | Description |
|---|---|---|
node_id | TEXT PK | Amazon browse node ID |
node_name | TEXT | Category display name |
parent_node_id | TEXT | Parent category node ID (for tree hierarchy) |
is_leaf | BOOLEAN | True if searchable leaf category (vs folder node) |
scan_status | TEXT | pending, in_progress, done, or error |
asins_found | INTEGER | Count of ASINs discovered in this category |
pages_scanned | INTEGER | Search result pages scanned so far |
last_page_token | TEXT | SP-API pagination token for resume |
error_message | TEXT | Error details if scan_status is "error" |
created_at | TIMESTAMP | When category was added to the tree |
updated_at | TIMESTAMP | Last status change time |
| Column | Type | Description |
|---|---|---|
asin | TEXT PK | Discovered ASIN |
source_node_id | TEXT | Which browse node discovered it |
discovered_at | TIMESTAMP | When discovered |
eligibility_checked | BOOLEAN | False until processed by eligibility checker |
| Column | Type | Description |
|---|---|---|
session_id | INTEGER PK | Auto-increment session ID |
started_at | TIMESTAMP | When scan started |
stopped_at | TIMESTAMP | When scan stopped (NULL if still running) |
status | TEXT | "running" or "stopped" |
scan_type | TEXT | "full", "targeted", or "smart" |
target_categories | TEXT | Comma-separated node IDs (for targeted scans) |
categories_scanned | INTEGER | Categories discovered this session |
asins_discovered | INTEGER | ASINs found this session |
asins_checked | INTEGER | ASINs evaluated for eligibility |
eligible_found | INTEGER | Products that passed eligibility |
not_eligible | INTEGER | Products that failed eligibility |
green_count | INTEGER | Score ≥ 7.0 |
yellow_count | INTEGER | Score 4.5–6.9 |
red_count | INTEGER | Score < 4.5 |
| Column | Type | Description |
|---|---|---|
key | TEXT PK | Metadata key (e.g., "scan_phase", "current_session_id", "daemon_status") |
value | TEXT | Metadata value |
| Column | Type | Description |
|---|---|---|
id | INTEGER PK | Auto-increment ID |
email | TEXT UNIQUE | Signup email address |
created_at | TIMESTAMP | Signup time (default: now) |
| Column | Type | Description |
|---|---|---|
asin | TEXT PK | Amazon product identifier |
monthly_sold | INTEGER | Amazon "bought in past month" badge value |
estimated_sales | INTEGER | Computed sales estimate after stale detection |
sales_source | TEXT | "Badge", "BSR Drops", or "BSR Formula" |
sales_confidence | TEXT | "high", "medium", "low" |
enriched_at | TIMESTAMP | When Keepa data was last fetched |
enrichment_count | INTEGER | Number of times enriched |
| Column | Type | Description |
|---|---|---|
tenant_id | UUID FK | References tenants(id) |
asin | TEXT | Watchlisted product ASIN |
added_at | TIMESTAMP | When the product was starred |
notes | TEXT | Optional user notes |
idx_session on checked_asins(scan_session_id) — fast session-based product filteringidx_scan_categories_status on scan_categories(is_leaf, scan_status) — fast pending-category lookupsidx_scan_asins_unchecked on scan_asins(eligibility_checked) — fast unchecked-ASIN batch fetchingKeepa provides 90-day historical data for every product, enriching the scanner's real-time data with trends and context:
Keepa data is maintained by the Shared Catalog enrichment service (enrichment.py), which runs as a background Railway worker refreshing sales and price history continuously. Each product lookup costs approximately 1 Keepa token. ADAW absorbs the Keepa API cost, so users don't need their own Keepa subscription. The dashboard reads from the shared_catalog table first, falling back to per-tenant Keepa columns if unavailable. The Data Enrichment card on the Overview tab shows live coverage and freshness.
The Keepa enrichment service is the sole owner of all Keepa API calls in production — no other code path calls Keepa. It runs as a separate Railway worker process (ENRICHMENT_MODE=1) and pulls from a per-tenant daily token budget that's synced with the tenant's plan.
| Parameter | Value |
|---|---|
| Tokens per product lookup | 1 token (no offers / buybox params; verified empirically in keepa_client.py) |
| Default daily budget | 80,000 tokens (DAILY_TOKEN_BUDGET in enrichment.py) |
| Per-plan daily budgets | Scout 100 / Pro 500 / Enterprise 1,500 / Admin 80,000 |
| Refill rate | ~60 tokens/minute (Keepa server-side) |
| Cycle interval | 1 hour (ENRICHMENT_INTERVAL_HOURS, configurable) |
| Batch size per cycle | 50 ASINs (BATCH_SIZE) |
| Token wait logic | If insufficient tokens, sleeps min(120, max(10, 60 - tokens*3)) seconds, retries up to 3 times |
The scanner extracts monthly sales data from Keepa using a 3-tier priority to get the freshest value:
| Priority | Source | Description |
|---|---|---|
| Tier 1 | monthlySoldHistory | Array of timestamped snapshots — takes the last value (newest). This is the freshest source. |
| Tier 2 | product.monthlySold | Static field on the product object. May lag behind history data. |
| Tier 3 | stats.monthlySold | Stats-level aggregate. Last resort if both above are missing. |
Each tier is validated: value must be non-null and > 0 before use. If all three are missing, the scanner falls back to BSR-based estimation.
update=1)When the product modal requests live Keepa data, the enrichment worker processes the request via the enrichment queue. The Keepa API default is update=1, which refreshes data if it's older than 1 hour — matching SellerAmp SAS freshness.
The official keepa Python library has a known bug (#221) where update_status() overwrites self.status from a proper Status dataclass to a raw Python dict, causing 'dict' object has no attribute 'refillRate' errors.
The scanner patches this in keepa_client.py with a replacement _safe_update_status() that detects dict corruption and reconstructs a proper Status dataclass with all attributes.
_safe_scalar())Keepa returns numpy types (arrays, scalars) that fail Python boolean comparisons (if val and val > 0 raises "ambiguous truth value" errors). The _safe_scalar() utility converts numpy types to plain Python scalars before comparison. It handles: numpy integers/floats (.item()), single-element arrays, multi-element arrays (returns None), and passes through regular Python values unchanged.
BUY_BOX_SHIPPING) in the current arrayPOST /api/keepa/backfill. Check enrichment status via GET /api/keepa/status. The scanner also auto-enriches products during scans.
The application is hosted on Railway, a modern cloud platform:
main branch on GitHub triggers an automatic deploymentweb-volume) that persists across deploysThe Procfile branches on ENRICHMENT_MODE so the same image powers two Railway services:
ENRICHMENT_MODE=1): runs python enrichment.py, the sole owner of Keepa API calls$PORT is set automatically by RailwayBoth services deploy from the same GitHub repo on every push to main; they're differentiated only by the ENRICHMENT_MODE env var on the worker service.
All sensitive configuration is stored as Railway environment variables (never in code). See the From-Scratch Setup page for the complete reference table. Key groups:
| Group | Variables | Purpose |
|---|---|---|
| SP-API | SP_API_REFRESH_TOKEN, SP_API_LWA_APP_ID, SP_API_LWA_CLIENT_SECRET, SP_API_ACCESS_KEY, SP_API_SECRET_KEY, SP_API_ROLE_ARN | Amazon Selling Partner API credentials |
| Supabase | SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET | Authentication (leave JWT secret empty for local dev) |
| Keepa | KEEPA_API_KEY | Historical data enrichment (optional) |
| App | MARKETPLACE_ID, DB_PATH, ALLOWED_EMAILS | App configuration |
When Railway redeploys, any running scan processes are killed. On startup, the application automatically calls _cleanup_orphaned_sessions() which:
status = 'running'status = 'stopped' with stopped_at = current timestampThis prevents stale "Running" indicators on the dashboard after a redeploy.
The SQLite database (checked_asins.db) is stored on a Railway persistent volume. The DB_PATH environment variable points to the volume mount location. If DB_PATH is not set, the database is created in the script's working directory. The database is auto-created with all tables and indexes on first run via init_db().
The enrichment service runs as a separate Railway worker using the same GitHub repo but with ENRICHMENT_MODE=1 environment variable. The Procfile conditional routes to python enrichment.py instead of Gunicorn.
Required variables: DATABASE_URL, KEEPA_API_KEY, ENRICHMENT_DAILY_BUDGET, ENRICHMENT_INTERVAL_HOURS, ENRICHMENT_MODE=1
Set DISCORD_WEBHOOK_URL on the web service to receive Discord notifications for high-quality Green Light products (score 7.0+ with 100+ monthly sales). The webhook is non-blocking and fires from the /api/analysis endpoint during dashboard polling.
The dashboard uses Supabase Authentication (an open-source Firebase alternative) for user management.
To set up authentication from scratch:
SUPABASE_URL — Project URL (e.g., https://xxxxx.supabase.co)SUPABASE_ANON_KEY — Public anon key (safe to embed in frontend)SUPABASE_JWT_SECRET — JWT signing secret (under Settings → API → JWT Secret)| Component | How It Works |
|---|---|
| Login | User submits email/password → Supabase validates → returns JWT + refresh token → stored in localStorage |
| API requests | All API calls go through authFetch() which attaches Authorization: Bearer <token> header |
| Server validation | @require_auth decorator decodes JWT using HS256 algorithm with SUPABASE_JWT_SECRET, audience "authenticated" |
| Token refresh | On 401 response, authFetch() calls _sb.auth.refreshSession(). If refresh succeeds, retries request with new token. If refresh fails, redirects to login screen. |
| Password reset | Email-based flow through Supabase's built-in password reset |
The ALLOWED_EMAILS environment variable restricts dashboard access to specific email addresses:
Comma-separated list. If empty or not set, all authenticated Supabase users can access the dashboard. If set, users whose email is not in the list receive a 403 "Access denied" response even with a valid JWT.
When SUPABASE_JWT_SECRET is empty or not set, the @require_auth decorator allows all requests through without authentication. This lets you develop and test locally without setting up Supabase:
Every API call from the frontend JavaScript uses authFetch() instead of plain fetch():
@require_auth decorator checks token validity, expiration, and email allowlist. Expired tokens return 401 (triggering auto-refresh), invalid tokens return 401, and disallowed emails return 403.
Your command center. Surfaces live scan state and aggregate metrics:
shared_catalog, your tenant's checked count, eligible vs. ineligible split.Sortable, searchable table of eligible products plus instant single-ASIN lookup:
/ focus search, j/k row nav, Enter open modal, s toggle star, ? shortcut help.Aggregated category-level breakdown of the products you've checked. Each row shows the number of products checked in that category, how many are eligible, the hit rate, average score, and average margin — useful for spotting which categories yield the best opportunities for your seller account.
Brand-level intelligence across all your scan data, using a two-pass detection algorithm:
Each brand card shows product count, ungated status, parent company, categories, and all listings with prices, margins, sales, and scores. CSV export available.
Strategic analytics across your scan results:
Complete record of every scan session run by your tenant:
The Overview tab features a Top 10 Opportunities section — the highest-ranked products that pass all quality gates: no Amazon on listing, no Amazon Buy Box, ≥5 sellers, ≥1 FBA seller, non-Red score, ≥$3 profit per unit. Each card shows the opportunity score and key metrics. Ranking is by raw opportunity score (sales velocity × margin ÷ competition factor) — there is no letter-grade overlay.
Help is the user-facing reference for filters, modal fields, scoring, and keyboard shortcuts. Account shows plan tier, usage, billing portal, Discord webhook setup, and tenant settings. Docs (admin only) is this comprehensive technical documentation hub.
pip install -r requirements.txt, set SP-API environment variables (Supabase optional for local dev), then run python dashboard.py. Dashboard loads at http://localhost:5000 with no login required when SUPABASE_JWT_SECRET is empty.ATVPDKIKX0DER (Amazon.com retail, also the US marketplace ID) and A2R2RITDJNW1Q6 (Amazon.com Services LLC). If either appears as the Buy Box holder or in the offer list, the product is flagged as having Amazon on the listing.shared_catalog) holding 500K+ ASINs with Keepa-enriched sales data, prices, BSR drops, and trend signals. The enrichment worker (a separate Railway service) refreshes it on a 1-hour cycle at 1 Keepa token per ASIN. All tenants score against the same pool — one Keepa refresh covers every user, so adding the 100th tenant costs zero additional Keepa tokens.Last updated: 2026-04-26
scanner.adawllc.com from a single-tenant internal FBA scanner into a multi-tenant SaaS product listed on the Amazon Selling Partner Appstore, with Stripe subscription billing.
Right now, the scanner uses our own Amazon SP-API credentials (refresh token, seller ID, etc.) hardcoded in environment variables. Only we can scan. The eligibility endpoint (getListingsRestrictions) is per-seller — it returns whether that specific seller can list a product. So if another seller wants eligibility results personalized to their account, they must connect their own Amazon account via OAuth. That’s the fundamental driver for multi-tenancy.
| Component | Technology | Details |
|---|---|---|
| Backend | Python 3.12 / Flask | dashboard.py (11,696 lines) |
| Scanner Engine | Python subprocess | finder.py (2,951 lines) |
| Keepa Client | Python | keepa_client.py (569 lines) |
| Database | SQLite (WAL mode) | checked_asins.db on Railway volume |
| Auth | Supabase JWT (optional) | HS256, audience="authenticated" |
| Frontend | Vanilla JS/HTML/CSS | Embedded in dashboard.py (~7,000 lines) |
| Deployment | Railway (3 services) | Web (Gunicorn), Enrichment (enrichment.py), PostgreSQL |
| Job Queue | None | Scan runs as subprocess |
| Cache | None | — |
+---------------------------+
| Amazon Selling Partner |
| Appstore (discovery) |
+------------+--------------+
| OAuth authorize
v
+----------------------------------------------------------------+
| scanner.adawllc.com |
| |
| +----------+ +--------------+ +---------------------+ |
| | React | | Flask API | | Celery Workers | |
| | Frontend |-->| (REST + Auth)|-->| (per-tenant scans) | |
| | (Vercel) | | (Railway) | | (Railway) | |
| +----------+ +------+-------+ +----------+----------+ |
| | | |
| v v |
| +----------------------------------+ |
| | PostgreSQL (multi-tenant, RLS) | |
| | tenants, checked_asins, | |
| | scan_sessions, usage_tracking | |
| +----------------------------------+ |
| | |
| +--------+--------+ |
| | Redis | |
| | (Celery broker | |
| | + rate limit | |
| | + cache) | |
| +------------------+ |
| |
| +-------------+ +--------------+ +------------------+ |
| | Stripe | | Amazon SP-API| | Keepa API | |
| | (payments) | | (per-tenant | | (enrichment, | |
| | | | credentials)| | shared API key) | |
| +-------------+ +--------------+ +------------------+ |
+----------------------------------------------------------------+
| Aspect | Current (Single-Tenant) | Target (Multi-Tenant SaaS) |
|---|---|---|
| Database | SQLite on Railway volume | PostgreSQL with tenant_id on every table |
| SP-API Credentials | Hardcoded env vars (one seller) | Per-tenant encrypted refresh tokens in DB |
| Auth | Optional Supabase JWT | Required Supabase Auth + tenant provisioning |
| Scan Execution | Subprocess on same machine | Celery workers with per-tenant queues |
| Payments | None | Stripe Checkout + webhooks |
| Frontend | Embedded in Flask | Separate React app (Vercel) — later |
| Rate Limiting | Per-endpoint in-memory | Per-tenant token buckets in Redis |
| Keepa | Shared API key | Shared API key (cost absorbed or tiered) |
| User Onboarding | None | Signup → Pay → Connect Amazon → Scan |
dashboard.py — Flask web server. Serves the entire UI (embedded HTML), all REST API endpoints, scan orchestration (start/stop/monitor), Supabase JWT auth, Keepa enrichment triggers, and stats/analytics.finder.py — Scanner engine. Runs as a subprocess spawned by dashboard.py. Three-phase scan: (1) download Amazon browse tree XML, (2) discover ASINs via CatalogItems API, (3) check eligibility via parallel API calls. Uses ThreadPoolExecutor with Wave 1 (3 threads) and Wave 2 (2 threads).keepa_client.py — Keepa API wrapper. Lazy init with monkey-patch for keepa library bug #221. Provides enrich_product() and enrich_batch(). Extracts 18 metric columns.backfill_offers.py, backfill_sales_estimates.py — Utility scripts for backfilling missing data.| Variable | Required | Purpose |
|---|---|---|
SP_API_REFRESH_TOKEN | Yes | Amazon SP-API auth |
SP_API_LWA_APP_ID | Yes | Login with Amazon app ID |
SP_API_LWA_CLIENT_SECRET | Yes | LWA client secret |
SP_API_AWS_ACCESS_KEY | Yes | AWS IAM access key |
SP_API_AWS_SECRET_KEY | Yes | AWS IAM secret key |
SP_API_ROLE_ARN | Yes | AWS IAM role ARN |
SELLER_ID | Yes | Amazon seller ID (e.g., A1XXXXXXXXXXXX) |
MARKETPLACE_ID | No | Default: ATVPDKIKX0DER (US) |
KEEPA_API_KEY | No | Keepa API key |
SUPABASE_URL | Yes | Supabase project URL |
SUPABASE_ANON_KEY | Yes | Supabase anon key |
SUPABASE_JWT_SECRET | Yes | For JWT verification |
ALLOWED_EMAILS | No | Comma-separated email allowlist |
DB_PATH | No | Default: ./checked_asins.db |
Last updated: 2026-04-26
A public app is one that can be authorized by any Amazon seller (not just you). It gets listed on the Selling Partner Appstore inside Seller Central, where sellers discover and connect third-party tools. This is how every competitor (Jungle Scout, Helium 10, SellerAmp, BoxEm, etc.) integrates with Amazon.
Contrast with a private app: only works for your own seller account, doesn’t need Appstore listing, and is exempt from developer fees. Our current setup is effectively a private app.
Read and understand these three documents before starting:
Go to https://developer.amazonservices.com and sign up. This is separate from Seller Central — it’s the developer portal where you manage your API applications.
Create a test application in the SP-API sandbox environment. This lets you make API calls against Amazon’s test data without affecting real listings.
Verify connectivity by making a simple API call (e.g., getCatalogItem) against the sandbox. This proves your IAM role, credentials, and OAuth flow all work.
Implement the full OAuth 2.0 flow (Login with Amazon / LWA). This is the mechanism by which sellers authorize your app to access their data. See the OAuth Flow page for full details.
Create your production app and select the API roles you need:
Neither role is “restricted” (restricted roles involve PII and require extra security controls).
Test with the version=beta parameter to verify your production app works with real data.
Validate all endpoints work with real seller data. Run through the full scan pipeline: browse tree download → ASIN discovery → eligibility check → pricing/fees.
Submit to the Selling Partner Appstore for review. You’ll need:
Approval timeline: Amazon targets 3–4 weeks. If issues found, they contact via case log.
| Resource | URL |
|---|---|
| Solution Provider Portal | https://developer.amazonservices.com |
| SP-API Documentation | https://developer-docs.amazon.com/sp-api |
| Appstore Listing Guide | https://developer-docs.amazon.com/sp-api/docs/list-your-app-on-the-selling-partner-appstore |
| Stage | Max Seller OAuth Connections |
|---|---|
| Before Appstore listing approved | 25 sellers |
| After Appstore listing approved | Unlimited |
| Self-authorizations (for testing) | 10 |
Last updated: 2026-04-26
OAuth 2.0 is a protocol that lets a user (an Amazon seller) grant our app permission to access their Amazon data without sharing their password. Amazon’s implementation is called Login with Amazon (LWA).
We need OAuth because the getListingsRestrictions API (which checks if a seller can sell a specific product) requires a sellerId parameter that must match the seller whose OAuth token is being used. You literally cannot check Seller A’s eligibility with Seller B’s token.
User clicks “Connect Amazon Account” on scanner.adawllc.com, which triggers the OAuth flow.
Seller finds your app inside Seller Central → Apps & Services, clicks “Authorize.” Same OAuth flow, different entry point.
1. User clicks "Connect Amazon Account" on scanner.adawllc.com
2. Your app redirects to Amazon consent page:
https://sellercentral.amazon.com/apps/authorize/consent
%sapplication_id=YOUR_APP_ID
&state=RANDOM_CSRF_TOKEN
&redirect_uri=https://scanner.adawllc.com/api/auth/amazon/callback
3. Seller logs into Seller Central (if not already logged in)
4. Amazon shows consent page listing requested permissions
5. Seller clicks "Confirm"
6. Amazon redirects back to YOUR redirect URI with params:
%sspapi_oauth_code=AUTH_CODE
&state=RANDOM_CSRF_TOKEN
&selling_partner_id=SELLER_ID
7. Your backend validates the state parameter (CSRF protection)
8. Your backend POSTs to https://api.amazon.com/auth/o2/token:
{
"grant_type": "authorization_code",
"code": "AUTH_CODE",
"redirect_uri": "https://scanner.adawllc.com/api/auth/amazon/callback",
"client_id": "YOUR_LWA_CLIENT_ID",
"client_secret": "YOUR_LWA_CLIENT_SECRET"
}
9. Amazon returns:
{
"access_token": "short-lived (1 hour)",
"refresh_token": "long-lived (365 days)",
"token_type": "bearer",
"expires_in": 3600
}
10. Store refresh_token ENCRYPTED (AES-256) in tenants table
11. User is now connected — can start scanning
| Token | Lifetime | Notes |
|---|---|---|
| Authorization code | 5 minutes | Must exchange immediately after user authorizes |
| Access token | 1 hour | Use for API calls, refresh when expired |
| Refresh token | 365 days | Must re-authorize annually. Amazon sends reminder 30 days before expiry |
1. Retrieve tenant's encrypted refresh_token from PostgreSQL
2. Decrypt refresh_token
3. POST to https://api.amazon.com/auth/o2/token:
{
"grant_type": "refresh_token",
"refresh_token": "TENANT_REFRESH_TOKEN",
"client_id": "YOUR_LWA_CLIENT_ID",
"client_secret": "YOUR_LWA_CLIENT_SECRET"
}
4. Receive new access_token (valid 1 hour)
5. Use access_token in Authorization header for SP-API calls
6. Cache access_token in Redis (TTL: 50 minutes) to avoid unnecessary refreshes
state parameters used to prevent CSRF attacks — validate bothselling_partner_id returned in the callback is the seller’s ID — store it in the tenants tableLast updated: 2026-04-26
When you register an SP-API application, you select which roles your app needs. Each role unlocks specific API endpoints. Amazon has two categories:
| Role | APIs Unlocked | Restricted? |
|---|---|---|
| Product Listing | CatalogItems API (searchCatalogItems, getCatalogItem), Listings Restrictions API (getListingsRestrictions) | No |
| Pricing | Product Pricing API (getCompetitivePricing, getItemOffers, getItemOffersBatch), Product Fees API (getMyFeesEstimate, getMyFeesEstimates) | No |
Neither role is restricted because our use case avoids PII entirely — we only deal with product data, pricing, and eligibility.
| API | Endpoint | Purpose in Our Scanner |
|---|---|---|
| CatalogItems | searchCatalogItems | Discovery phase: find ASINs by category/keyword |
| CatalogItems | getCatalogItem | Get product name, category, BSR |
| ListingsRestrictions | getListingsRestrictions | Check if THIS seller can list THIS ASIN |
| CompetitivePricing | getCompetitivePricing | Get sale price, seller count (batch of 20) |
| ItemOffers | getItemOffers / getItemOffersBatch | Amazon on listing? FBA count, Buy Box info |
| ProductFees | getMyFeesEstimates | Referral fee, FBA fee, total (batch of 20) |
| Reports | GET_XML_BROWSE_TREE_DATA | Download category tree (one-time per scan) |
getListingsRestrictions requires the sellerId parameter and it MUST match the seller whose OAuth token is being used. You cannot check Seller A’s eligibility with Seller B’s token. This is the fundamental reason each user must connect their own Amazon account.
Eligibility varies per seller because of:
Last updated: 2026-04-26
| API Operation | Rate (req/sec) | Burst | Items/Request | Effective Throughput |
|---|---|---|---|---|
getListingsRestrictions | 5 | 10 | 1 ASIN | 5 ASINs/sec |
searchCatalogItems | 2 | 2 | 20 ASINs/page | 40 ASINs/sec |
getCompetitivePricing | 0.5 | 1 | 20 ASINs | 10 ASINs/sec |
getItemOffers | 0.5 | 1 | 1 ASIN | 0.5 ASINs/sec |
getItemOffersBatch | 0.5 | 1 | 20 ASINs | 10 ASINs/sec |
getMyFeesEstimates | 0.5 | 1 | 20 ASINs | 10 ASINs/sec |
The bottleneck is getListingsRestrictions at 5 ASINs/sec (no batch endpoint exists).
| Scan Size | Eligibility Time | Pricing Time | Total Estimate |
|---|---|---|---|
| 100 ASINs | ~20 sec | ~10 sec | ~30 sec |
| 1,000 ASINs | ~200 sec (3.3 min) | ~100 sec | ~5 min |
| 10,000 ASINs | ~33 min | ~17 min | ~50 min |
| 50,000 ASINs | ~2.8 hours | ~1.4 hours | ~4 hours |
| Fee | Amount | Effective Date |
|---|---|---|
| Annual subscription | $1,400/year | January 31, 2026 (ALREADY IN EFFECT) |
| Tier | Monthly GET Calls | Monthly Fee | Effective Date |
|---|---|---|---|
| Basic | Up to 2,500,000 | Free | April 30, 2026 |
| Pro | Up to 25,000,000 | $1,000/month | April 30, 2026 |
| Plus | Up to 250,000,000 | $10,000/month | April 30, 2026 |
| Enterprise | Custom | Custom pricing | April 30, 2026 |
| Overage | Beyond tier limit | $0.40 per 1,000 GET calls | April 30, 2026 |
POST getMyFeesEstimates) and token exchanges (POST) don’t count| Tenants | Scans/Month | Est. GET Calls | Tier | Monthly API Cost |
|---|---|---|---|---|
| 1–10 | 20–100 | ~200K–1M | Basic | $0 |
| 10–50 | 100–500 | ~1M–2.5M | Basic | $0 |
| 50–100 | 500–1,000 | ~2.5M–5M | Pro | $1,000 |
| 100–500 | 1,000–5,000 | ~5M–25M | Pro | $1,000 |
Last updated: 2026-04-26
We’re migrating from SQLite (single file on Railway volume) to PostgreSQL. Options:
We’re using the pool model: a single shared database where every table has a tenant_id column. This is the simplest, cheapest, and recommended approach for our scale (tens to low hundreds of tenants).
Other patterns (silo model = separate DB per tenant, bridge model = separate schema per tenant) are overkill for our scale and add operational complexity.
shared_catalog| Column | Type | Description |
|---|---|---|
asin | TEXT PK | Amazon product identifier |
monthly_sold | INTEGER | Amazon "bought in past month" badge value |
estimated_sales | INTEGER | Computed sales estimate after stale detection |
sales_source | TEXT | "Badge", "BSR Drops", or "BSR Formula" |
sales_confidence | TEXT | "high", "medium", "low" |
enriched_at | TIMESTAMP | When Keepa data was last fetched |
enrichment_count | INTEGER | Number of times enriched |
tenants
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
email TEXT NOT NULL,
display_name TEXT,
-- Stripe
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
plan TEXT NOT NULL DEFAULT 'scout',
-- scout / pro / enterprise / admin
plan_status TEXT NOT NULL DEFAULT 'active',
-- active / past_due / canceled / trialing
-- Amazon SP-API (encrypted)
sp_api_refresh_token TEXT, -- AES-256 encrypted
seller_id TEXT,
marketplace_id TEXT DEFAULT 'ATVPDKIKX0DER',
amazon_connected_at TIMESTAMP,
amazon_token_expires_at TIMESTAMP,
-- refresh_token expiry (365 days from auth)
-- Limits (per-plan, enforced via stripe_config.get_plan_limits())
-- scout: 10,000 scans/mo / pro: 50,000 / enterprise: unlimited / admin: unlimited
scan_limit_monthly INTEGER DEFAULT 10000,
keepa_daily_budget INTEGER DEFAULT 100,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
usage_tracking
CREATE TABLE usage_tracking (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
month TEXT NOT NULL, -- '2026-03'
scans_started INTEGER DEFAULT 0,
asins_scanned INTEGER DEFAULT 0,
api_calls_made INTEGER DEFAULT 0,
keepa_tokens_used INTEGER DEFAULT 0,
UNIQUE(tenant_id, month)
);
Every existing table gets tenant_id UUID NOT NULL REFERENCES tenants(id):
-- checked_asins: add tenant_id, change PK ALTER TABLE checked_asins ADD COLUMN tenant_id UUID NOT NULL; ALTER TABLE checked_asins DROP CONSTRAINT checked_asins_pkey; ALTER TABLE checked_asins ADD PRIMARY KEY (tenant_id, asin); -- Same pattern for all other tables: -- scan_categories: add tenant_id -- scan_asins: add tenant_id -- scan_sessions: add tenant_id -- scan_meta: add tenant_id
The primary key for checked_asins changes from (asin) to (tenant_id, asin) because different sellers can have different eligibility results for the same ASIN.
If using Supabase Postgres, you can enable RLS so that queries automatically filter by the current tenant — an extra safety net beyond application-level filtering:
ALTER TABLE checked_asins ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON checked_asins
USING (tenant_id = current_setting('app.current_tenant')::uuid);
In your Flask app, set the tenant context at the start of each request:
@app.before_request
def set_tenant_context():
tenant_id = get_tenant_from_jwt(request)
db.execute("SET app.current_tenant = %s", [str(tenant_id)])
The full current database schema with all columns and types is documented on the Database Schema page under the Technical section. When migrating, every table gets tenant_id added and composite primary keys where applicable.
Last updated: 2026-04-26
Currently, scans run as a Python subprocess on the same machine as the web server. This works for a single user, but with multiple tenants scanning simultaneously, we need:
Celery is a Python distributed task queue. It takes jobs from your web application and runs them in separate worker processes. Redis acts as the “message broker” — the queue that connects your Flask API to Celery workers.
Flask API
|
| celery_app.send_task('run_scan', args=[tenant_id, config])
v
Redis Queue (broker)
|
| Worker picks up task
v
Celery Worker Process
|
| Uses tenant's SP-API credentials
v
SP-API (Amazon) + PostgreSQL (results)
# tasks.py
from celery import Celery
celery_app = Celery('scanner', broker='redis://localhost:6379/0')
@celery_app.task(bind=True, max_retries=3)
def run_scan(self, tenant_id, scan_config):
# Run a full scan for a specific tenant.
tenant = get_tenant(tenant_id)
sp_client = create_sp_client(
tenant.sp_api_refresh_token,
tenant.seller_id
)
# Phase 1: Tree (skip if already downloaded)
# Phase 2: Discovery (searchCatalogItems)
# Phase 3: Eligibility (Wave 1 + Wave 2)
# All results written to PostgreSQL with tenant_id
# Procfile (updated for multi-service) web: gunicorn dashboard:app --bind 0.0.0.0:$PORT --workers 4 --timeout 120 worker: celery -A tasks worker --loglevel=info --concurrency=4
Railway runs the web and worker as separate services (each with their own container). Redis is also a separate Railway service. All three share the same internal network.
| Option | Pros | Cons |
|---|---|---|
| Celery + Redis (chosen) | Python ecosystem standard, mature, well-documented, Railway-compatible | Adds operational complexity |
| RQ (Redis Queue) | Simpler than Celery | Less features, fewer tutorials |
| Subprocess (current) | Simple, no dependencies | No concurrency, no retry, no monitoring |
| AWS Lambda | Serverless, auto-scaling | Different platform, cold starts, 15 min limit |
enrichment.py) instead of Celery+Redis. It runs as a separate Railway worker with ENRICHMENT_MODE=1 and processes the shared_catalog table on a configurable interval. Celery+Redis is planned for future scaling when concurrent per-tenant scan queuing is needed.
Last updated: 2026-04-26
Stripe is a payment processing platform that handles subscription billing, credit card processing, invoicing, and tax compliance. It’s the industry standard for SaaS products.
| Component | Purpose |
|---|---|
| Stripe Checkout | Hosted payment page for initial subscription. User picks a plan, enters card, Stripe handles everything. |
| Stripe Customer Portal | Self-service page where users manage billing: change plan, update card, download invoices, cancel. |
| Stripe Webhooks | Backend events that notify us when things happen (payment succeeded, failed, subscription canceled, etc.). |
| Stripe Products/Prices | Plan tiers (Scout, Pro, Enterprise) and their prices configured in the Stripe dashboard. |
1. User signs up (Supabase Auth — email/password or Google OAuth) 2. Redirect to Stripe Checkout (plan selection page) 3. User enters credit card and pays 4. Stripe fires checkout.session.completed webhook 5. Backend creates tenant record (plan='scout', status='active') 6. User sees "Connect Amazon Account" button 7. Amazon OAuth flow → refresh_token stored 8. User can now scan
# Endpoint: POST /api/webhooks/stripe checkout.session.completed → User completed payment → Activate tenant, set plan, update plan_status='active' invoice.paid → Recurring payment succeeded → Extend subscription, reset monthly usage counters invoice.payment_failed → Payment failed → Set plan_status='past_due' → Grace period (7 days), then suspend scans customer.subscription.updated → Plan change (upgrade/downgrade) → Update plan and limits in tenants table customer.subscription.deleted → Subscription canceled → Set plan_status='canceled' → Revoke scan access, keep data for 30 days
Every webhook request from Stripe includes a Stripe-Signature header. You must verify this signature using your STRIPE_WEBHOOK_SECRET to prevent spoofed webhook events.
event = stripe.Webhook.construct_event(
payload=request.data,
sig_header=request.headers['Stripe-Signature'],
secret=STRIPE_WEBHOOK_SECRET
)
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Backend API key (sk_live_...) |
STRIPE_PUBLISHABLE_KEY | Frontend key (pk_live_...) |
STRIPE_WEBHOOK_SECRET | Webhook signature verification (whsec_...) |
STRIPE_SCOUT_PRICE_ID | Stripe Price ID for Scout tier |
STRIPE_PRO_PRICE_ID | Stripe Price ID for Pro tier |
STRIPE_ENTERPRISE_PRICE_ID | Stripe Price ID for Enterprise tier |
Last updated: 2026-04-26
We already use Supabase Auth with JWT verification. It’s optional (controlled by ALLOWED_EMAILS env var). For multi-tenant, auth becomes required and we add tenant provisioning on top.
1. Supabase Auth handles signup, login, password reset, MFA 2. On signup, Supabase creates a user in auth.users 3. After Stripe payment, our backend creates a row in tenants table linked to auth.users.id 4. JWT from Supabase includes user_id claim 5. Backend maps user_id → tenant_id on every request 6. All database queries scoped to tenant_id
{
"sub": "user-uuid",
"email": "seller@example.com",
"aud": "authenticated",
"role": "authenticated",
"app_metadata": {
"tenant_id": "tenant-uuid",
"plan": "pro"
}
}
@app.before_request
def require_auth():
token = request.headers.get('Authorization', '')
token = token.replace('Bearer ', '')
payload = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=['HS256'],
audience='authenticated'
)
g.user_id = payload['sub']
g.tenant_id = get_tenant_id_for_user(payload['sub'])
# Enforce plan limits
tenant = get_tenant(g.tenant_id)
if tenant.plan_status != 'active':
abort(402, 'Subscription required')
| Aspect | Current | Multi-Tenant |
|---|---|---|
| Auth required? | Optional | Required for all API endpoints |
| User → tenant mapping | N/A (single tenant) | user_id → tenant_id lookup on every request |
| Plan enforcement | N/A | Check plan_status before allowing scans |
| Signup flow | Email allowlist | Open signup → Stripe payment → tenant creation |
Last updated: 2026-04-26
As a public SP-API developer, Amazon requires you to meet ongoing security obligations. Non-compliance can result in revocation of your API access.
| Requirement | Frequency | Details |
|---|---|---|
| Vulnerability scanning | Every 180 days | Automated scanning of your app and infrastructure |
| Penetration testing | Every 365 days | Must be done by an entity DIFFERENT from whoever built the app |
| Code scanning | Before each release | Static analysis for security issues |
| Encryption at rest | Always | Refresh tokens must be encrypted (AES-256) |
| Encryption in transit | Always | HTTPS everywhere |
| Incident response plan | Always | Document what happens if tokens are leaked |
Last updated: 2026-04-26
We researched 6+ competitors to validate that our planned architecture is correct. The result: every single competitor uses the exact same pattern — public SP-API app + OAuth per-seller + Appstore listing. This confirms our approach.
| Tool | Type | SP-API? | Appstore? | Pricing | Notes |
|---|---|---|---|---|---|
| SellerAmp | Product analysis, eligibility | Yes | Yes | ~$14–28/mo | Shows “No” eligibility without connection |
| ScanUnlimited | Wholesale list scanning | Yes | Yes (Carbon6) | ~$40–80/mo | Upload spreadsheets, bulk analysis |
| Tactical Arbitrage | Retail/online arbitrage | Yes | Yes (Threecolts) | ~$50–100/mo | Scans 1,500+ retail sites |
| BuyBotPro | Product analysis Chrome ext | Yes | Yes | ~$30–50/mo | Chrome extension focused |
| Seller Assistant | Product analysis | Yes | Yes | ~$16–50/mo | Integrates Keepa + SP-API |
| Tool | Type | Pricing | Notes |
|---|---|---|---|
| Jungle Scout | Full suite (research + tracking) | ~$49–129/mo | Massive dataset, own API |
| Helium 10 | Full suite (research + optimization) | ~$39–249/mo | Owned by Pacvue |
| BoxEm | Shipment/inventory/analytics | $23–70/mo | US only, 14-day trial |
getListingsRestrictions needs sellerId matching the OAuth tokenLast updated: 2026-04-26
| Tier | Price | Includes |
|---|---|---|
| Scout | $49/mo | 10,000 scans/mo, Keepa 100 tokens/day (~1,000 enrichments/mo), score-gated auto-enrichment |
| Pro | $99/mo | 50,000 scans/mo, Keepa 500 tokens/day (~5,000 enrichments/mo), all eligible enriched |
| Enterprise | $179/mo | Unlimited scans, Keepa 1,500 tokens/day (~15,000 enrichments/mo), priority queue |
| Scenario | Tenants | Avg Revenue/Tenant | Monthly Revenue | Annual Revenue |
|---|---|---|---|---|
| Early | 25 | $40 | $1,000 | $12,000 |
| Growth | 100 | $55 | $5,500 | $66,000 |
| Scale | 500 | $65 | $32,500 | $390,000 |
| Cost | Monthly | Annual |
|---|---|---|
| Railway (web + worker + Redis + Postgres) | ~$20–50 | ~$240–600 |
| Amazon SP-API developer fee | — | $1,400 |
| Amazon SP-API usage (Basic tier) | $0 | $0 |
| Keepa API | ~$55 (€49) | ~$660 |
| Stripe fees (2.9% + $0.30/txn) | Variable | ~3% of revenue |
| Pen testing | — | ~$2,000–5,000 |
| Total fixed costs | ~$200 | ~$5,000–8,000 |
With ~$200/month in fixed costs, break-even is at ~5–7 paying customers on the Scout plan, or ~3 customers on Pro.
Last updated: 2026-04-26
| # | Task | Status |
|---|---|---|
| 1 | Register as public SP-API developer on Solution Provider Portal | ☐ |
| 2 | Set up PostgreSQL on Railway (or Supabase Postgres) | ☐ |
| 3 | Migrate SQLite schema → PostgreSQL with tenant_id on all tables | ☐ |
| 4 | Create tenants and usage_tracking tables | ☐ |
| 5 | Set up Stripe: create products, prices, checkout session | ☐ |
| 6 | Build signup → payment → dashboard flow | ☐ |
| 7 | Add tenant_id enforcement to all API queries | ☐ |
| # | Task | Status |
|---|---|---|
| 1 | Implement Amazon OAuth flow (LWA authorization + token exchange) | ☐ |
| 2 | Build “Connect Amazon Account” UI flow | ☐ |
| 3 | Store per-tenant SP-API refresh tokens (encrypted AES-256) | ☐ |
| 4 | Modify finder.py to accept tenant context and use tenant’s credentials | ☐ |
| 5 | Set up Celery + Redis for background scan jobs | ☐ |
| 6 | Implement per-tenant scan queuing and rate limiting | ☐ |
| 7 | Add scan progress WebSocket/SSE for real-time updates | ☐ |
| # | Task | Status |
|---|---|---|
| 1 | Stripe webhook handling (payment failures, upgrades, cancellations) | ☐ |
| 2 | Usage metering and plan enforcement (scan limits, ASIN limits) | ☐ |
| 3 | Tenant settings page (billing, Amazon connection status, usage) | ☐ |
| 4 | Onboarding tour for new users | ☐ |
| 5 | Landing page with pricing table | ☐ |
| 6 | Submit app to Selling Partner Appstore for review | ☐ |
| 7 | Vulnerability scan + pen test (for Amazon DPP compliance) | ☐ |
| # | Task | Status |
|---|---|---|
| 1 | Chrome extension for on-page product analysis | ☐ |
| 2 | Additional marketplaces (UK, CA, EU) | ☐ |
| 3 | Team/sub-account support | ☐ |
| 4 | API access for Enterprise tier | ☐ |
| 5 | Separate React frontend (if needed) | ☐ |
| Decision | Choice | Rationale |
|---|---|---|
| Database | PostgreSQL | Multi-tenant RLS, scalability, industry standard |
| App type | Public SP-API app on Appstore | Required for multi-seller OAuth, unlimited connections |
| Payments | Stripe | Industry standard SaaS billing |
| Job queue | Celery + Redis | Python ecosystem standard, mature, Railway-compatible |
| Auth | Supabase Auth | Already in use, handles full user lifecycle |
| Decision | Options | Notes |
|---|---|---|
| PostgreSQL host | Railway Postgres vs Supabase Postgres | Supabase has free tier + built-in RLS |
| Frontend | Keep embedded HTML (MVP) vs React | Embedded is faster to ship |
| Keepa cost | Absorb in plan price vs separate add-on | Most competitors include data enrichment |
| SP-API app count | Single app vs multiple registrations | Single is fine until rate limits become an issue |
Last updated: 2026-04-26
Terms and definitions used throughout the SaaS scaling documentation. If you encounter an unfamiliar term, check here first.
| Term | Definition |
|---|---|
| ASIN | Amazon Standard Identification Number — a unique 10-character alphanumeric product ID assigned by Amazon to every product in its catalog. |
| BSR | Best Seller Rank — a number representing how well a product sells compared to others in its category. Lower = better. Updated hourly by Amazon. |
| Buy Box | The “Add to Cart” button area on a product page. Multiple sellers can list the same product, but only one “wins” the Buy Box at any time. Winning the Buy Box means your offer is the default purchase option. |
| Celery | A Python distributed task queue library for processing background jobs asynchronously. Used to run scans in separate worker processes. |
| DPP | Data Protection Policy — Amazon’s security requirements for SP-API developers, including encryption, vulnerability scanning, and penetration testing. |
| FBA | Fulfillment by Amazon — a service where sellers ship inventory to Amazon warehouses. Amazon handles storage, packing, shipping, and customer service. |
| FBM | Fulfillment by Merchant — the seller handles their own storage, packing, and shipping directly to the customer. |
| Green Light Score | Our proprietary weighted 0–10 scoring system that evaluates product opportunity quality based on profit margin, sales velocity, competition (FBA sellers), price point, and Keepa trends. |
| IAM | Identity and Access Management — an AWS service for managing API credentials and permissions. SP-API requires an IAM Role for authentication. |
| LWA | Login with Amazon — Amazon’s implementation of OAuth 2.0. Used to let sellers authorize our app to access their SP-API data. |
| MWS | Marketplace Web Service — Amazon’s old API for seller tools. Fully sunset in March 2024. Replaced by SP-API. |
| OAuth | Open Authorization — a protocol that lets a user (Amazon seller) grant our app permission to access their data without sharing their password. |
| Pool Model | A multi-tenant database pattern where all tenants share one database and tables, with a tenant_id column on every row for isolation. Simplest and cheapest approach. |
| Redis | An in-memory data store used as a message broker (Celery queue), cache (access tokens), and rate limiter (token buckets). |
| RLS | Row-Level Security — a PostgreSQL feature that restricts which rows a query can see based on the current session context. Provides an extra layer of tenant isolation beyond application code. |
| SP-API | Selling Partner API — Amazon’s current REST API for third-party seller tools. Replaced MWS in 2024. |
| Stripe | A payment processing platform that handles subscription billing, credit card processing, invoicing, and tax compliance for SaaS products. |
| Tenant | A single customer (Amazon seller) in our multi-tenant system. Each tenant has their own data, credentials, and scan results, isolated from other tenants. |
| WAL | Write-Ahead Logging — a SQLite journaling mode that allows concurrent reads while writing. Used in our current single-tenant setup. |
| Webhook | An HTTP callback — when an event happens (payment succeeded, subscription canceled), the external service (Stripe/Amazon) sends a POST request to our server to notify us. |
shared_catalog table (550K+ ASINs) replaces per-tenant Keepa enrichmentenrichment.py) on separate Railway instance#/overview, #/products, etc.)<template> cloning/health for Railway monitoring/api/diagnostic/shared-catalog for catalog healthfont-family: inherit fix for all buttons/inputsComplete system overhaul: new scan modes, redesigned UI, cost optimizations, performance improvements, and 30+ bug fixes.
| Feature | Description | Impact |
|---|---|---|
| Top Sellers Scan | New scan mode using Keepa Best Sellers API to discover 100K+ top-selling ASINs across all categories. Bypasses category browsing entirely. | 10x faster product discovery |
| Eligibility-First Pipeline | Check eligibility BEFORE pricing/catalog. Non-eligible ASINs (92%) cost 1 API call instead of 5. | 75% fewer SP-API calls |
| Shared Eligibility Cache | Global cache with 7-day TTL prevents re-checking 300K+ non-eligible ASINs on subsequent scans. | 80% fewer eligibility calls |
| Shared Sales Catalog | Global shared_catalog table with 550K+ ASINs and Keepa enrichment data. Background enrichment service refreshes stale entries daily. All tenants share the same sales data — ADAW absorbs the Keepa API cost. | 90% Keepa cost reduction |
| Discord Webhook Alerts | Real-time Discord notifications for high-quality Green Light products (score 7.0+ with 100+ monthly sales). Set via DISCORD_WEBHOOK_URL environment variable. | Instant product alerts |
| Per-Category BSR Curves | 27 category-specific BSR-to-sales formulas replace one-size-fits-all. High-volume categories get steeper decay curves. | 40% more accurate sales estimates |
| Sales Intelligence Panel | Product modal shows all 3 data sources (Badge, BSR Drops, BSR Formula) with confidence levels and cross-validation. | Transparent sales data |
| Live Product Feed | Products page shows LIVE/NEW badges on recently discovered products with green glow animation. | Real-time discovery visibility |
| Scan Type Badges | Visual indicators for FULL SCAN vs TOP SELLERS mode in header and stat cards. | Clear scan mode awareness |
| Yield Analytics Redesign | Summary stats (Current/Trend/Peak/Average), improved chart with deduped x-axis labels. | Better yield insights |
| ETA Indicators | Live time-remaining estimates on Eligibility, Yield, and Data Enrichment cards. | Scan progress predictability |
| Restart Scan | Full scan restart with history preservation. Clears scan data while keeping historical sessions. | Clean re-scan capability |
| Change | Before | After |
|---|---|---|
| Products default sort | Newest first (checked_at) | Best first (score DESC) |
| Buy Box column | "Retailers" with %s badges | "Buy Box" with Amazon/3P Seller/Open badges |
| Brand column | Often empty (SP-API doesn't always return) | Removed — saves space |
| Score tinting | All rows same style | Green/Yellow left border accent |
| Image fallbacks | Blank square | Initial letter in dark circle |
| Missing data | Dashes (---) | "Pending" text with opacity |
| Pipeline cards | 4 cards (Discovery, Eligibility, Refresh, Yield) | 3 cards (removed Refresh) |
| Keepa section | Large card with buttons | Compact "Data Enrichment" bar |
| Mobile responsive | Horizontal scroll issues | Locked axis, stacked layouts |
| Polling rate | Every 1 second | Every 5 seconds (80% less load) |
| Optimization | Metric | Improvement |
|---|---|---|
| SELECT * → specific columns | Brands endpoint | 5.9MB → 1.5MB response |
| Analysis cache (30s TTL) | Dashboard poll | 6x fewer heavy queries |
| Brands cache (5min TTL) | Brands page | Eliminated 502 timeouts |
| Stats cache (3s TTL) | All pages | 60% fewer COUNT queries |
| Eligibility-first pipeline | Per-ASIN processing | 4-5x throughput increase |
| Database indexes | asin, is_eligible, category | Faster queries on 300K+ rows |
| Lazy brand detail rendering | Brands expand/collapse | Instant page load vs 10s+ lag |
| Client-side brand caching | Tab switching | Instant re-render from memory |
The scanner uses two external APIs with usage-based costs. Our optimization strategy reduces total API calls by ~80% without sacrificing data quality.
| Strategy | How It Works | Savings |
|---|---|---|
| Eligibility-First Pipeline | Check eligibility BEFORE pricing/catalog. 92% of ASINs are rejected at this gate (1 call) instead of running the full 5-call pipeline. | 75% fewer calls |
| Shared Eligibility Cache | Non-eligible results cached globally with 7-day TTL. Subsequent scans skip SP-API entirely for known-rejected ASINs. | 80% on re-scans |
| Early Price Rejection | Products under $12 or with 15+ sellers skip Wave 2 (fees + offers), saving 2 API calls per product. | ~20% of eligible |
| Strategy | How It Works | Savings |
|---|---|---|
| Global Keepa Cache | 289K+ ASINs cached globally. When any user scans an ASIN already enriched, data served from cache (0 tokens). | 60-80% at scale |
| Smart Budget Management | When daily budget < 50%, daemon skips Red products and focuses tokens on Green + Yellow products only. | 70% token savings |
| Tiered Freshness TTL | BSR <10K = 2-day refresh, BSR <50K = 5-day, BSR >50K = 10-day. Fast movers get fresher data. | 50% daemon savings |
| Pre-Score Threshold | Only products scoring ≥3.5 pre-Keepa get enriched. Low-quality products skip enrichment entirely. | ~30% fewer enrichments |
The Shared Catalog architecture further reduces costs by 90%: one Keepa plan (~$100/mo) serves all tenants via the shared_catalog table, vs $500+/mo for per-tenant enrichment.
| Bug | Root Cause | Fix |
|---|---|---|
| Scan crashes after 5 ASINs | UnboundLocalError on _keepa_batch_buffer — reassignment inside function made Python treat global as local | Added global _keepa_batch_buffer declaration |
| PostgreSQL transaction abort | init_db() DDL failures left connection in InFailedSqlTransaction state, poisoning all subsequent queries | Wrapped each DDL in try/except with explicit commit/rollback |
| Brands 500 error | SELECT * loading 135K+ rows into memory caused timeout. Also, pagination code mutated cached objects via pop() | Added is_eligible = TRUE filter (11K vs 135K rows) + shallow copy instead of mutation |
| 502 server overload | Frontend polling every 1 second with 2 gunicorn workers. 600+ requests queued. | Reduced polling to 5 seconds, added analysis cache (30s TTL) |
| 12.3M duplicate ASINs | Clicking Top Sellers multiple times stacked ASINs in scan_asins without dedup. No guard against concurrent starts. | Added is_scan_running() check + dedup on insert + cleanup endpoint |
| Silent Keepa failures | 50+ except: pass blocks hiding real errors. Production debugging impossible. | Replaced with except Exception as e: print() for visibility |
| Amazon grade incorrect | Products with Amazon on Buy Box scored C/Hold instead of F/Avoid | Hardcoded grade override: Amazon BB = F grade + AVOID badge |
| 0/10 scores on valid products | Score computation used fee_margin from DB (often NULL) instead of calculating from price/fees | Compute margin inline if DB value is NULL |
| NULL sort crashes | Sorting by score put NULL-score products randomly in results | Added NULLS LAST to all sort columns |
| History card shows zeros | Running scan session queried only finalized data, not live counts | Added live query fallback for active sessions |
| Keepa price mismatch | Badge data diverging >3x from sale price displayed misleading prices | Hidden with warning when divergence exceeds 3x |
| Stale badge CHECK 3 | Drops-vs-badge ratio >5x not detected, inflating sales estimates | Added cross-validation check; capped at 3x BSR estimate |
| N+1 query | shared_catalog per-product queries caused slow dashboard loads | Replaced with batch preload on analysis endpoint |
| Font fallback | 17+ buttons using Arial instead of system font | Global font-family: inherit fix for all buttons/inputs |
| Watchlist table creation | PostgreSQL CREATE TABLE IF NOT EXISTS not running on startup | Added watchlist table to init_db() migration |
| Bug | Fix |
|---|---|
HTML tags rendered in Quality Distribution (<span> visible) | Fixed template escaping |
| Product Type showing underscores (PRECISION_MEASURING) | Added title case + space conversion |
| Yield chart x-axis showing "Mar 5, Mar 5, Mar 5..." repeating | Deduplicated labels, show only on date change |
| Mobile horizontal scroll on all pages | Added overflow-x:hidden, responsive breakpoints |
| Top Opportunities not sorted correctly | Fixed opportunity_score sort to use DESC |
| Alert/confirm dialogs blocking UI | Replaced with non-blocking showToast() notifications |
| Docs page not mobile-friendly | Added responsive sidebar, stacked layout |
Manage your plan, billing, notifications, and integration settings.
Your identifiers and quick-access utilities.
Connect your own Seller Central account so eligibility checks and pricing data come from your seller credentials, not the shared master account. Get your LWA refresh token →
Force a specific ASIN or brand to force_eligible (you have a brand grant Amazon's API doesn't surface) or force_ineligible (your "do-not-source" list). Overrides take precedence over the cache and live SP-API result.
Track copyright, trademark, patent, and authenticity complaints
Amazon has issued against your seller account. Open complaints
hide the matching ASINs and brands from your Top Opportunities
(when ENABLE_COMPLAINT_FILTER is on). Upload a CSV
export from Seller Central or add manually.
Send alerts to a Discord channel when new Green-light products land in your dashboard (score ≥ 7.0, 100+ monthly sales, 2+ sellers, Amazon not in buy box). How do I create a webhook?
Switch plans any time. Pro-rated to your billing cycle by Stripe.
Aggregate platform stats from the shared-pool architecture.
All brands detected in your scan data — ungated brands are highlighted.
| / | Focus search |
| j / k | Navigate rows down / up |
| Enter | Open product detail |
| s | Toggle watchlist star |
| Esc | Close modal / dialog |
| %s | Show this help |