# Content

Resource class for the GunSpec content feeds. Instantiated internally by the GunSpec client and exposed as `client.content`.

Source: https://docs.gunspec.io/en/sdk/content

## client.content.listChangelog()

`GET /v1/changelog`

Auth: Optional API key  
Tier: Explorer+

List published changelog entries, newest first.

### 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) |
| `category` | string | no | Filter by entry category |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | ChangelogEntry[] | One page of results |
| `data[].id` | string | Slug of the entry, taken from the file it was authored in. |
| `data[].title` | string | Entry headline. |
| `data[].body` | string | Entry body, in Markdown. |
| `data[].category` | string \| null | What kind of change: `feature`, `improvement`, `bugfix`, `data`, `breaking`. |
| `data[].publishedAt` | string | When it was published, ISO 8601. |
| `data[].published` | integer | Integer flag, `1` when the entry is live. The public feed only returns published entries, so this is `1` on every row a consumer sees. |
| `data[].createdAt` | string | When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC. |
| `data[].updatedAt` | string | When it last changed, ISO 8601. |
| `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": [
    {
      "id": "043-webhooks-that-actually-fire",
      "title": "Ammunition ballistics endpoints",
      "body": "string",
      "category": "feature",
      "publishedAt": "2026-08-01T00:00:00.000Z",
      "published": 1,
      "createdAt": "2026-09-10T03:57:09.092Z",
      "updatedAt": "2026-09-10T08:31:20.586Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 42,
    "totalPages": 3
  }
}
```

### 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/changelog' \
  --header 'X-API-Key: your_key'
```

## client.content.getChangelogEntry()

`GET /v1/changelog/{id}`

Auth: Optional API key  
Tier: Explorer+

Get a single changelog entry.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Changelog entry id |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | ChangelogEntry | The response payload |
| `data.id` | string | Slug of the entry, taken from the file it was authored in. |
| `data.title` | string | Entry headline. |
| `data.body` | string | Entry body, in Markdown. |
| `data.category` | string \| null | What kind of change: `feature`, `improvement`, `bugfix`, `data`, `breaking`. |
| `data.publishedAt` | string | When it was published, ISO 8601. |
| `data.published` | integer | Integer flag, `1` when the entry is live. The public feed only returns published entries, so this is `1` on every row a consumer sees. |
| `data.createdAt` | string | When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC. |
| `data.updatedAt` | string | When it last changed, ISO 8601. |

```json
{
  "success": true,
  "data": {
    "id": "043-webhooks-that-actually-fire",
    "title": "Ammunition ballistics endpoints",
    "body": "string",
    "category": "feature",
    "publishedAt": "2026-08-01T00:00:00.000Z",
    "published": 1,
    "createdAt": "2026-09-10T03:57:09.092Z",
    "updatedAt": "2026-09-10T08:31:20.586Z"
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/changelog/039-3d-models-line-art-and-a-phantom-firearm' \
  --header 'X-API-Key: your_key'
```

## client.content.listChanges()

`GET /v1/changes`

Auth: API key required  
Tier: Explorer+

List changes to the catalogue: records added, updated, removed and merged, with the fields that moved. Requires an API key.

### 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'
```

## client.content.listBlogPosts()

`GET /v1/blog`

Auth: Optional API key  
Tier: Explorer+

List published blog posts, newest first.

### 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) |
| `category` | string | no | Filter by post category |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | BlogPost[] | A published post. Both endpoints serve the stored row; `body` is markdown. |
| `data[].id` | string | Opaque post id. A string, not a number: posts are keyed like every other authored record here. |
| `data[].slug` | string | URL-safe slug, and what `/v1/blog/{slug}` addresses. |
| `data[].title` | string | Post headline. |
| `data[].summary` | string \| null | One-line description, for a card or a feed. Null where the author wrote none. |
| `data[].body` | string \| null | The post itself, as markdown. |
| `data[].heroImage` | string \| null | Path of the hero image on the assets CDN, or null. Prefix it with the assets host to fetch. |
| `data[].heroAlt` | string \| null | What the hero image shows, as alt text. Null where the post has no hero or none was written. |
| `data[].category` | string \| null | Editorial category, e.g. `guides`. Null where the post has none. |
| `data[].status` | string | Always `published` on these endpoints: a draft is not served publicly. |
| `data[].postedAt` | string \| null | When the post was published, which is what the list orders by. Null on a post never given a date. |
| `data[].createdAt` | string | When the post was first written, `YYYY-MM-DD HH:MM:SS` in UTC. Not the publication date: `postedAt` is. |
| `data[].updatedAt` | string | When the post was last edited, `YYYY-MM-DD HH:MM:SS` in UTC. |
| `data[].author` | object \| null | Who wrote the post: the display name on the account that created it, never its address. Null when that account has no display name, and the post is then attributed to the publisher. |
| `data[].author.name` | string | The author's display name, as set on their GunSpec account. |
| `data[].author.handle` | string | URL segment of the author's page on the blog, made from their display name. |
| `data[].author.avatarStyle` | string | Which avatar style the author chose on their account. |
| `data[].author.avatarSeed` | string | Seed the avatar is drawn from. Stable per author, and not their account id. |
| `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": [
    {
      "id": "post_01hbar",
      "slug": "complete-guide-firearm-calibers-for-developers",
      "title": "What is a firearms specification API",
      "summary": "What the catalog holds, and how to call it.",
      "body": null,
      "heroImage": "guides/what-is-a-firearms-api.webp",
      "heroAlt": "Studio render of the AK-47, left side",
      "category": "guides",
      "status": "published",
      "postedAt": "2026-08-01T00:00:00.000Z",
      "createdAt": "2026-08-01 09:12:44",
      "updatedAt": "2026-08-02 10:01:07",
      "author": {
        "name": "Sacha Roussakis-Notter",
        "handle": "sacha-roussakis-notter",
        "avatarStyle": "bottts",
        "avatarSeed": "9f2c4a1b7e30d5c6"
      }
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 42,
    "totalPages": 3
  }
}
```

### 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/blog' \
  --header 'X-API-Key: your_key'
```

## client.content.getBlogPost()

`GET /v1/blog/{slug}`

Auth: Optional API key  
Tier: Explorer+

Get a single blog post, including its full body.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | string | yes | Blog post slug |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.post` | BlogPost | A published post. Both endpoints serve the stored row; `body` is markdown. |
| `data.post.id` | string | Opaque post id. A string, not a number: posts are keyed like every other authored record here. |
| `data.post.slug` | string | URL-safe slug, and what `/v1/blog/{slug}` addresses. |
| `data.post.title` | string | Post headline. |
| `data.post.summary` | string \| null | One-line description, for a card or a feed. Null where the author wrote none. |
| `data.post.body` | string \| null | The post itself, as markdown. |
| `data.post.heroImage` | string \| null | Path of the hero image on the assets CDN, or null. Prefix it with the assets host to fetch. |
| `data.post.heroAlt` | string \| null | What the hero image shows, as alt text. Null where the post has no hero or none was written. |
| `data.post.category` | string \| null | Editorial category, e.g. `guides`. Null where the post has none. |
| `data.post.status` | string | Always `published` on these endpoints: a draft is not served publicly. |
| `data.post.postedAt` | string \| null | When the post was published, which is what the list orders by. Null on a post never given a date. |
| `data.post.createdAt` | string | When the post was first written, `YYYY-MM-DD HH:MM:SS` in UTC. Not the publication date: `postedAt` is. |
| `data.post.updatedAt` | string | When the post was last edited, `YYYY-MM-DD HH:MM:SS` in UTC. |

```json
{
  "success": true,
  "data": {
    "post": {
      "id": "post_01hbar",
      "slug": "complete-guide-firearm-calibers-for-developers",
      "title": "What is a firearms specification API",
      "summary": "What the catalog holds, and how to call it.",
      "body": null,
      "heroImage": "guides/what-is-a-firearms-api.webp",
      "heroAlt": "Studio render of the AK-47, left side",
      "category": "guides",
      "status": "published",
      "postedAt": "2026-08-01T00:00:00.000Z",
      "createdAt": "2026-08-01 09:12:44",
      "updatedAt": "2026-08-02 10:01:07",
      "author": {
        "name": "Sacha Roussakis-Notter",
        "handle": "sacha-roussakis-notter",
        "avatarStyle": "bottts",
        "avatarSeed": "9f2c4a1b7e30d5c6"
      }
    }
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/blog/complete-guide-firearm-calibers-for-developers' \
  --header 'X-API-Key: your_key'
```

## client.content.listNotices()

`GET /v1/notices`

Auth: Optional API key  
Tier: Explorer+

The notices the website is showing right now, best first. Public, cached for a minute at the edge. An empty list means no banner.

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.notices` | SiteNotice[] | Published notices inside their display window, best first: highest priority, then newest. |
| `data.notices[].id` | string | Row id of the notice. The website keys its dismissal on this, so a new notice is seen even by a reader who dismissed the last one. |
| `data.notices[].label` | string | Short label shown before the message. |
| `data.notices[].highlight` | string | The sentence shown in the banner. |
| `data.notices[].detail` | string | A second line, shown under the highlight where one is set. |
| `data.notices[].variant` | NoticeVariant | How prominently a site notice is shown. |
| `data.notices[].ctaLabel` | string \| null | Text of the call to action, or null when the banner has none. |
| `data.notices[].ctaUrl` | string \| null | Where the call to action goes. |
| `data.notices[].priority` | integer | Higher sorts first. Ties break on newest. |
| `data.notices[].startsAt` | string \| null | When the notice becomes visible. Null means immediately. |
| `data.notices[].endsAt` | string \| null | When it stops being shown. This is why notices are rows rather than copy: an end date is the only version of "take this down later" that does not rely on somebody remembering. |

```json
{
  "success": true,
  "data": {
    "notices": [
      {
        "id": "silhouettes-2026-05",
        "label": "Notice",
        "highlight": "Scheduled maintenance on Sunday.",
        "detail": "The API stays up; the website may be briefly unavailable.",
        "variant": "info",
        "ctaLabel": "Read the changelog",
        "ctaUrl": "/changelog",
        "priority": 0,
        "startsAt": "2026-05-01T00:00:00.000Z",
        "endsAt": "2026-09-30T00:00:00.000Z"
      }
    ]
  }
}
```

### 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/notices' \
  --header 'X-API-Key: your_key'
```
