
# List events

`GET /v1/events` · permission `events:read`

Returns the environment's events in order, oldest first. Each event is the same document, with the same `id`, that webhooks deliver. Events are kept for 30 days on every plan.

Use this endpoint when your system cannot receive webhooks, or to fill a gap after an outage: keep the last `next_cursor` you processed and continue from it.

The environment always comes from the API key; there is no environment parameter.

## Request

| Parameter | In | Required | Description |
|---|---|---|---|
| `after` | query | no | `next_cursor` from the previous response, or `latest` to receive only events recorded from now on. Without it the list starts at the oldest retained event. |
| `types` | query | no | Comma-separated event types, at most 20, such as `user.created,user.deleted`. Without it every type is returned. |
| `org_id` | query | no | Only events of this organization (`org_…`). |
| `limit` | query | no | Page size, 1 to 100. Default 20. |
| `payload` | query | no | `thin` (default) or `full`. `full` also requires the `users:read` permission. Until the full payload is available, `full` returns the thin payload. |
| `purpose` | query | no | `tail` (default) or `webhooks_listen`. `webhooks_listen` is accepted only in `development` and `staging` environments. |

## Response

`200 OK`:

| Field | Description |
|---|---|
| `data` | Events in order. Every event has a `seq` that increases within the environment. |
| `next_cursor` | Always present. Pass it as `after` in the next request, even when `data` is empty. |
| `has_more` | `true` when more events follow; request again right away. When `false`, wait before polling again (5 to 60 seconds). |

The cursor does not depend on `types` or `org_id`: you can change the filter and continue from the same position. A cursor works only in the environment that issued it.

```json
{
  "data": [
    {
      "id": "evt_01j9xk3m7q8r2s4t6v8w0y2z4a",
      "type": "connection.deleted",
      "schema_version": 1,
      "environment_id": "env_01j9xk3m7q8r2s4t6v8w0y2z4e",
      "occurred_at": "2026-11-03T08:15:42.120Z",
      "recorded_at": "2026-11-03T08:15:42.140Z",
      "seq": 4182,
      "data": {
        "object": {"id": "conn_01j9xk3m7q8r2s4t6v8w0y2z4f", "status": "deleted"},
        "object_version": 2
      },
      "context": {"actor": {"type": "system"}, "request_id": "0192a6c1-7d3e-7a10-9b2c-3d4e5f607182"}
    }
  ],
  "next_cursor": "eyJ2IjoxfQ.c2lnbmF0dXJl",
  "has_more": false
}
```

## Example: poll for new events

```bash
cursor=latest
while true; do
  page=$(curl -sS -G https://api.keenauth.com/v1/events \
    -H "Authorization: Bearer $KEENAUTH_API_KEY" \
    --data-urlencode "after=$cursor" --data-urlencode "limit=100")
  echo "$page" | jq -c '.data[]'
  cursor=$(echo "$page" | jq -r '.next_cursor')
  [ "$(echo "$page" | jq -r '.has_more')" = "true" ] || sleep 10
done
```

The CLI does the same with `keenauth events tail`.

## Errors

Every error is an `application/problem+json` body with a stable `code`.

| HTTP | `code` | When |
|---|---|---|
| 400 | `invalid_cursor` | The cursor is malformed or was issued in another environment. |
| 401 | `unauthenticated` | The API key is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The key lacks `events:read`, or `payload=full` without `users:read`. |
| 403 | `test_feature_not_in_production` | `purpose=webhooks_listen` in a production environment. |
| 422 | `validation_failed` | A parameter is invalid or repeated; `errors[].pointer` names it. |
| 429 | `rate_limited` | The rate limit was exceeded; honor `Retry-After`. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable; retry later. |

## Personal data

This endpoint carries personal data: events. [Personal data in the KeenAuth APIs](/docs/policies/personal-data) describes what is stored, how long it is kept and how it is deleted. Do not send special categories of personal data (health, biometric data, religion, political opinions, sexual orientation) in any field.
