Skip to content
GPU Ninja

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#

PropertyMeaning
idThe record's identifier, YYYY-MM-DD-name. A daily research run's record is YYYY-MM-DD-research
dateThe date in the record's identifier: always its first ten characters
mergedAtWhen the change was merged into the published data, in UTC
originresearch for the daily automated run, manual for a maintainer's pull request
changesThe 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#

PropertyMeaning
opThe summary: added, updated, removed, discontinued or corrected
rowOpWhat was done to the stored row: insert, update or delete
correctiontrue when the change fixes something that was wrong, rather than following a change in the world
rowKey16 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
entityThe 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
aspectrecord for the entity itself, offer for one of its merchant listings, benchmark-result for a measurement, fit-override for a maintainer's fit override
merchantThe merchant, for offer changes; otherwise null
currencyThe 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
resultFor a benchmark result: the benchmark, the model (or null), the platform and the unit, by name as the record gave them; otherwise null
fieldThe API field that changed, such as price, inStock or lengthMm; null when something was added or removed, and for a fit override
from, toThe old and new values, in the API's units; null when something was added or removed, and for a fit override
valuesFor 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 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#

  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.