Changes feed
Last updated
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.
curl -s https://gpuninja.com/api/v1/changes{
"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.
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 withcorrection: true, androwOpsays whether it is an addition, an update or a removal:field,fromandtoare set when it is an update. Every change to a frozen record appears this way. Changes from the daily research run are nevercorrected; 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 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.
{
"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#
- Download the entities you care about, and remember the newest
mergedAtyou have processed. - Poll
/api/v1/changes. With theETagfrom your last request inIf-None-Match, an unchanged file costs a304. The file changes at most once per deploy, so polling more often than every five minutes gains nothing. - Process every record whose
mergedAtis later than your cursor, oldest first, and re-fetch the entities they name. Then move your cursor to the newestmergedAt. - 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.