---
title: "Read the project events feed (cursored catch-up or newest-first display order)"
method: GET
path: "/projects/v1/{project_id}/events"
tags: ["Projects"]
---

# Read the project events feed (cursored catch-up or newest-first display order)

`GET /projects/v1/{project_id}/events`

## Request

- Base URL: `https://api.run402.com`
- URL: `https://api.run402.com/projects/v1/{project_id}/events`
- Auth: one of:
  - HTTP bearer
  - API key in header `SIGN-IN-WITH-X`
  - HTTP bearer
  - HTTP bearer

The durable, ordered, per-project feed of operationally significant facts — deploy activations, mailbox suspensions, transfers, lifecycle cliffs, verification outcomes. Answers "what happened to my project since cursor C" in one call. Read-only and never lifecycle-gated: a frozen project's feed stays readable (that is when you most need it). Authorize-before-reveal: unauthorized callers get 403, never a 404 existence oracle. After every deploy, the apply/promote success response's `next_actions[]` hands you this route with a cursor positioned at your own `deploy_activated` event. Two pagination modes, disjoint by construction: the default ascending catch-up (`cursor` — "what's new since") and the display-order read `order=desc` (+ `before` for older pages — "latest N, then the rest of history"). A desc response still carries the live high-water `cursor`, so a display surface flips to catch-up polling without a discovery call. Each event's `next_actions[]` entries carry `kind`: `pointer` (a read — safe to follow whenever, no judgment implied) or `remediation` (a change the platform is suggesting). A remediation may also carry `precondition`, the machine-checkable condition under which it is still warranted — `renew_tier` states one, because it spends money and is moot the moment the organization renews by another route. Treat an unqualified remediation as advice, not a live claim. Every event carries `project_id`; on this feed it is always this project, and it is null only on the organization feed, where facts that belong to no project also appear. A fact OUTLIVES the project it describes — deleting a project no longer erases its history, so a row may name a project that no longer exists.

## Path parameters

- `project_id` string, required: Run402 project ID. Public IDs are not UUIDs.

## Query parameters

- `cursor` string
- `order` 'asc' | 'desc'
- `before` string
- `limit` integer
- `source` 'app' | 'platform'
- `event_type` string
- `min_severity` 'info' | 'notice' | 'warn' | 'critical'

## Headers

- `SIGN-IN-WITH-X` string, required

## Response `200`

Feed page

- ProjectEventFeedPage: One page of the cursored events feed — oldest-first by default (catch-up), newest-first with `order=desc` (display). Store `cursor` and pass it as `?cursor=` next time to catch up in one call; a desc page walks older history via `before_cursor` instead. Events become visible within seconds (a short visibility watermark orders concurrent commits, identically in both orders) and are never lost after it.
  - `events` ProjectEvent[], required
    - `id` string: Opaque event cursor (`evc_...`). Never parse it; store and echo it as `?cursor=`.
    - `event_type` string: Flat snake_case event name, e.g. `deploy_activated`, `error_fingerprints_observed`, `platform_incident`, `mailbox_suspended`, `project_transfer_initiated`, `organization_past_due`, `verification_failed`, `webhook_disabled` (platform vocabulary) or an app-chosen name such as `signature_completed` (app-events-emit-lane, `source: "app"`). The vocabulary is the platform notification event set plus feed-native events plus whatever an app's own deployed code has emitted via `POST /projects/v1/:project_id/events`. `platform_incident` is an ATTRIBUTED platform-fault event: it lands on an affected project's feed when a platform incident resolves, with a compact payload `{ incident_id, subsystem, severity, scope, status, started_at, resolved_at, summary, impact: { count } }` — the real number of that project's invocations the platform, not the app, caused to fail. Global (unattributed) incidents ride the page-level `platform_incidents[]` overlay instead.
    - `severity` 'info' | 'notice' | 'warn' | 'critical': event-attention-rules: how significant the event is, stamped at write and never updated. An app event carries what its emitter declared (`info` when it declared nothing); a platform event carries the level registered for its type. States what happened, not how important the class is: `verification_succeeded` is `notice`, `mailbox_suspended` is `warn`.
    - `class` string: Event class stamped at write time: `lifecycle`, `threshold`, `security`, `recovery`, `billing_critical`, `destructive_lifecycle`, `verification`, `platform_incident`, `digest`, or `app` (app-events-emit-lane). Mandatory classes (`security`, `recovery`, `billing_critical`, `destructive_lifecycle`, `verification`, `platform_incident`) are retained 365 days; everything else — including `app`, which is NEVER a mandatory class — 90 days. `platform_incident` is mandatory for the retention floor only — the platform's confession log is not the first thing swept — and is never delivered as a preference-gated notification.
    - `source` string: Producer discriminator. `"app"` for a row written by a project's own deployed code via `POST /projects/v1/:project_id/events`; any other value (`gateway`, `apply`, `email-lambda-events`, …) is a platform producer — the internal split among them is an implementation detail. Consumers key on `(source, event_type)` to tell app facts from platform facts; filter with `?source=app` / `?source=platform` on both feed GETs.
    - `occurred_at` string, date-time
    - `payload` object: Compact fact: resource ids + verdict fields (never bodies or secret material). Oversize payloads carry `payload_truncated: true` + `dropped_keys[]`; the referenced domain resources hold full detail.
    - `next_actions` object[]: Platform-synthesized drill-downs for this event — the highest-probability next call, identical across HTTP/SDK/CLI/MCP.
      - `type` string
      - `method` string
      - `path` string
      - `command` string
      - `why` string
  - `cursor` string, required: High-water mark: the last served event's cursor (present even when `events` is empty — an empty page echoes your own `cursor` unchanged). On `order=desc` pages it is the LIVE high-water mark — the same value a caught-up ascending read with the same filters would return — so a display surface hands off to catch-up polling without a discovery call.
  - `has_more` boolean, required: True when more events are immediately available past `cursor` — poll again right away. On `order=desc` pages: true when OLDER rows remain past this page (within any active `source`/`event_type` filter) — follow `before_cursor`.
  - `before_cursor` string: `order=desc` pages only, and only when older rows remain (`has_more: true`): pass as `?order=desc&before=` for the next OLDER page. Absent on the oldest retained page — its absence IS the end-of-history signal, so you never fetch a trailing empty page. Same opaque cursor encoding as `cursor`.
  - `reset` boolean, required: True when the supplied position was unusable — an ascending `cursor` that was malformed or older than the retention floor (swept rows can no longer be proven seen), or a malformed desc `before`. The page restarts (ascending: from the earliest retained event; desc: from the newest page) and `earliest_cursor` is provided — never a bare 4xx, never a silent skip.
  - `earliest_cursor` string: Present only when `reset` is true: a cursor positioned just before the earliest retained event for this scope.
  - `platform_incidents` object[]: Sidecar overlay of GLOBAL (unattributed) platform incidents — open incidents plus incidents resolved after the caller's cursor position. It is a sidecar, NOT interleaved into `events[]`: the numeric cursor stays monotonic and uncorrupted. Each entry carries a stable `id` so a client dedups across reads. Attributed (per-project) incidents are NOT here — they arrive as `platform_incident` entries inside `events[]`. Present only when at least one such global incident applies; omitted otherwise.
    - `id` string, required: Stable incident id (`inc_…`) — the dedup key across feed reads.
    - `subsystem` string, required
    - `severity` 'info' | 'minor' | 'major', required
    - `scope` 'global', required: Always `global` in the overlay — attributed incidents ride `events[]`.
    - `status` 'ongoing' | 'resolved', required
    - `summary` string
    - `started_at` string, date-time, required
    - `resolved_at` string, date-time, nullable: Null while the incident is ongoing.
  - `platform_status` 'degraded': Present and equal to `degraded` only while an open platform incident is global or affects this caller's projects; omitted when clear. A health-assessment rider (same field as on GET /agent/v1/me/status and GET /tiers/v1/status), never added to unrelated 200s.

## Other responses

- `400`: Invalid `source` (must be app or platform), `event_type` (must match the flat snake_case grammar), or `order` (must be asc or desc); or a nonsensical pagination combination — `before` without `order=desc`, or `cursor` with `order=desc`
- `401`: Missing or invalid authentication. Run402-originated JSON bodies use the canonical ErrorEnvelope.
- `403`: Not authorized for this project (FORBIDDEN) — including a service_key presented for a different project

---

[API](https://skmtc.dev/run402/apis/run402-api.md) · [All operations](https://skmtc.dev/run402/apis/run402-api/llms.txt) · [OpenAPI document](https://skmtc.dev/run402/apis/run402-api/revisions/893607557f50?raw)
