# PG Pulse API

A free, open, read-only API for everything on [PG Pulse](https://princegeorgepulse.ca/), the live local guide to Prince George, British Columbia, Canada. No key, no sign-up, no cost. CORS is open, so you can call it straight from a browser.

- Base URL: `https://princegeorgepulse.ca/api/v1`
- 279 endpoints in 37 groups, all `GET`
- OpenAPI 3.1: [https://princegeorgepulse.ca/api/v1/openapi.json](https://princegeorgepulse.ca/api/v1/openapi.json)
- Every docs page is also Markdown: [https://princegeorgepulse.ca/developers/index.md](https://princegeorgepulse.ca/developers/index.md)
- For AI agents: [https://princegeorgepulse.ca/llms.txt](https://princegeorgepulse.ca/llms.txt) and [https://princegeorgepulse.ca/llms-full.txt](https://princegeorgepulse.ca/llms-full.txt)

## Quickstart

```bash
curl --fail-with-body "https://princegeorgepulse.ca/api/v1/pages?limit=5"
curl --fail-with-body "https://princegeorgepulse.ca/api/v1/search?q=parks&per=3"
```

```js
const response = await fetch('https://princegeorgepulse.ca/api/v1/pages?limit=5', { signal: AbortSignal.timeout(15000) })
if (!response.ok) throw new Error(`API returned HTTP ${response.status}`)
const { data, meta } = await response.json()
console.log(data, 'Source:', meta.attribution)
```

More in the [quickstart](https://princegeorgepulse.ca/developers/quickstart/).

## Assistant

Ask a local question from your application with **POST https://princegeorgepulse.ca/api/chat**. This is a separate, rate-limited AI endpoint, with JSON responses by default and no saved conversation unless you request one. It supports server-to-server calls; browser requests must come from our own site. No API key is needed.

```bash
curl 'https://princegeorgepulse.ca/api/chat' -H 'Content-Type: application/json' --data '{"question":"What’s on this weekend?","store":false}'
```

The response contains **answer** (Markdown), **links** (title and URL), and **tools** (source lookups used). Treat the answer as guidance and check the linked sources. To continue, send **messages** with user/assistant history ending in a user question, or request **store: true** and reuse the returned **thread** id with **store: true**. Thread ids grant access to the conversation: keep them private. Never send sensitive information.

For streaming, send **Accept: text/event-stream**. Handle **text**, **retract** (clear earlier text), **links**, **status**, **thread**, **done** and **error**. A stream ending without **done** is incomplete. Limits are shared with the website chat; honour **Retry-After** on 429/503 and do not retry in a tight loop. See [current availability and limits](https://princegeorgepulse.ca/api/chat) and the [chat OpenAPI contract](https://princegeorgepulse.ca/api/chat/openapi.json).


## Response format

### Connect an agent

[Connect your agent](https://princegeorgepulse.ca/developers/mcp/) with one-click links, copyable configurations and troubleshooting. Use **https://princegeorgepulse.ca/mcp** as a Streamable HTTP MCP server. It covers **every enabled public API endpoint**, including custom city modules and OpenAPI. Focused tools for search, the directory, events, news, jobs, conditions, weather, address zoning, garbage day and transit are convenient starting points; the server also offers a usage-guide and API-catalogue resource and two prompts (local briefing, address report). For other topics, use **discover_api** to find an endpoint, **describe_api** for its parameters, and **query_api** to read it. **batch_api** combines up to four independent queries, with two running at a time and duplicate queries sharing work. Query filters and sparse fields keep responses small. Large answers return explicit partial-view notices, JSON Pointers and continuation instructions; follow these before assuming a result is complete. No model is called. Tool availability follows enabled city modules. Requests and distinct data lookups share the public API rate limit; respect Retry-After. Server-to-server clients need no key. Cross-site browser requests and persistent subscriptions are not supported. Cite the returned sources and use local listing URLs.

Every JSON response has the same envelope:

```json
{
  "data": [ ... ],
  "meta": {
    "source": "Who the data comes from",
    "sourceUrl": "https://…",
    "updatedAt": "2026-09-30T21:00:00.000Z",
    "license": "The licence the data is under",
    "attribution": "The credit line to show",
    "total": 129, "count": 50, "limit": 50, "offset": 0
  },
  "links": { "self": "/api/v1/…", "next": "/api/v1/…&offset=50", "docs": "/developers/reference/…" }
}
```

`meta.stale: true` means the upstream source did not answer and you are getting the last good copy. `updatedAt` is when we last fetched or built the data.

## Dates and times

Dates are `YYYY-MM-DD`. Every date-time is ISO 8601 with a UTC offset: `Z` for moments we record (`updatedAt`, `firstSeen`), the local offset (such as `-07:00`) for local times like event starts, forecast hours, sunrise and meetings. Parse them as offsets, never as floating local times.

## Pagination

List endpoints take `limit` and `offset`. Follow `links.next` (also sent as an HTTP `Link: <…>; rel="next"` header) until it is `null`. `meta.total` is the number of matching items.

## Filtering, search and fields

- Most lists take `q` (text search, every word must match) plus their own filters, listed on each endpoint.
- `fields=slug,name,lat,lng` keeps only those keys in each item (dot paths work through nested objects and arrays, such as `hourly.temp`).
- Location filters use `near=lat,lng` (and `radius` in km where offered), for example `/businesses?near=53.9171,-122.7497&radius=1`.

## Output formats

| `format` | Content type | Where |
| --- | --- | --- |
| `json` (default) | application/json | everywhere; add `pretty` for indented output |
| `md` | text/markdown | everywhere; also `Accept: text/markdown` |
| `csv` | text/csv | list endpoints |
| `geojson` | application/geo+json | map endpoints (neighbourhoods, map layers, crime points, transit shapes, garbage zones) |

## Caching and ETags

Responses carry `Cache-Control`, a weak `ETag` and, when the data has a timestamp, `Last-Modified` (the same moment as `meta.updatedAt`). Send the ETag back as `If-None-Match` and you get `304 Not Modified` with no body when the data has not changed. Revalidate with the ETag rather than `If-Modified-Since`: many lists also depend on the current time (upcoming events, next pickup day). Live sources are cached on our server for minutes (weather, outages, transit) to hours (maps, census), so polling faster than an endpoint's cache time gains nothing. Responses are compressed (brotli or gzip) when you send `Accept-Encoding`.

## Rate limits

120 requests per 60 seconds per IP address. Every response includes `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). Over the limit you get `429 Too Many Requests` with a `Retry-After` header. Need more? Cache responses, use ETags, or email hello@princegeorgepulse.ca.

## Errors

Errors use HTTP status codes and a JSON body:

```json
{ "error": { "status": 404, "code": "not_found", "message": "Business \"nope\" not found" }, "links": { "docs": "/developers/" } }
```

Codes: `bad_request` (400), `not_found` (404), `method_not_allowed` (405), `sync_expired` (410, a `/changes` cursor older than the sync window: start a full sync), `rate_limited` (429), `upstream_unavailable` (503, retry later). Branch on `code`, not on `message`. Unknown paths answer 404 with `didYouMean` suggestions. When an upstream fails we serve the last good copy with `meta.stale: true` instead of an error where we can.

## Versioning

`/api/v1` only grows: new endpoints, fields and parameters can appear at any time, so ignore fields you do not know. Nothing is removed, renamed or changed in meaning within v1; a breaking change would ship as `/api/v2` with at least 90 days of overlap and a note in the [changelog](https://princegeorgepulse.ca/developers/changelog/). Current version: **1.1.0** (`info.version` in OpenAPI).

## Discovery, status and tools

- OpenAPI 3.1: [`https://princegeorgepulse.ca/api/v1/openapi.json`](https://princegeorgepulse.ca/api/v1/openapi.json). Import it into Insomnia, Bruno, Hoppscotch or any OpenAPI generator.
- Postman collection (v2.1): [`https://princegeorgepulse.ca/developers/postman.json`](https://princegeorgepulse.ca/developers/postman.json), one folder per endpoint group with working examples.
- API catalog (RFC 9727): [`https://princegeorgepulse.ca/.well-known/api-catalog`](https://princegeorgepulse.ca/.well-known/api-catalog) links the OpenAPI, these docs and the status endpoint; MCP server card: [`https://princegeorgepulse.ca/.well-known/mcp/server-card.json`](https://princegeorgepulse.ca/.well-known/mcp/server-card.json).
- Status: [`https://princegeorgepulse.ca/api/health`](https://princegeorgepulse.ca/api/health) returns HTTP 200 with `"ok": true` while the site and its database are up and 503 when not; `?strict=1` also turns 503 on stale data or repeated job failures. It sits outside the `/api/v1` rate limit and is safe to poll every minute.
- Security issues: see [`/.well-known/security.txt`](https://princegeorgepulse.ca/.well-known/security.txt).

## Attribution and licences

The API is free for any use, including commercial. Please credit **PG Pulse (princegeorgepulse.ca)** and the original source named in each response's `meta.attribution`. PG Pulse's own content and compiled data are [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) with credit to PG Pulse. Third-party data stays under its source's licence (`meta.license`). News items are headlines and links only: follow `url` to read the story on the publisher's site. Details: [attribution and licences](https://princegeorgepulse.ca/developers/licensing/).

## Markdown for every page

Every page on PG Pulse has a Markdown version with its data attached:

- Add `.md` to the path: `https://princegeorgepulse.ca/about.md`, `https://princegeorgepulse.ca/index.md`
- Or prefix `/md`: `https://princegeorgepulse.ca/md/about/`
- Or send `Accept: text/markdown` to the normal URL

Pages also advertise it with `<link rel="alternate" type="text/markdown">`.

## Endpoint groups

- [About the API](https://princegeorgepulse.ca/developers/reference/about-the-api/) (5): `/`, `/meta`, `/pages`, `/search`, …
- [Directory](https://princegeorgepulse.ca/developers/reference/directory/) (12): `/businesses`, `/businesses/changes`, `/businesses/new`, `/businesses/{slug}`, …
- [Neighbourhoods](https://princegeorgepulse.ca/developers/reference/neighbourhoods/) (7): `/neighbourhoods`, `/neighbourhoods/geojson`, `/neighbourhoods/lookup`, `/neighbourhoods/aliases`, …
- [Food & nightlife](https://princegeorgepulse.ca/developers/reference/food-and-nightlife/) (9): `/food/places`, `/food/cuisines`, `/food/late-night`, `/nightlife`, …
- [Events](https://princegeorgepulse.ca/developers/reference/events/) (5): `/events`, `/events/months`, `/events/categories`, `/events/sources`, …
- [Sports](https://princegeorgepulse.ca/developers/reference/sports/) (8): `/sports/teams`, `/sports/teams/{id}`, `/sports/games`, `/sports/standings`, …
- [Local artists](https://princegeorgepulse.ca/developers/reference/local-artists/) (6): `/artists`, `/artists/taxonomy`, `/artists/{slug}`, `/djs`, …
- [Awards](https://princegeorgepulse.ca/developers/reference/awards/) (4): `/awards/seasons`, `/awards/seasons/{season}`, `/awards/seasons/{season}/{category}`, `/awards/honours`
- [News & stories](https://princegeorgepulse.ca/developers/reference/news-and-stories/) (12): `/news`, `/news/archive`, `/news/archive/{year}`, `/news/archive/{year}/{month}`, …
- [Guides](https://princegeorgepulse.ca/developers/reference/guides/) (15): `/guides/sections`, `/guides`, `/guides/{section}/{slug}`, `/resources`, …
- [Live conditions](https://princegeorgepulse.ca/developers/reference/live-conditions/) (6): `/now`, `/home`, `/happening`, `/happening/ended`, …
- [Weather](https://princegeorgepulse.ca/developers/reference/weather/) (14): `/weather`, `/weather/now`, `/weather/official`, `/weather/observations`, …
- [Climate](https://princegeorgepulse.ca/developers/reference/climate/) (4): `/climate`, `/climate/this-day`, `/climate/normals`, `/climate/records`
- [Air & water](https://princegeorgepulse.ca/developers/reference/air-and-water/) (8): `/air`, `/rivers`, `/rivers/stations`, `/rivers/history`, …
- [Hazards](https://princegeorgepulse.ca/developers/reference/hazards/) (8): `/wildfire`, `/earthquakes`, `/roads`, `/aurora`, …
- [Power](https://princegeorgepulse.ca/developers/reference/power/) (3): `/power/outages`, `/power/rates`, `/power/outages/history`
- [Safety](https://princegeorgepulse.ca/developers/reference/safety/) (4): `/crime`, `/crime/points`, `/crime/occurrences`, `/crime/severity`
- [People & economy](https://princegeorgepulse.ca/developers/reference/people-and-economy/) (13): `/markets`, `/people`, `/census`, `/economy`, …
- [Jobs](https://princegeorgepulse.ca/developers/reference/jobs/) (8): `/jobs`, `/jobs/categories`, `/jobs/employers`, `/jobs/top-employers`, …
- [City data](https://princegeorgepulse.ca/developers/reference/city-data/) (20): `/permits`, `/bylaws`, `/bylaws/stats`, `/bylaws/{number}`, …
- [Civic](https://princegeorgepulse.ca/developers/reference/civic/) (9): `/council/votes`, `/council/votes/summary`, `/civic`, `/civic/representatives`, …
- [Indigenous Prince George](https://princegeorgepulse.ca/developers/reference/indigenous-prince-george/) (1): `/indigenous`
- [Maps](https://princegeorgepulse.ca/developers/reference/maps/) (2): `/maps/layers`, `/maps/layers/{id}`
- [Transit](https://princegeorgepulse.ca/developers/reference/transit/) (10): `/transit/routes`, `/transit/stops`, `/transit/stops/{id}/departures`, `/transit/shapes`, …
- [Garbage & recycling](https://princegeorgepulse.ca/developers/reference/garbage-and-recycling/) (5): `/garbage/lookup`, `/garbage/suggest`, `/garbage/schedule`, `/garbage/zones`, …
- [Datasets](https://princegeorgepulse.ca/developers/reference/datasets/) (6): `/datasets`, `/datasets/{id}`, `/datasets/{id}/records`, `/datasets/{id}/series`, …
- [History](https://princegeorgepulse.ca/developers/reference/history/) (3): `/history/series`, `/history`, `/live/sources`
- [Key services](https://princegeorgepulse.ca/developers/reference/key-services/) (6): `/services/places`, `/services/places/{id}`, `/services/needs`, `/services/sources`, …
- [Emergency](https://princegeorgepulse.ca/developers/reference/emergency/) (8): `/emergency/contacts`, `/emergency/care`, `/emergency/prepare`, `/emergency/alerts`, …
- [Taxes & fees](https://princegeorgepulse.ca/developers/reference/taxes-and-fees/) (2): `/taxes`, `/taxes/estimate`
- [Getting around](https://princegeorgepulse.ca/developers/reference/getting-around/) (10): `/airport/info`, `/intercity`, `/taxis`, `/roads/highways`, …
- [Advertising](https://princegeorgepulse.ca/developers/reference/advertising/) (2): `/ads`, `/ads/featured-listings`
- [Community](https://princegeorgepulse.ca/developers/reference/community/) (19): `/community`, `/community/worship`, `/community/charities`, `/community/child-care`, …
- [Living](https://princegeorgepulse.ca/developers/reference/living/) (4): `/costs/household`, `/costs/rates`, `/internet/providers`, `/internet/neighbourhoods`
- [City](https://princegeorgepulse.ca/developers/reference/city/) (3): `/holidays`, `/holidays/next`, `/holidays/calendar`
- [Outdoors](https://princegeorgepulse.ca/developers/reference/outdoors/) (10): `/fishing`, `/fishing/species`, `/fishing/guide`, `/fishing/{slug}`, …
- [City spending](https://princegeorgepulse.ca/developers/reference/city-spending/) (8): `/spending`, `/spending/suppliers`, `/spending/grants`, `/spending/contracts`, …

Every endpoint by group: [https://princegeorgepulse.ca/developers/reference/](https://princegeorgepulse.ca/developers/reference/) ([Markdown](https://princegeorgepulse.ca/developers/reference.md)).

## Changelog

- **1.1.0** (2026-10-05): New endpoints: `/events/months` and a `month=YYYY-MM` filter on `/events` (the site’s month pages); `/news/archive/{year}`; `/zoning/addresses` (civic address suggestions) and `/zoning/small-scale-housing`. `/maps/layers/{id}` takes `bbox` and `near`. `meta.updatedAt` is always a date-time with Z or an offset: sources dated only to a day, month or year give the start of that period in the city’s time zone, with `meta.updatedAtPrecision`. Transit departures add ISO `scheduledAt`/`expectedAt` next to the epoch-millisecond values; air stations add `pm25Avg24h` (same value as `pm25_24`). Every OpenAPI operation has a response example (hand-written or a placeholder generated from its schema). MCP: focused tools for news, jobs, weather, address zoning, garbage day and bus stops; event month and date filters; tool titles; two resources (usage guide, API catalogue) and two prompts (local briefing, address report); schema violations return the same structured `invalid_arguments` error as other failures; HTTP refusals use the API error shape. The server card lists the tools, resources, prompts and protocol version.
- **1.0.2** (2026-10-02): Responses carry `Last-Modified` (from `meta.updatedAt`) and the full `RateLimit-*` set is declared in OpenAPI; ETags now ignore `meta.ageSec`, so `If-None-Match` returns 304 whenever the data is unchanged. Discovery from the domain alone: `/.well-known/api-catalog` (RFC 9727), an MCP server card at `/.well-known/mcp/server-card.json`, and `/.well-known/security.txt`. A Postman collection at `/developers/postman.json`. OpenAPI documents error codes, the `If-None-Match` header and the versioning policy.
- **1.0.1** (2026-10-01): Full public API coverage through MCP discovery, filtered queries and bounded batches, with explicit partial results and actionable retry errors. Client setup and downloadable configurations at /developers/mcp/. City-aware examples, safer pagination, nested-array field selection and accessible code copying.
- **1.0.0** (2026-09-30): First public release: every module of PG Pulse as read-only JSON under /api/v1, with OpenAPI 3.1, Markdown, CSV and GeoJSON output, ETags and generous per-IP rate limits. Markdown versions of every page (/<page>.md, /md/<page>, or Accept: text/markdown), plus /llms.txt and /llms-full.txt.
