# Keep prices fresh, and get notified when something changes

Four endpoints, one idea: every time your app fetches live data from a brand for one of
your own users, it can hand that reading back to Ziffi — for free, as a side effect of a
request you were already making. That keeps the shared catalogue fresh for everyone, and
lets you subscribe to price drops, restocks, and other changes without polling.

- [Report a live reading — `POST /v1/observe`](#report-a-live-reading) — the "greedy
  harvest": relay a brand's real response back to us.
- [Watches — `POST/GET/DELETE /v1/watches`](#watches) — "tell me when this variant's price
  drops to X, or it's back in stock."
- [Notifications — `GET /v1/notifications`, `POST /v1/notifications/ack`](#notifications) —
  pull the list of things your watches have fired.
- [Webhooks — `POST/GET/DELETE /v1/webhooks`](#webhooks) — get notifications pushed to your
  own server instead of polling, HMAC-signed.

If you only want live price/stock for a product you already know, you don't need any of
this — see [Get live price](#/price) instead. This guide is for two different, related
needs: (1) contributing freshness back to the shared catalogue, and (2) being told
proactively when something changes, instead of asking repeatedly.

## Report a live reading

```bash
curl -X POST "https://api.ziffi.xyz/v1/observe" \
  -H "Content-Type: application/json" \
  -H "X-Ziffi-Key: zk_live_your_key_here" \
  -d '{
    "observations": [
      {"variant_id": "018f0000-…", "shopify_variant_id": "47880003420382",
       "price_minor": 149900, "compare_at_minor": 199900, "available": true,
       "currency": "INR", "observed_at": "2026-08-06T10:00:00Z"}
    ]
  }'
```

**The model, in one sentence: your app is a courier.** When your own user's device fetches
a brand's public data (e.g. `GET https://{brand}/products.json?limit=250`, or a single
`GET https://{brand}/products/{handle}.js`), that request already went out from your
user's own IP to the brand's own public endpoint — nothing sensitive, nothing we couldn't
fetch ourselves. Relaying that real response back to `/v1/observe` lets us write it
straight into the same live tables our own server-side sweep writes into, at the same
trust level. One `products.json` page can refresh price/stock for up to 250 variants at
once — most brands' entire catalogue fits in one page.

**Why we trust it enough to write it directly (no quarantine, no review queue):** the
*value* isn't the risk — it's the brand's own real public response, same as if we'd
fetched it ourselves. What we actually guard against is a **malformed or hostile
payload** damaging the database, with four structural defenses, always on, no exceptions:

1. Every SQL write is parameterized — a value can never be interpreted as SQL.
2. Every field is coerced to its exact type before it's allowed near the database — price
   must be a sane positive integer, `available` must be a clean boolean, currency must
   match the brand's own currency. Anything that doesn't pass is **skipped, never
   written** — not corrected, not guessed at.
3. Every observation is mapped to a **real, existing variant** first. An id that doesn't
   resolve, or a Shopify variant id that doesn't match what we have on file for that
   internal id, is skipped — this is what stops one brand's data from ever landing on a
   different brand's variant.
4. A **monotonic write guard**: a reading older than what we already have can never roll
   a price backward. Replaying or resending an old observation is always safe — it's a
   no-op, never a regression.

Auth is required — abuse is handled by
revoking the offending key, not by a per-price suspicion engine.

### Request body

| Field | Type | Required | Notes |
|---|---|---|---|
| `observations` | array, ≤250 items | yes | See table below. You can also POST a single observation object directly (no wrapper) — it's treated as a 1-item array. |
| `brand_snapshot` | object | no | `{brand_id, observed_product_ids: [...]}` — the full set of product ids seen on the page. Currently just acknowledged and counted (`brand_snapshot_received`); feeds the new/removed-product change-detector in a later phase — sending it now costs nothing and future-proofs your integration. |

Each item in `observations`:

| Field | Type | Required | Notes |
|---|---|---|---|
| `variant_id` | string (uuid) | yes | Ziffi's own variant id — the one you got from `/v1/products/{id}` or a search result. |
| `shopify_variant_id` | string | no | The brand's own numeric variant id (from the `.js`/`products.json` response). If present, must match what we have on file for `variant_id` — a mismatch is skipped as a likely wrong-store mixup, not silently accepted. |
| `price_minor` | integer | yes | Minor units (paise/cents) — `149900` not `1499.00`. |
| `compare_at_minor` | integer | no | The struck-through "was" price, if the brand showed one. |
| `available` | boolean | no | Omit if the source didn't tell you — don't guess `true`/`false`. |
| `currency` | string | no | 3-letter ISO code. Must match the brand's own currency or it's skipped as garbage. |
| `observed_at` | ISO-8601 string | no | When you actually fetched it. Defaults to now(); a future timestamp is clamped to now(), not trusted at face value. |

### Response

```jsonc
{
  "data": {
    "results": [
      {"variant_id": "018f0000-…", "status": "filed", "changed": true}
    ],
    "counts": {"filed": 1},
    "brand_snapshot_received": 250   // only present if you sent brand_snapshot
  },
  "tier": "live", "as_of": "…"
}
```

Every item gets exactly one result — the request itself only fails (with a normal error
response) on a malformed request shape (e.g. `observations` missing or empty), never
because one bad item existed. `status` is either `"filed"` (written) or `"skipped"` with a
`"reason"`:

| `reason` | Meaning |
|---|---|
| `unresolved` | `variant_id` doesn't match any real, non-archived variant. |
| `id_mismatch` | `shopify_variant_id` was sent but doesn't match what we have on file — possible wrong-store data. |
| `bad_price` | `price_minor` wasn't a sane positive integer. |
| `bad_currency` | `currency` didn't match the brand's own currency. |

`changed: true` on a filed item means this reading actually differed from what we already
had (a real price or stock change) — that's also what can trigger a [watch](#watches) to
fire, if one exists on that variant.

## Watches

```bash
curl -X POST "https://api.ziffi.xyz/v1/watches" \
  -H "Content-Type: application/json" -H "X-Ziffi-Key: zk_live_your_key_here" \
  -d '{"variant_id": "018f0000-…", "condition": {"metric": "price", "op": "lte", "value": 99900}}'
```

A watch is a standing rule on **one specific variant** — "tell me when X happens to this
exact variant." v1 supports exactly two condition shapes, stated plainly rather than
silently accepting something that will never fire:

| `condition` | Fires when |
|---|---|
| `{"metric": "price", "op": "lte", "value": <minor units>}` | The price crosses at or below `value`. |
| `{"metric": "availability", "op": "eq", "value": "in_stock"}` | Stock goes from out-of-stock to in-stock. |

Any other shape is rejected up front with a clear `invalid_argument` error — never
silently accepted as a watch that will simply never fire.

`cooldown_hours` (default `6`, max `720`) debounces rapid flapping — a variant bouncing
in and out of stock only refires after the cooldown, not on every single crossing.

**A watch only fires on a NEW crossing after you created it.** If a variant is already
below your threshold at creation time, that's recorded as the starting state
(`last_state`), not treated as an immediate fire — you won't get spammed the moment you
subscribe to something already-true.

```jsonc
// POST /v1/watches response
{
  "data": {
    "id": "…", "scope_type": "variant", "variant_id": "018f0000-…",
    "condition": {"metric": "price", "op": "lte", "value": 99900},
    "status": "active", "last_state": {"price": 149900, "avail": "in_stock"},
    "last_fired_at": null, "cooldown_hours": 6, "expires_at": null, "created_at": "…"
  }, "tier": "live", "as_of": "…"
}
```

`GET /v1/watches` (paginated, `limit`/`offset`) lists your own watches; `DELETE
/v1/watches/{watch_id}` removes one. Watches are scoped to whoever's calling — an API key
or a logged-in session — you'll never see another caller's watches.

## Notifications

```bash
curl "https://api.ziffi.xyz/v1/notifications?unread_only=true" -H "X-Ziffi-Key: zk_live_your_key_here"
```

Pull-based: query for what your watches have fired. Each notification:

```jsonc
{
  "id": "…", "watch_id": "…", "kind": "price_drop",
  "entity_type": "variant", "entity_id": "018f0000-…",
  "payload": {"old_price": 149900, "new_price": 89900, "currency": "INR"},
  "created_at": "…", "read_at": null
}
```

`kind` is currently one of `price_drop` or `back_in_stock` — the two conditions watches
actually support today (see [Watches](#watches)). `unread_only` defaults to `true`; pass
`false` to see everything. Mark notifications read with `POST /v1/notifications/ack`,
body `{"ids": ["…", "…"]}` — returns `{"acked": <count>}`.

Every real-world crossing fires **at most once** — a retried or duplicated underlying
write can never double-notify for the same event (enforced at the database level, not
just in application logic).

## Webhooks

```bash
curl -X POST "https://api.ziffi.xyz/v1/webhooks" \
  -H "Content-Type: application/json" -H "X-Ziffi-Key: zk_live_your_key_here" \
  -d '{"url": "https://your-app.example.com/ziffi-webhook", "events": ["price_drop", "back_in_stock"]}'
```

Get notifications pushed to your own endpoint instead of polling `/v1/notifications`.
Requires an API key (not a bare logged-in session — a session has no stable per-app
identity to attribute deliveries to).

```jsonc
// response — "secret" is shown ONLY here, on creation, never again
{
  "data": {
    "id": "…", "url": "https://your-app.example.com/ziffi-webhook",
    "events": ["price_drop", "back_in_stock"], "status": "active",
    "failure_count": 0, "created_at": "…",
    "secret": "whsec_…"
  }, "tier": "live", "as_of": "…"
}
```

**Store the `secret` immediately — it is never shown again after this response.**

`events` accepts a subset of `price_drop`, `back_in_stock`, `new_drop`, `sale`,
`delivery_change`, `homepage_change`. **Only `price_drop` and `back_in_stock` actually
fire today** — the other four are accepted so your subscription doesn't need to change
later, but nothing currently triggers them; don't build logic today that assumes they'll
arrive. `events` defaults to `["price_drop", "back_in_stock", "new_drop", "sale"]` if
omitted.

### Delivery format

Each event is a signed `POST` to your `url`:

```jsonc
// body
{"event": "price_drop", "notification_id": "…", "entity_type": "variant",
 "entity_id": "018f0000-…", "data": {"old_price": 149900, "new_price": 89900}, "created_at": "…"}
```

Headers: `X-Ziffi-Signature: sha256=<hex>` and `X-Ziffi-Event: price_drop`. Verify the
signature before trusting the payload:

```python
import hashlib, hmac
expected = "sha256=" + hmac.new(secret.encode(), request_body_bytes, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Ziffi-Signature"]):
    reject()  # not really from Ziffi, or the body was tampered with
```

**Security requirements on `url`, enforced not just documented:** must be `https://`, and
must resolve to a public address — a private/loopback/link-local/internal IP is rejected
both when you register the webhook *and again at every delivery* (so an endpoint that
gets re-pointed to an internal address after registration is still blocked, not just
checked once). This protects our infrastructure, and incidentally means your endpoint
must be genuinely internet-reachable to receive anything.

A URL that fails 10 consecutive deliveries is auto-disabled (`status` changes, deliveries
stop) — fix the endpoint and re-register rather than expecting an indefinite retry queue.

`GET /v1/webhooks` lists your subscriptions (never re-includes `secret`). `DELETE
/v1/webhooks/{webhook_id}` removes one.
