# Event stream

> Read every analytics and purchase event of a project from the pull feed.

Source: https://docs.grovs.io/docs/api-reference/event-stream

The pull feed lets your own job read a project's analytics and purchase events. Webhook and S3 destinations, exports and feed tokens are managed in the dashboard. See the [event stream guide](https://docs.grovs.io/docs/data/event-stream).

**Availability:** paid grovs Cloud plans and the self-hosted Enterprise image. Other instances get `402`.

**Auth:** a [feed token](https://docs.grovs.io/docs/api-reference/authentication#scoped-keys) (`est_…`). An instance admin creates it in **Developers** › **Data Export** › **Pull feed tokens** › **Create feed token**. The token is shown once. It reads one project environment and cannot change anything.

Delivery is at least once, so deduplicate on the event `id`. Order is kept per device. Captured events are kept for 7 days. Capture starts when a project gets its first destination or feed token. Earlier events are only reachable through an [export](https://docs.grovs.io/docs/data/exports) in the dashboard.

**Endpoints**

- `GET /api/v1/projects/{project_id}/event_stream`

## Find the project ID

The feed path takes the **numeric** ID of the project environment where you created the token. Production and test have different IDs.

It is not the `project.id` hashid inside events. The dashboard does not show the numeric ID yet. To find it:

1. Open **Developers** › **Data Export** › **Pull feed tokens**.
2. Open your browser's network panel.
3. Click **Refresh tokens**.
4. Read the number in the request path `/api/v1/projects/PROJECT_ID/event_stream_tokens`.

A token used with another project's ID gets `403`.

```text
/api/v1/projects/123/event_stream
                 ^^^
                 project ID (integer)
```

## Read the pull feed

`GET /api/v1/projects/{project_id}/event_stream`

Returns a page of captured analytics and purchase events, merged in arrival order. grovs keeps no position for you. Save `next_after` after you process the page, then send it back as `after`. Events become readable about 15 seconds after they arrive. The [event stream guide](https://docs.grovs.io/docs/data/event-stream) walks through a full polling loop.

Auth: Scoped key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

Send a feed token as `Authorization: Bearer est_…`.

**Path**

- `project_id` (integer, required): The numeric ID of the project environment the token belongs to. See [Find the project ID](#find-the-project-id).

**Query**

- `after` (string, optional): The `next_after` of your last page, unchanged. Omit it to start at the oldest captured event (up to 7 days back).
- `limit` (integer, optional): 1 to 1000. Default `100`. Caps analytics and purchases together.
- `event_types[]` (string, optional): Repeatable filter, for example `event_types[]=install&event_types[]=purchase`. Unknown values are ignored. Default: every type.

**Returns** `schema_version`, `events` (an array of [event envelopes](#the-event-envelope), without `geo.city` or `geo.ip`) and `next_after`. An empty `events` array means you have caught up.

In production, each feed token can make 120 requests a minute. Over that, the API answers `503`.

**Errors**

| Status | Meaning                                                                                  |
| ------ | ---------------------------------------------------------------------------------------- |
| 400    | \`after\` is not a cursor this API returned, or \`limit\` is not an integer.             |
| 401    | Missing, invalid or revoked token.                                                       |
| 402    | The instance is not on a plan that includes the event stream.                            |
| 403    | The token belongs to another project.                                                    |
| 503    | More than 120 requests a minute for this token. Back off and retry with the same cursor. |

**Request**

```bash
curl -X GET "https://api.sqd.link/api/v1/projects/{project_id}/event_stream?limit=500&event_types%5B%5D=install&event_types%5B%5D=purchase" \
  -H "Authorization: Bearer $GROVS_STREAM_KEY"
```

**Response 200 OK**

```json
{
  "schema_version": 1,
  "events": [
    {
      "schema_version": 1,
      "id": "3f9a1c2e7b5d4e0a9c8b7a6f5e4d3c2b",
      "type": "install",
      "name": null,
      "occurred_at": "2026-10-22T09:03:11.412Z",
      "project": { "id": "pQx7", "test": false },
      "link": null,
      "attribution": { "link_visitor_id": null, "inviter_id": null, "sdk_generated": false },
      "device": {
        "id": "D8mz",
        "platform": "android",
        "os": "Android",
        "os_version": "15",
        "app_version": "4.2.0",
        "build": "4210",
        "model": "Pixel 9",
        "timezone": "Europe/Berlin",
        "language": "de"
      },
      "user": { "id": null, "attributes": {} },
      "geo": { "country": "DE" },
      "session_id": "s_8c21f0",
      "screen": null,
      "engagement_time": 0,
      "properties": {},
      "caused_by": null,
      "purchase": null
    },
    {
      "schema_version": 1,
      "id": "b71e04c9d2a35f8e6c1b0a9d8e7f6a5b",
      "type": "purchase",
      "name": "buy",
      "occurred_at": "2026-10-22T09:05:40.218Z",
      "project": { "id": "pQx7", "test": false },
      "link": null,
      "attribution": { "link_visitor_id": null, "inviter_id": null, "sdk_generated": false },
      "device": { "id": "D8mz", "platform": "android" },
      "user": { "id": "member-4471", "attributes": {} },
      "geo": {},
      "session_id": "s_8c21f0",
      "screen": null,
      "engagement_time": 0,
      "properties": {},
      "caused_by": null,
      "purchase": {
        "transaction_id": "GPA.3312-8841-2290-11873",
        "original_transaction_id": null,
        "product_id": "medly_plus_yearly",
        "purchase_type": "subscription",
        "store": "google",
        "quantity": 1,
        "currency": "EUR",
        "usd_price_cents": 5399,
        "purchase_date": "2026-10-22T09:05:38.000Z",
        "version": "2026-10-22T09:05:41.006Z"
      }
    }
  ],
  "next_after": "eyJlIjpbIjIwMjYtMTAtMjIgMDk6MDM6MTEuNDEyIiwiM2Y5YSJdLCJwIjpbIjIw…"
}
```

**Response 400 Bad Request**

```json
{ "error": "Invalid cursor" }
```

## The event envelope

Every event has the same JSON shape on every transport: pull feed, webhook and S3. This is envelope version 1. New keys may appear within a version. A rename or removal comes as a new `schema_version`.

**Attributes**

- `schema_version` (integer): Always `1` for this shape.
- `id` (string): The deduplication key. For a purchase it changes with every store correction.
- `type` (string): `view`, `open`, `install`, `reinstall`, `app_open`, `time_spent`, `reactivation`, `user_referred`, `screen_view`, `custom`, `purchase`, or `test` for a test event sent from the dashboard.
- `name` (string | null): The custom event's name, or the store action of a purchase: `buy`, `cancel`, `refund`, `refund_reversed`. `null` otherwise.
- `occurred_at` (string): When the event happened, ISO 8601 with milliseconds.
- `project` (object): `id` (hashid) and `test` (whether it is the test environment).
- `link` (object | null): `id` and `campaign_id` (integers), `path`, `source`, `medium`, `campaign`, `ads_platform`, `tags`. `null` when the event has no link.
- `attribution` (object): `link_visitor_id` and `inviter_id` (hashids or `null`), `sdk_generated`.
- `device` (object): `id` (hashid), `platform`, `os`, `os_version`, `app_version`, `build`, `model`, `timezone`, `language`. Purchases carry `id` and `platform` only.
- `user` (object): `id` is the identifier your app set through the SDK, `attributes` its attributes. Use `user.id` to join with a CRM.
- `geo` (object): `country`. Destinations with `include_ip` also get `city` and `ip`. The pull feed never includes them.
- `session_id` (string | null): As recorded by the SDK.
- `screen` (string | null): Screen name, as recorded by the SDK.
- `engagement_time` (integer): As recorded by the SDK. `0` for purchases.
- `properties` (object): Event properties.
- `caused_by` (string | null): On `user_referred`, the `id` of the install that produced it.
- `purchase` (object | null): On a purchase: `transaction_id`, `original_transaction_id`, `product_id`, `purchase_type`, `store`, `quantity`, `currency`, `usd_price_cents`, `purchase_date`, `version`.

Stores correct purchases after the fact. Each correction arrives as a new event with a newer `purchase.version`. Within a project, upsert on (`purchase.transaction_id`, `name`) and keep the newest `purchase.version`.

**The event envelope**

```json
{
  "schema_version": 1,
  "id": "3f9a1c2e7b5d4e0a9c8b7a6f5e4d3c2b",
  "type": "install",
  "name": null,
  "occurred_at": "2026-10-22T09:03:11.412Z",
  "project": { "id": "pQx7", "test": false },
  "link": {
    "id": 8812,
    "path": "flu-shot",
    "campaign_id": 41,
    "source": "email",
    "medium": "newsletter",
    "campaign": "autumn_checkup",
    "ads_platform": null,
    "tags": ["q4"]
  },
  "attribution": { "link_visitor_id": "V1k9", "inviter_id": null, "sdk_generated": false },
  "device": {
    "id": "D8mz",
    "platform": "ios",
    "os": "iOS",
    "os_version": "18.6",
    "app_version": "4.2.0",
    "build": "4210",
    "model": "iPhone16,2",
    "timezone": "Europe/Berlin",
    "language": "de"
  },
  "user": { "id": "member-4471", "attributes": { "plan": "plus" } },
  "geo": { "country": "DE" },
  "session_id": "s_8c21f0",
  "screen": "Home",
  "engagement_time": 0,
  "properties": {},
  "caused_by": null,
  "purchase": null
}
```
