# observe-events

Reads the events of a **subject** and **keeps sending new ones** as they are written.

- **Route:** `QUERY /api/v2/data/observe-events`
- **Category:** `data`
- **Authentication:** required, with the API token
- **Answer:** a stream of NDJSON

An observation first sends the events that a **[subject](/docs/eventsourcingdb/subjects)** already holds, oldest first, and then **each new event once it is written**, over the same connection. It **never ends by itself**, so it suits whatever has to follow the events as they happen, such as a projection or a read model. If the connection drops, observe again from the last event you received, as **[Resuming After Connection Loss](/learn/tutorials/first-steps-with-eventsourcingdb/observing-events#resuming-after-connection-loss)** shows. The tutorial **[Observing Events](/learn/tutorials/first-steps-with-eventsourcingdb/observing-events)** walks through it step by step.

## Request

The body is a JSON object, with the content type `application/json`:

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `subject` | string | yes | The subject to observe, a slash-separated path such as `/books/42`. |
| `options.recursive` | boolean | no | Whether to observe every subject below the given one as well. Defaults to `false`, which observes the given subject alone. |
| `options.lowerBound` | object | no | The event ID to start at. Without it, the observation starts at the first event. |
| `options.lowerBound.id` | string | yes, in `lowerBound` | An event ID, a non-negative integer in a string, without a sign or a leading zero, such as `"23"`. |
| `options.lowerBound.type` | string | yes, in `lowerBound` | `inclusive` to send the event with that ID as well, or `exclusive` to leave it out. |
| `options.fromLatestEvent` | object | no | Starts the observation at the latest event of a type in a subject, that event included, instead of at `lowerBound`. |
| `options.fromLatestEvent.subject` | string | yes, in `fromLatestEvent` | The subject of that event, exactly that one and not a subject below it. |
| `options.fromLatestEvent.type` | string | yes, in `fromLatestEvent` | The event type of that event. |
| `options.fromLatestEvent.ifEventIsMissing` | string | yes, in `fromLatestEvent` | What to do if there is no such event: `read-everything` observes as if `fromLatestEvent` were left out, `wait-for-event` sends nothing until an event of that type is written to that subject, and every event from that one on. |

An observation always goes on in the order in which the events were written, so it takes **neither an `order` nor an `upperBound`**. **`fromLatestEvent` cannot be combined** with `lowerBound`, see **[Observing From the Last Event of a Given Type](/learn/tutorials/first-steps-with-eventsourcingdb/observing-events#observing-from-the-last-event-of-a-given-type)**.

To go on observing `/books/42` after the event with the ID `0`:

```shell
curl \
  -X QUERY \
  -H "authorization: Bearer secret" \
  -H "content-type: application/json" \
  -d '{"subject":"/books/42","options":{"lowerBound":{"id":"0","type":"exclusive"}}}' \
  http://localhost:3000/api/v2/data/observe-events
```

## Answer

The answer is a **stream of items**, one per line. Each event is an `event` item:

| Type | Data | When |
| --- | --- | --- |
| `event` | the event, a **[CloudEvent](/docs/eventsourcingdb/cloud-events)** with every field it was stored with | For every event that the observation covers, first those already written, then each new one once it is written, in the order in which they were written. |
| `error` | `code`, `description`, and `details` | When the observation cannot go on, which ends the stream, see **[Errors](#errors)**. |
| `heartbeat` | empty | While nothing else is sent for a while, to keep the connection alive. |

**Heartbeats** work as on every stream of the API, see **[Streams](/docs/eventsourcingdb/api#streams)**. Since an observation **never ends by itself, there is no `end` item**: the stream runs until you close the connection, or until an error ends it.

For a database in which `/books/42` holds a `book-acquired` event with the ID `0` and a `book-borrowed` event with the ID `1`, and into which a `book-returned` event is written while the observation runs, the request above is answered with these items, and the stream goes on until the connection is closed:

```json
{"type":"event","data":{"specversion":"1.0","id":"1","source":"https://library.eventsourcingdb.io","subject":"/books/42","type":"io.eventsourcingdb.library.book-borrowed","time":"2026-10-28T04:36:07.365416Z","predecessorhash":"esdb:hash:v2:0c93c8369291dbce3e786d0427a0b5e94a7be9867d5d7e7357f5b2557ec049cc","datacontenttype":"application/json","data":{"borrowedBy":"/readers/23","borrowedUntil":"2026-11-22"},"traceparent":null,"tracestate":null,"hash":"esdb:hash:v2:f3785555ef047f8366866f6b1a9da3e1967c0a911ff030779789ae1c5ef5ac88","signature":null}}
{"type":"heartbeat","data":{}}
{"type":"event","data":{"specversion":"1.0","id":"2","source":"https://library.eventsourcingdb.io","subject":"/books/42","type":"io.eventsourcingdb.library.book-returned","time":"2026-10-28T04:36:19.677736Z","predecessorhash":"esdb:hash:v2:f3785555ef047f8366866f6b1a9da3e1967c0a911ff030779789ae1c5ef5ac88","datacontenttype":"application/json","data":{"returnedBy":"/readers/23"},"traceparent":null,"tracestate":null,"hash":"esdb:hash:v2:071dcd6fc73535ed7caa99060d69bd6905ce5d67f7c8479b05b4fb361d5f45ea","signature":null}}
{"type":"heartbeat","data":{}}
```

## Errors

The observation reports these errors as an `error` item, which ends the stream without an `end` item:

- **[`subject-missing`](/docs/eventsourcingdb/errors/subject-missing)**: The request leaves out the subject.
- **[`subject-malformed`](/docs/eventsourcingdb/errors/subject-malformed)**: The subject to observe, or the subject of `fromLatestEvent`, is not a valid subject, such as `books/42` without its leading slash.
- **[`subject-too-long`](/docs/eventsourcingdb/errors/subject-too-long)**: The subject to observe, or the subject of `fromLatestEvent`, is longer than 1,024 characters.
- **[`subject-too-deep`](/docs/eventsourcingdb/errors/subject-too-deep)**: The subject to observe, or the subject of `fromLatestEvent`, has more than 32 segments.
- **[`event-id-missing`](/docs/eventsourcingdb/errors/event-id-missing)**: `lowerBound` leaves out its `id`.
- **[`event-id-malformed`](/docs/eventsourcingdb/errors/event-id-malformed)**: The `id` of `lowerBound` is not a non-negative integer without a sign or a leading zero, such as `-1` or `007`.
- **[`bound-type-missing`](/docs/eventsourcingdb/errors/bound-type-missing)**: `lowerBound` leaves out its `type`.
- **[`bound-type-unknown`](/docs/eventsourcingdb/errors/bound-type-unknown)**: The `type` of `lowerBound` is neither `inclusive` nor `exclusive`.
- **[`from-latest-event-incomplete`](/docs/eventsourcingdb/errors/from-latest-event-incomplete)**: `fromLatestEvent` leaves out its `subject`, its `type`, or `ifEventIsMissing`.
- **[`if-event-is-missing-unknown`](/docs/eventsourcingdb/errors/if-event-is-missing-unknown)**: `ifEventIsMissing` is neither `read-everything` nor `wait-for-event`.
- **[`event-type-malformed`](/docs/eventsourcingdb/errors/event-type-malformed)**: The `type` of `fromLatestEvent` is not a valid event type.
- **[`selection-conflicting`](/docs/eventsourcingdb/errors/selection-conflicting)**: The request combines `fromLatestEvent` with `lowerBound`.
- **[`range-unsatisfiable`](/docs/eventsourcingdb/errors/range-unsatisfiable)**: `lowerBound` is exclusive at the largest possible event ID, which leaves no event to observe.
- **[`restore-in-progress`](/docs/eventsourcingdb/errors/restore-in-progress)**: A restore is running as the observation starts, or starts while it runs, which ends the observation.
- **[`file-unreadable`](/docs/eventsourcingdb/errors/file-unreadable)**: A file of the store that the observation needs is damaged, such as an events file whose checksum no longer matches.
- **[`store-failed`](/docs/eventsourcingdb/errors/store-failed)**: The store refuses reads for good, after a restore that could not empty it again.
- **[`server-shutting-down`](/docs/eventsourcingdb/errors/server-shutting-down)**: The server began to shut down while the observation was running. Before the stream begins, the server answers with `503` instead.

## Status Codes

Before the stream begins, the server answers with a status code other than `200` where it cannot take the request at all, as described in **[Errors](/docs/eventsourcingdb/api#errors)**:

- **`400`**: The content type of the body is not `application/json`, or the body is no JSON object of the shape above, for example with a field the route does not know, such as `order` or `upperBound` in `options`, with a value of the wrong JSON type, such as an `id` that is a number rather than a string, or with `null` as a value.
- **`401`**: The API token is missing or wrong.
- **`408`**: The body did not arrive in time.
- **`413`**: The body is larger than its **[limit](/docs/eventsourcingdb/limits)**.
- **`429`**: Too many requests were sent, or the client already holds 256 open streams, see **[Rate Limiting](/docs/eventsourcingdb/rate-limiting)**; the answer says when to try again.
- **`500`**: The server failed unexpectedly before the stream began. Once it has begun, the stream breaks off without an `error` item instead.
- **`503`**: The server is shutting down.
