# Monitor prices with scheduled refreshes and webhooks

> Schedule daily price re-checks and receive signed price_drop and availability_change webhooks, with receivers that verify the signature and drop retries.

*Source: https://shopsavvy.com/data/documentation/recipe-price-monitor* · Part of the [ShopSavvy Data API documentation](https://shopsavvy.com/data/documentation.md)

---

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](https://shopsavvy.com/data/documentation/schedule-product) and [Webhooks](https://shopsavvy.com/data/documentation/webhooks). An account can have up to 5 webhooks.

## Set up: bash

```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

```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

```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.

```python
"""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

```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)
```
