Your own product events, and the aggregates built from them.

**`POST /analytics/events`** emits events; **`GET /analytics`** reads back usage, API volume and revenue for your application.

```ts
await connect.analytics.emit([
  { name: "campaign_sent", occurredAt: new Date().toISOString(), value: 1 },
]);

const { series } = await connect.analytics.series({
  metrics: ["usage.events", "api.requests"],
  from,
  to,
});
```

Send events in batches - one request per user action is a request per user action.

**Events are bucketed by UTC day.** An `occurredAt` far in the past or the future is clamped rather than rejected, so a client with a wrong clock cannot rewrite history or poison tomorrow. Backfill has a bounded window; anything older is clamped to it.

Distinct from **Logs**: an audit entry is one attributable action a person can be asked about, an analytics event is a number in an aggregate.

## Endpoints

### POST `/api/v1/application/events`

Emit your own product events

**Scope** `analytics:write` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key

Pushes your platform's own product events into Connect's analytics pipeline, so your usage shows up alongside the numbers Connect already collects. The sibling of `logs:write`: that one is a searchable log line, this one is a countable event.

#### Implementation

**1. Prerequisite** - an API key scoped to `analytics:write`.

**2. Send up to 50 events per request:**
```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/events" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"name":"checkout.completed","value":4999,"unit":"paise","sessionToken":"..."}]}'
```

**3. Name your events `namespace.action`**, lowercase. Prefixes Connect emits itself (`api.`, `billing.`, `addon.`, `subscription.`, `connect.`) are rejected, so your events can never be mistaken for - or inflate - Connect's own metered numbers.

**4. Pass `sessionToken` to attribute an event** to the signed-in user and their workspace. Without it the event still lands, just unattributed: `attributed` in the response tells you how many were tied to a user. An expired or wrong-application token is not an error either, for the same reason - losing the event would be worse than losing its attribution.

`value` is a whole number in the smallest unit of `unit` (paise, bytes, milliseconds). Fractional measures belong in `props`.

`occurredAt` is optional and clamped server-side to within 48 hours - enough to replay a queue after an outage, not enough to rewrite a period that has already been reported.

#### Errors
`forbidden` (403 - key missing `analytics:write`), `validation_error` (422 - reserved prefix, malformed name, or oversized `props`), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `events` | `array` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Events accepted |
| `403` | forbidden - key missing analytics:write |
| `422` | validation_error |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `accepted` | `integer` | yes |  |
| `attributed` | `integer` | yes |  |

### GET `/api/v1/application/analytics`

Read this application's aggregated analytics

**Scope** `analytics:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Daily numbers for your application - usage volume, API call counts, active users, and subscription/revenue totals - over a date range of up to 90 days.

#### Implementation

**1. Prerequisite** - an API key scoped to `analytics:read`.

**2. Send the request:**
```bash
curl "https://connect.cosmoops.com/api/v1/application/analytics?metrics=usage.events,api.requests&from=2026-08-01&to=2026-08-25" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**3. Read `series`** - one entry per requested metric, each with a `points` array of `{ day, value }`.

**A `value` of `null` is not zero.** It means that day is not measurable for that metric: `usage.active_users` is a distinct count that only exists after the nightly rollup, so today's is always `null`, and future days are `null` for the obvious reason. `total` sums only the measurable days and `partial` is `true` whenever any day was skipped - render that caveat rather than a confident total.

The scope is always **your** application, taken from the presenting key. There is no parameter for it, by design.

#### Errors
`forbidden` (403 - key missing `analytics:read`), `validation_error` (422 - unknown metric, inverted range, or a range of 90 days or more), `rate_limited` (429).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Daily metric series |
| `403` | forbidden - key missing analytics:read |
| `422` | validation_error |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `series` | `array` | yes |  |

### GET `/api/v1/application/analytics/series`

Read your declared metrics as a series

**Scope** `analytics:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Series for the metrics **your application declared** in its analytics schema (Console > Applications > Analytics), bucketed by day, week, month or the whole range, in the workspace's own time zone. Up to 400 days per request.

#### Implementation

**1. Prerequisite** - an API key scoped to `analytics:read`, and the metrics declared in your application's analytics schema. A metric reads one event you emit through `POST /events` (or `events` on `POST /data-commit`) with one aggregation: `count`, `sum` of `value`, `distinct` users, or `gauge` (the last level pushed).

**2. Send the request:**
```bash
curl "https://connect.cosmoops.com/api/v1/application/analytics/series?metrics=deals.won,deals.won_value&from=2026-10-01&to=2026-10-31&granularity=week&workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

- `metrics` - comma-separated declared metric keys.
- `from`, `to` - inclusive local days, `YYYY-MM-DD`, in the workspace's time zone (`timeZone` in the response says which).
- `granularity` - `day` (default), `week` (buckets start Monday), `month` (the 1st) or `total`.
- `groupBy` - a declared dimension: one series per value, highest volume first. Values beyond the dimension's cardinality fold into `_other`.
- `filter` - `dimension:value`, restricting to one value of one declared dimension.
- `workspaceId` - optional. When present it must be a workspace subscribed to your application, as on every workspace-scoped endpoint. **Omit it for the application-wide total across every workspace** - a server-side number; never forward it to a browser.

**3. Read `series`** - one entry per metric (per dimension value when grouped), each with `points` of `{ bucket, value }` and a `total` combined by the metric's own rule: sums add, a gauge is the latest level, distinct users are merged rather than added.

**A `value` of `null` is not zero.** Today comes from live counters; earlier days come from the nightly rollup, and a day it has not reached is `null`. A `distinct` metric is `null` for today. `partial` is `true` whenever any day in range was not measurable - render that caveat rather than a confident total.

#### Errors
`forbidden` (403 - key missing `analytics:read`, or a `workspaceId` not subscribed to your application), `validation_error` (422 - undeclared metric or dimension, inverted or over-long range, malformed query), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `metrics` | query | yes | Comma-separated declared metric keys |
| `from` | query | yes | Inclusive local day, YYYY-MM-DD |
| `to` | query | yes | Inclusive local day, YYYY-MM-DD |
| `granularity` | query | no |  |
| `groupBy` | query | no | A declared dimension key |
| `filter` | query | no | dimension:value |
| `workspaceId` | query | no | Omit for the application-wide total |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Metric series |
| `403` | forbidden |
| `422` | validation_error |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `from` | `string` | yes |  |
| `to` | `string` | yes |  |
| `granularity` | `day` \| `week` \| `month` \| `total` | yes |  |
| `timeZone` | `string` | yes | The zone the local days are in - the workspace's, or the default for an application-wide read |
| `series` | `array` | yes |  |

### GET `/api/v1/application/analytics/schema`

Read your application's analytics schema

**Scope** `analytics:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

The metrics and dimensions your application declared - what `GET /analytics/series` will accept. Lets a dashboard render its metric picker from the declaration instead of hardcoding it.

#### Implementation

```bash
curl "https://connect.cosmoops.com/api/v1/application/analytics/schema" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

The schema is edited in Console (or by your seed script), never by an API key. An application that declared nothing answers `{ "metrics": [], "dimensions": [] }`.

#### Errors
`forbidden` (403 - key missing `analytics:read`), `rate_limited` (429).

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The declared schema |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `metrics` | `array` | yes |  |
| `dimensions` | `array` | yes |  |