# Changes feed > The machine-readable record of every data change in the last 30 days, the monthly archives, and how to poll them to keep a local copy current. URL: https://gpuninja.com/docs/api/changes Last updated: 2026-10-10 ## What it is Every change to GPU Ninja's data arrives as a reviewed pull request with a change record. `/api/v1/changes` publishes those records for the last 30 days, so you can see what changed without diffing the whole catalogue. Every month's records are also kept in a monthly archive (https://gpuninja.com/docs/api/changes#monthly-archives). ```bash curl -s https://gpuninja.com/api/v1/changes ``` ```json { "apiVersion": "v1", "kind": "changes", "asOf": "2026-10-04T09:30:12Z", "window": { "from": "2026-09-05", "to": "2026-10-04", "days": 30 }, "records": [ { "id": "2026-10-04-research", "date": "2026-10-04", "mergedAt": "2026-10-04T09:30:12Z", "origin": "research", "changes": [ { "op": "added", "rowOp": "insert", "correction": false, "rowKey": "a41c9e07f3b2d865", "entity": { "kind": "board", "slug": "asus-rog-astral-rtx-5080", "name": "ASUS ROG Astral GeForce RTX 5080 OC", "links": { "self": "https://gpuninja.com/api/v1/boards/asus-rog-astral-rtx-5080", "page": "https://gpuninja.com/boards/asus-rog-astral-rtx-5080", "text": "https://gpuninja.com/boards/asus-rog-astral-rtx-5080.txt" } }, "aspect": "record", "merchant": null, "currency": null, "result": null, "field": null, "from": null, "to": null, "values": { "slug": "asus-rog-astral-rtx-5080", "name": "ASUS ROG Astral GeForce RTX 5080 OC", "vramGb": 16, "lengthMm": 357.6, "slotWidth": 3.8 } }, { "op": "updated", "rowOp": "update", "correction": false, "rowKey": "3f9a0c71d2e48b56", "entity": { "kind": "board", "slug": "nvidia-rtx-5090-founders-edition", "name": "NVIDIA GeForce RTX 5090 Founders Edition", "links": { "self": "https://gpuninja.com/api/v1/boards/nvidia-rtx-5090-founders-edition", "page": "https://gpuninja.com/boards/nvidia-rtx-5090-founders-edition", "text": "https://gpuninja.com/boards/nvidia-rtx-5090-founders-edition.txt" } }, "aspect": "offer", "merchant": "Best Buy", "currency": "USD", "result": null, "field": "price", "from": 2099, "to": 1949, "values": null } ] } ], "archive": [ { "month": "2026-10", "records": 1, "url": "https://gpuninja.com/api/v1/changes/2026-10" } ], "links": { "self": "https://gpuninja.com/api/v1/changes", "page": null, "text": null } } ``` (Abridged: an added row's `values` holds every published field of the row.) ## Records | Property | Meaning | | ---------- | -------------------------------------------------------------------------------------------------- | | `id` | The record's identifier, `YYYY-MM-DD-name`. A daily research run's record is `YYYY-MM-DD-research` | | `date` | The date in the record's identifier: always its first ten characters | | `mergedAt` | When the change was merged into the published data, in UTC | | `origin` | `research` for the daily automated run, `manual` for a maintainer's pull request | | `changes` | The individual changes, in a fixed, reviewed order. It can be empty | Records are ordered by `mergedAt`, newest first, then by `id`. Beside `records`, the file's `archive` lists every month that has a merged record, newest first, each with its `month` (`YYYY-MM`), its number of `records` and the `url` of its monthly archive (https://gpuninja.com/docs/api/changes#monthly-archives). A record with `changes: []` is normal. It comes from a pull request that changed no catalogue record itself, such as a schema migration or an edit to provenance or price history alone, and it is kept so that your `mergedAt` cursor still moves past it. ## Changes | Property | Meaning | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `op` | The summary: `added`, `updated`, `removed`, `discontinued` or `corrected` | | `rowOp` | What was done to the stored row: `insert`, `update` or `delete` | | `correction` | `true` when the change fixes something that was wrong, rather than following a change in the world | | `rowKey` | 16 lower-case hex digits identifying the stored row. Every change to the same row has the same `rowKey`, in any record. It is opaque: only compare it | | `entity` | The affected entity's `kind`, its current `slug`, its `name` as the record gave it when it was written, and its `links`. `links` is `null` only when the entity has since been removed and no longer exists. Platforms, benchmarks, motherboards, risers and sockets have no web page, so their `links.page` and `links.text` are `null` | | `aspect` | `record` for the entity itself, `offer` for one of its merchant listings, `benchmark-result` for a measurement, `fit-override` for a maintainer's fit override | | `merchant` | The merchant, for offer changes; otherwise `null` | | `currency` | The offer's currency, on every offer change; otherwise `null`. An offer's currency never changes, so the entity, `merchant` and `currency` together identify the offer | | `result` | For a benchmark result: the `benchmark`, the `model` (or `null`), the `platform` and the `unit`, by name as the record gave them; otherwise `null` | | `field` | The API field that changed, such as `price`, `inStock` or `lengthMm`; `null` when something was added or removed, and for a fit override | | `from`, `to` | The old and new values, in the API's units; `null` when something was added or removed, and for a fit override | | `values` | For an added or removed row, the whole row by API field name and in the API's units; `null` for an update and a fit override | What each `op` means: - `added`: a new entity, offer or benchmark result. - `updated`: a field changed. Each changed field is its own entry. - `removed`: something was deleted. A discontinued chip, board, chassis, motherboard or riser is never deleted. - `discontinued`: a chip, board, chassis, motherboard or riser became discontinued and is now a frozen snapshot. - `corrected`: a fix to something that was wrong, as opposed to a change in the world. It is exactly the changes with `correction: true`, and `rowOp` says whether it is an addition, an update or a removal: `field`, `from` and `to` are set when it is an update. Every change to a frozen record appears this way. Changes from the daily research run are never `corrected`; a maintainer's hand edit to an existing value usually is, unless the maintainer marks it as a real-world change. Values are converted to API units, in `from` and `to` as in `values`, so a `price` is a decimal amount in the change's `currency`, exactly like an offer's `price`, and `inStock` is a boolean. Booleans are `true` and `false`, timestamps are UTC with a `Z`, and dates are `YYYY-MM-DD`. List-valued fields are given as text: PCI device IDs lower-case, sorted and joined with commas, and power connectors in the same form as `powerConnectors`. A board moving to a different chip is reported with `field: "chip"` and `from` and `to` both `null`, because internal identifiers are never published; re-fetch the board to see its new chip. The same holds for a motherboard's `socket` and a case's `bundledRiser`, and `values` leaves these links out. Slugs are followed through renames, so `entity.slug` is always the entity's current slug, even in an old record. A fit override being added, changed or removed is reported with `aspect: "fit-override"`, and `field`, `from`, `to` and `values` all `null`; the override's contents aren't published. Its `rowKey` stays the same across every change to that override. Fetch the board's `fits` document to see the current verdicts. A change to a motherboard's PCIe slots, whether a slot was added, changed or removed, is reported on the motherboard as `updated` (or `corrected`) with `aspect: "record"`, `field: "pcieSlots"`, and `from`, `to` and `values` all `null`. Re-fetch the motherboard to see its slots. ## The 30-day window The window is chosen by merge time. `window.to` is the UTC date of the newest `mergedAt`, and `window.from` is 29 days earlier, so the window covers 30 days inclusive. A record is included when the UTC date of its `mergedAt` falls in that range, whatever its own `date`: a record dated 40 days ago but merged yesterday is included. `asOf` is the newest `mergedAt`. With no records at all, `window` is `null` and `records` is empty. The file is computed from the records alone, never from the clock, so it doesn't slide forward on a day with no merges. Every operation is included, corrections too. The daily digest (https://gpuninja.com/docs/digest) chooses what to announce to readers; `changes.json` is the complete machine record of its window. ## Monthly archives Every UTC calendar month that has at least one merged record is published at `/api/v1/changes/{month}`, such as `/api/v1/changes/2026-10`, and listed in the `archive` of `/api/v1/changes`. A month with no records has no file. ```json { "apiVersion": "v1", "kind": "changes-archive", "month": "2026-10", "asOf": "2026-10-04T09:30:12Z", "records": [{ "id": "2026-10-04-research", "…": "a record, as in /api/v1/changes" }], "links": { "self": "https://gpuninja.com/api/v1/changes/2026-10", "page": null, "text": null } } ``` An archive holds every record whose `mergedAt` falls in that month, in the same form and order as `/api/v1/changes`, and at least one. Its `asOf` is the newest `mergedAt` in the month. The current month's archive grows as records merge; earlier months change only when an entity they name is renamed or removed, because `entity.slug` follows renames and `entity.links` becomes `null` for a removed entity. ## Keeping a local copy current 1. Download the entities you care about, and remember the newest `mergedAt` you have processed. 2. Poll `/api/v1/changes`. With the `ETag` from your last request in `If-None-Match`, an unchanged file costs a `304`. The file changes at most once per deploy, so polling more often than every five minutes gains nothing. 3. Process every record whose `mergedAt` is later than your cursor, oldest first, and re-fetch the entities they name. Then move your cursor to the newest `mergedAt`. 4. If your cursor is older than `window.from`, you have been away for longer than the window and may have missed changes. Re-fetch everything you hold. The monthly archives tell you what changed while you were away, but re-fetching is the only way to be sure your copy is current. Use `mergedAt` as the cursor, not `date` or `id`. A record can merge days after the date in its identifier, and the window is chosen by merge time precisely so that a `mergedAt` cursor never skips it.