# Changes

What changed in the catalogue: records added, updated, removed and merged, with the fields that moved, as a list to read forward from and as RSS

Source: https://docs.gunspec.io/en/api/changes

## List changes to the catalogue

`GET /v1/changes`

Auth: API key required  
Tier: Explorer+

Returns what changed in the catalogue, newest first: records added, updated, removed and merged, with the fields that moved and their values before and after. Entries are written within about a quarter of an hour of a change. To keep a copy current, store the highest `seq` you have read and ask again with `since` set to it and `order=asc`. A firearm field your plan does not include is listed as changed with `withheld: true` and no values. Each entry names the quality tasks behind it (`tasks`), with what the check found and what the research concluded. Entries are kept for 180 days. The same feed is available as RSS at `/v1/changes.rss`, and `/v1/me/changes` narrows it to the firearms your account has saved.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `since` | string | no | Entries on or after this ISO date or UTC instant, or after this `seq` |
| `until` | string | no | Entries on or before this ISO date (the whole day) or UTC instant |
| `entity` | string | no | Only changes to this kind of record |
| `event` | string | no | Only this kind of change |
| `manufacturer` | string | no | Only changes to this manufacturer's firearms, or to the manufacturer itself |
| `order` | string | no | Newest first, or oldest first for reading forward from a `seq` |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One change to the catalogue. |
| `data[].seq` | integer | The entry's position in the feed. Increases with every entry; store the highest you have read and pass it as `since` to continue from there. |
| `data[].entity` | string | What kind of record changed. `catalog` is a `resynced` entry, which belongs to no one record. |
| `data[].id` | string \| null | The record's id, for `GET /v1/{entity}s/{id}`. Null on a `resynced` entry. |
| `data[].event` | string | `created`, `updated`, `deleted`, `merged` (folded into the record named by `mergedInto`), or `resynced`: too many records changed at once to list, so re-read what you hold. |
| `data[].at` | string | When the change was recorded, ISO 8601 UTC. |
| `data[].version` | string \| null | The record's `version` after the change. Null for a removal. |
| `data[].mergedInto` | string \| null | For `merged`, the id of the record it was folded into. An old id also answers with that record. |
| `data[].name` | string \| null | The record's name when it changed, kept after it is removed. |
| `data[].manufacturer` | object \| null | The maker of a firearm, or the manufacturer itself, when it changed. |
| `data[].manufacturer.id` | string | Manufacturer id. |
| `data[].manufacturer.name` | string \| null | Manufacturer name. |
| `data[].fields` | object[] | For `updated`, the fields that moved. Null when the change happened before an earlier version of the record had been kept, so it is known to have changed and no more. |
| `data[].fields[].field` | string | The field as the record names it; a nested field is a dotted path. |
| `data[].fields[].before` | unknown | The value before the change. Null when the field was empty. |
| `data[].fields[].after` | unknown | The value after the change. Null when the field was emptied. |
| `data[].fields[].truncated` | boolean | Present and true when a long value was cut to its start; the record holds the rest. |
| `data[].fields[].withheld` | boolean | Present and true when your plan does not include this field: it moved, and `before` and `after` are null. |
| `data[].changedCount` | integer \| null | For `resynced`, how many records the rewrite touched. |
| `data[].images` | object[] | A firearm's current pictures, for showing the entry. Empty for a removed record and for other kinds of record. |
| `data[].images[].id` | integer | Image id. |
| `data[].images[].url` | string | Where to fetch it. |
| `data[].images[].kind` | string | What kind of picture it is. |
| `data[].images[].alt` | string \| null | Alternative text. |
| `data[].images[].width` | integer \| null | Width in pixels. |
| `data[].images[].height` | integer \| null | Height in pixels. |
| `data[].tasks` | object[] | The quality tasks behind the change, at most three, newest first: the task on the same record and field whose fix or accepted answer closed it, up to two days after or sixty days before the change. Empty when none is linked. |
| `data[].tasks[].number` | integer \| null | The task's number, for `GET /v1/data/tasks/{id}`. |
| `data[].tasks[].key` | string | The task's key. |
| `data[].tasks[].kind` | string | What kind of task it is. |
| `data[].tasks[].check` | string \| null | The quality check that raised it. |
| `data[].tasks[].title` | string | The check's name, readable. |
| `data[].tasks[].why` | string \| null | What the check found on this record, in one sentence. |
| `data[].tasks[].outcome` | string | `fixed` where the record was corrected so the check no longer finds it; otherwise how an accepted answer ended. |
| `data[].tasks[].closedAt` | string \| null | When the task closed. |
| `data[].tasks[].finding` | object \| null | What the research found: the value a source states and the page, or why the value was removed. Null when nothing was recorded, or when the task is withheld. |
| `data[].tasks[].withheld` | boolean | Present and true when every field the task concerns is above your plan: `finding` is null and `why` is the check's rule. |
| `data[].tasks[].url` | string | The task's public page. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "seq": 48213,
      "entity": "firearm",
      "id": "glock-19-gen5",
      "event": "updated",
      "at": "2026-10-20T03:15:00Z",
      "version": "8c1f3a90d24b",
      "mergedInto": null,
      "name": "Glock 19 Gen5",
      "manufacturer": {
        "id": "glock",
        "name": "Glock"
      },
      "fields": [
        {
          "field": "barrelLengthMm",
          "before": 102,
          "after": 106,
          "truncated": true,
          "withheld": true
        }
      ],
      "changedCount": null,
      "images": [
        {
          "id": 812,
          "url": "https://api.gunspec.io/v1/firearms/glock-19-gen5/images/812",
          "kind": "render",
          "alt": "Glock 19 Gen5, left side",
          "width": 1600,
          "height": 900
        }
      ],
      "tasks": [
        {
          "number": 13325,
          "key": "contradiction:quality.firearm-loaded-weight-fits-empty:a-762",
          "kind": "contradiction",
          "check": "firearm-loaded-weight-fits-empty",
          "title": "Firearm loaded weight fits empty",
          "why": "Empty 3.60 kg, loaded 4.00 kg, a difference of 400 g; 30 rounds and a magazine should add 466 to 1065 g.",
          "outcome": "fixed",
          "closedAt": "2026-10-20T21:05:20Z",
          "finding": {
            "verdict": "removed",
            "summary": "Neither page gives a loaded weight, so the record's 4000 g is unsourced. Loaded is nulled.",
            "value": null,
            "source": {
              "url": "https://example.com/spec",
              "quote": null
            },
            "agent": null
          },
          "withheld": true,
          "url": "https://docs.gunspec.io/en/quality/tasks/13325"
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 42,
    "totalPages": 3
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/changes?since=2026-10-01' \
  --header 'X-API-Key: your_key'
```

## Changes to the catalogue as RSS

`GET /v1/changes.rss`

Auth: Optional API key  
Tier: Explorer+

The newest 50 changes to the catalogue as an RSS 2.0 document, for a feed reader. No key is needed, so a firearm's fields above the free plan are named as changed without their values. Each item links to the record's page on the website.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `entity` | string | no | Only changes to this kind of record |
| `event` | string | no | Only this kind of change |
| `manufacturer` | string | no | Only changes to this manufacturer's firearms, or to the manufacturer itself |

### Response 200 (application/rss+xml)

```json
"string"
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/changes.rss' \
  --header 'X-API-Key: your_key'
```
