---
title: Pricesaurus Watch API
description: Paste a product URL, watch the price, set an alert. Bearer token.
canonical: https://pricesaurus.com/docs/api.md
last-updated: 2026-09-11
---

# Pricesaurus Watch API

Base URL: `https://pricesaurus.com/api/v1`

Version: `1.0.0`. OpenAPI: `/openapi.json`. HTML: `/developers`. Auth notes: `/auth.md`. SDK and CLI: [pricesaurus-js on GitHub](https://github.com/alfonsobries/pricesaurus-js) (`npm install pricesaurus`).

The iOS app, Android app, web app, and any SDK speak this API. It is not a wrapper around Inertia or the old store-picker flow.

## Auth

Create a **developer** token on Plus or Max: Account → API (`/api-keys`). Copy it once.

```
Authorization: Bearer <token>
```

The token the mobile app stores is not a developer token. Those do not appear on `/api-keys` and cannot call this API as a substitute for a key you created there.

Eco cannot create keys (`403`).

## Idempotency and content type

- `Content-Type: application/json`
- `Accept: application/json`
- IDs are UUIDs. Use the `id` field from responses, never integer primary keys.

## Rate limit

60 requests per minute per user. Exceeding it returns `429`.

## Pagination

List endpoints return:

```json
{
  "data": [ ... ],
  "meta": { "page": 1, "per_page": 20, "total": 41 }
}
```

Query: `page` (default `1`), `per_page` (products max `100`, prices max `200`).

## Errors

```json
{
  "error": {
    "code": "no_price",
    "message": "human readable",
    "retryable": false,
    "driver": "scrapingbee"
  }
}
```

| HTTP | When |
| --- | --- |
| 401 | Missing or invalid bearer |
| 403 | Eco cannot use the API (`plan_required`). Product or alert cap (`limit_reached`). Not your resource |
| 404 | Unknown id |
| 422 | Body invalid, or the page was reached but unusable (`no_price`, `unavailable`) |
| 429 | Rate limit |
| 503 | Retry later (`blocked`, timeout, quota, crawler down) |

`error.code` values: `no_price`, `unavailable`, `blocked`, `http`, `not_configured`, `quota`, `invalid`.

## Objects

### Product

| Field | Type | Notes |
| --- | --- | --- |
| id | string | UUID |
| url | string | URL you saved |
| canonical_url | string | Tracking params stripped |
| name | string | May be clipped to 255 |
| price | number \| null | Major units (`685.00`). Null while `pending` |
| currency | string \| null | ISO 4217 |
| image_url | string \| null | |
| store_name | string \| null | Display only. There is no store resource |
| status | string | `watching` \| `pending` \| `failing` |
| is_failing | boolean | |
| is_active | boolean | Paused products do not count toward the plan cap |
| crawler | string \| null | Internal driver name |
| can.add_alert | boolean | |
| can.add_alert_message | string \| null | |
| can.update | boolean | |
| can.delete | boolean | |
| lowest_price | number \| null | |
| highest_price | number \| null | |
| initial_price | number \| null | First successful check |
| checked_at | string \| null | ISO 8601 |
| created_at | string \| null | ISO 8601 |

### Alert

| Field | Type | Notes |
| --- | --- | --- |
| id | string | UUID |
| product_id | string | UUID |
| name | string | |
| condition | string | `drops` \| `below` \| `above` (API). Stored internally as `lower` / `below` / `above` |
| threshold | number \| null | Required for `below` and `above` |
| is_active | boolean | |
| created_at | string \| null | |

`condition` on write:

| Value | Meaning |
| --- | --- |
| `drops` | Fires when the price goes down. No threshold. Can fire more than once |
| `below` | Fires when price ≤ `threshold`. One-shot |
| `above` | Fires when price ≥ `threshold`. One-shot |

### Snapshot (extract / check)

| Field | Type | Notes |
| --- | --- | --- |
| url | string | |
| canonical_url | string | |
| name | string \| null | |
| price | number \| null | |
| currency | string \| null | |
| availability | string | `in_stock` \| `out_of_stock` \| `unknown` |
| image_url | string \| null | |
| store_name | string \| null | |
| driver | string | `scrapingbee` \| `firecrawl` |
| contract | string \| null | e.g. `amazon` |
| credits_used | integer \| null | |

### Account (`GET /me`)

| Field | Type |
| --- | --- |
| email | string |
| plan | `eco` \| `plus` \| `max` \| null |
| products_used | integer | Active watches |
| products_limit | integer \| null |
| webhook_configured | boolean |

## Endpoints

### GET /me

Current plan and webhook flag.

### POST /extract

Read a page. Does **not** save a product.

Body: `url` (required), `country` (ISO 3166-1 alpha-2, optional), `currency` (ISO 4217, optional).

### GET /products

List watches. Query: `page`, `per_page`.

### POST /products

Start watching a URL. Counts toward the plan cap.

Body: `url` (required), `name` (optional), `country` (optional), `currency` (optional).

`201` + product. `422` if the page has no price.

### GET /products/{id}

### PATCH /products/{id}

Body (any subset): `name`, `is_active`.

Pause with `"is_active": false` to free a slot without deleting history.

### DELETE /products/{id}

`204`.

### POST /products/{id}/checks

Force a check now. Returns `{ product, snapshot }`.

### GET /products/{id}/prices

History. Query: `page`, `per_page`.

Each row: `id`, `price`, `currency`, `recorded_at`.

### GET /products/{id}/alerts

### POST /products/{id}/alerts

Body: `condition` (`drops` \| `below` \| `above`), `threshold` (number, required for below/above), `name` (optional).

`201`. Blocked when the product is at the alert cap (`can.add_alert` is false).

### PATCH /alerts/{id}

Body (any subset): `name`, `is_active`.

### DELETE /alerts/{id}

`204`.

### POST /webhook/test

Queues `webhook.test` to the URL saved in `/api-keys`. `422` if no URL.

## Webhooks

Save the URL and copy the secret once (Account → API).

We POST:

```json
{
  "event": "price.alert",
  "occurred_at": "2026-09-11T12:00:00+00:00",
  "data": {
    "product": { "id": "…", "name": "…", "url": "…" },
    "price": 42.5,
    "alerts": [{ "id": "…", "name": "…" }]
  }
}
```

| Header | Value |
| --- | --- |
| Content-Type | application/json |
| X-Pricesaurus-Event | `price.alert` or `webhook.test` |
| X-Pricesaurus-Signature | `sha256=<hex>` HMAC-SHA256 of the **raw body** with your secret |
| User-Agent | Pricesaurus-Webhook/1.0 |

Verify: `hmac_sha256(secret, raw_body) === signature_without_prefix`.

Retries: 3. Timeout: 10s.

## Plans

| Plan | API | Products | Alerts / product |
| --- | --- | --- | --- |
| Essential (eco) | no | 5 | 1 |
| Plus | yes | 15 | 3 |
| Max | yes | 40 | unlimited |

## Examples

### cURL

```bash
TOKEN=ps_live_...
curl -s https://pricesaurus.com/api/v1/me \
  -H "Authorization: Bearer $TOKEN"

curl -s https://pricesaurus.com/api/v1/extract \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.amazon.com/dp/B00CH9QWOU"}'

curl -s https://pricesaurus.com/api/v1/products \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.amazon.com/dp/B00CH9QWOU"}'

ID=product-uuid
curl -s https://pricesaurus.com/api/v1/products/$ID/alerts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"condition":"drops"}'

curl -s -X PATCH https://pricesaurus.com/api/v1/products/$ID \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_active":false}'
```

### JavaScript

```js
const api = (path, init = {}) =>
  fetch("https://pricesaurus.com/api/v1" + path, {
    ...init,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  }).then((r) => (r.status === 204 ? null : r.json()));

await api("/me");
await api("/extract", { method: "POST", body: JSON.stringify({ url }) });
const product = await api("/products", { method: "POST", body: JSON.stringify({ url }) });
await api(`/products/${product.data.id}/alerts`, {
  method: "POST",
  body: JSON.stringify({ condition: "below", threshold: 500 }),
});
```

### Python

```python
import requests
H = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
B = "https://pricesaurus.com/api/v1"
requests.get(f"{B}/me", headers=H).json()
requests.post(f"{B}/products", headers=H, json={"url": url}).json()
```

### PHP

```php
$ch = curl_init('https://pricesaurus.com/api/v1/products');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer {$token}",
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['url' => $url]),
    CURLOPT_RETURNTRANSFER => true,
]);
json_decode(curl_exec($ch), true);
```

This API is designed so an SDK is a thin client over `/api/v1`. Pin the OpenAPI document. Do not depend on Inertia routes, session flash, or temporal products.
