Monitor prices with scheduled refreshes and webhooks
Last updated · Changelog · OpenAPI spec · Markdown
Have ShopSavvy re-check a list of products every day and call your server when a price drops or a product goes in or out of stock. Part one schedules the products and registers the webhook. Part two is the receiver: it verifies the X-ShopSavvy-Signature header, rejects stale or forged requests, and ignores retries of an event it has already handled.
| Step | REST endpoint | MCP tool | Credits |
|---|---|---|---|
| Schedule daily re-checks | PUT /v1/products/scheduled | product_schedule | 1 per product, once |
| Register the webhook (save the secret it returns) | POST /v1/webhooks | none | 0 |
Receive price_drop and availability_change events | your HTTPS endpoint | none | 0 |
| Read the latest prices when you need them | GET /v1/products/offers | product_offers | 3 per product with offers |
Cost for N products: N credits to schedule them; the daily refreshes and webhook deliveries are not charged. Scheduled refreshes run at the retailers ShopSavvy can re-check through an official retailer API (Amazon and Best Buy today); other retailers update as ShopSavvy sees them. See Enabling Scheduled Refreshes for Products and Webhooks. An account can have up to 5 webhooks.
Set up: bash
#!/usr/bin/env bash
# Schedule products for daily re-checks and register a webhook for price drops and stock changes.
# Usage: SHOPSAVVY_API_KEY=... ./monitor-setup.sh https://your-app.example/shopsavvy 611247373064,885909950805
set -euo pipefail
. ./shopsavvy-call.sh
# 1 credit per product scheduled (up to 100 per call); the daily refreshes themselves are not charged.
api PUT "/products/scheduled?ids=$2&schedule=daily" | jq -r '.data[] | "Scheduled \(.schedule): \(.title)"'
# The secret is shown once. Store it where your receiver can read it.
api POST /webhooks "$(jq -n --arg url "$1" '{url: $url, events: ["price_drop", "availability_change"]}')" \
| jq -r '"Webhook \(.webhook.id) registered. SHOPSAVVY_WEBHOOK_SECRET=\(.webhook.secret)"'Set up: Python
"""Schedule products for daily re-checks and register a webhook for price drops and stock changes.
Usage: SHOPSAVVY_API_KEY=... python monitor_setup.py https://your-app.example/shopsavvy 611247373064 885909950805
"""
import sys
from shopsavvy_call import call
def main(webhook_url, identifiers):
# 1 credit per product scheduled; the daily refreshes themselves are not charged.
for start in range(0, len(identifiers), 100):
ids = ",".join(identifiers[start:start + 100])
scheduled = call("PUT", "/products/scheduled", params={"ids": ids, "schedule": "daily"}).json()
for product in scheduled["data"]:
print(f"Scheduled {product['schedule']}: {product['title']}")
webhook = call("POST", "/webhooks", json={"url": webhook_url, "events": ["price_drop", "availability_change"]}).json()["webhook"]
# The secret is shown once. Store it where your receiver can read it.
print(f"Webhook {webhook['id']} registered. SHOPSAVVY_WEBHOOK_SECRET={webhook['secret']}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2:])Set up: TypeScript
// Schedule products for daily re-checks and register a webhook for price drops and stock changes.
// Usage: SHOPSAVVY_API_KEY=... npx tsx monitor-setup.ts https://your-app.example/shopsavvy 611247373064 885909950805
import { call } from './shopsavvy-call'
async function main(webhookUrl: string, identifiers: string[]) {
// 1 credit per product scheduled; the daily refreshes themselves are not charged.
for (let start = 0; start < identifiers.length; start += 100) {
const ids = encodeURIComponent(identifiers.slice(start, start + 100).join(','))
const scheduled = await (await call('PUT', `/products/scheduled?ids=${ids}&schedule=daily`)).json()
for (const product of scheduled.data) console.log(`Scheduled ${product.schedule}: ${product.title}`)
}
const { webhook } = await (await call('POST', '/webhooks', { body: { url: webhookUrl, events: ['price_drop', 'availability_change'] } })).json()
// The secret is shown once. Store it where your receiver can read it.
console.log(`Webhook ${webhook.id} registered. SHOPSAVVY_WEBHOOK_SECRET=${webhook.secret}`)
}
main(process.argv[2], process.argv.slice(3))Receiver: Python
The signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, computed with the webhook's secret. Verify it against the raw request body before parsing the JSON.
"""Receive ShopSavvy Data API webhooks: verify the signature, drop repeats, handle the event.
Usage: SHOPSAVVY_WEBHOOK_SECRET=<secret from POST /v1/webhooks> python webhook_receiver.py
Listens on port 8000. Put it behind HTTPS and register that URL.
"""
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["SHOPSAVVY_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
seen_event_ids = set() # keep these in your database in production
def signature_valid(header, body):
"""X-ShopSavvy-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">."""
parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item)
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not signature_valid(self.headers.get("X-ShopSavvy-Signature", ""), body):
self.send_response(401)
self.end_headers()
return
event = json.loads(body)
# Retries reuse the same id; test events have none.
if event.get("id") not in seen_event_ids:
if event.get("id"):
seen_event_ids.add(event["id"])
data = event.get("data", {})
product, price = data.get("product", {}), data.get("price", {})
print(f"{event['event']}: {product.get('title')} at {data.get('retailer')}: "
f"{price.get('old')} -> {price.get('new')} {price.get('currency') or ''}", flush=True)
self.send_response(200)
self.end_headers()
HTTPServer(("", 8000), Handler).serve_forever()Receiver: TypeScript
// Receive ShopSavvy Data API webhooks: verify the signature, drop repeats, handle the event.
// Usage: SHOPSAVVY_WEBHOOK_SECRET=<secret from POST /v1/webhooks> npx tsx webhook-receiver.ts
// Listens on port 8000. Put it behind HTTPS and register that URL.
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.SHOPSAVVY_WEBHOOK_SECRET!
const TOLERANCE_SECONDS = 300
const seenEventIds = new Set<string>() // keep these in your database in production
// X-ShopSavvy-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">.
function signatureValid(header: string, body: Buffer): boolean {
const parts = Object.fromEntries(header.split(',').map((item) => item.split('=', 2)))
const timestamp = parts.t ?? ''
const signature = Buffer.from(parts.v1 ?? '')
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = Buffer.from(createHmac('sha256', SECRET).update(`${timestamp}.`).update(body).digest('hex'))
return expected.length === signature.length && timingSafeEqual(expected, signature)
}
createServer((request, response) => {
const chunks: Buffer[] = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
const body = Buffer.concat(chunks)
if (!signatureValid(String(request.headers['x-shopsavvy-signature'] ?? ''), body)) {
response.writeHead(401).end()
return
}
const event = JSON.parse(body.toString('utf8'))
// Retries reuse the same id; test events have none.
if (!seenEventIds.has(event.id)) {
if (event.id) seenEventIds.add(event.id)
const { product = {}, retailer, price = {} } = event.data ?? {}
console.log(`${event.event}: ${product.title} at ${retailer}: ${price.old} -> ${price.new} ${price.currency ?? ''}`)
}
response.writeHead(200).end()
})
}).listen(8000)