> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Interaction Summaries

> Read materialized interaction summaries by customer, period type, offer, and channel for verifying contact-policy and metric checks

`GET /api/v1/interaction-summaries` reads rows from the materialized interaction-summary store — the aggregate that powers contact-policy enforcement, behavioral-metric values, and frequency-cap evaluation. The endpoint is intended for verification, debugging, and integration tests; production read paths use the cached read helpers directly.

## What it does

Each materialized row aggregates raw interaction events for one `(customerId, offerId | creativeId | channelId, periodType, periodKey)` tuple. The endpoint accepts query filters and returns the matching rows with the count fields (`impressions`, `positive`, `negative`, `neutral`, `converts`, `totalValue`) and last-contact metadata. `customerId` is required — there is no all-customers list mode.

For the related materialized-aggregate writer and decay/retention rules, see [Interaction Summary](/api-reference/interaction-summary).

## Quick start

Read all summaries for a customer across every period type:

```bash theme={null}
curl 'https://playground.kaireonai.com/api/v1/interaction-summaries?customerId=cust_42' \
  -H "X-API-Key: krn_your_api_key" \
  -H "X-Tenant-Id: 5a9904b9-..."
```

Response (abbreviated):

```json theme={null}
{
  "data": [
    {
      "periodType": "daily",
      "periodKey": "2026-04-30",
      "customerId": "cust_42",
      "offerId": "off_premium_card",
      "creativeId": "crv_email_a",
      "channelId": "ch_email",
      "impressions": 3,
      "positive": 1,
      "negative": 0,
      "neutral": 0,
      "converts": 0,
      "totalValue": 0,
      "lastContactAt": "2026-04-30T14:22:01.123Z",
      "lastOutcomeKey": "click",
      "direction": "outbound"
    }
  ],
  "total": 1
}
```

## How it works

### Authentication and roles

The endpoint requires the `admin`, `editor`, or `viewer` role and a tenant header. All reads are scoped by `tenantId`. Cross-tenant reads are not possible.

### Filter composition

The endpoint composes a filter from query parameters. `tenantId` and `customerId` are always required. Optional `periodType`, `offerId`, and `channelId` filters are AND-ed onto the base clause when supplied. Results are ordered by `(periodType ASC, periodKey DESC)` so the most recent period in each type appears first.

### Pagination

A single integer `limit` clamps results to 200 maximum. Cursor pagination is not supported — for large result sets, narrow the query with `periodType` or `offerId` filters.

## Reference

### Query Parameters

<ParamField query="customerId" type="string" required>
  Customer identifier. Missing this parameter returns `400 customerId is required`.
</ParamField>

<ParamField query="periodType" type="string">
  One of `daily`, `weekly`, `monthly`, `alltime`. When omitted, all period types are returned.
</ParamField>

<ParamField query="offerId" type="string">
  Filter to summaries for one offer.
</ParamField>

<ParamField query="channelId" type="string">
  Filter to summaries for one channel.
</ParamField>

<ParamField query="limit" type="number" default="50">
  Maximum rows returned. Clamped to `[1, 200]` (`route.ts:46`).
</ParamField>

### Response

Returned at `route.ts:54-73`.

<ResponseField name="data" type="array">
  Matching summary rows, ordered by `(periodType ASC, periodKey DESC)`.
</ResponseField>

<ResponseField name="total" type="number">
  Length of `data` after the limit was applied. Not the total matching count — there is no separate count query.
</ResponseField>

#### `data[]` per-item shape

Built at `route.ts:55-71`.

<ResponseField name="periodType" type="string">
  `daily`, `weekly`, `monthly`, or `alltime`.
</ResponseField>

<ResponseField name="periodKey" type="string">
  Period bucket identifier. For `daily`, `YYYY-MM-DD`. For `weekly`, `YYYY-Www`. For `monthly`, `YYYY-MM`. For `alltime`, the literal `"alltime"`.
</ResponseField>

<ResponseField name="customerId" type="string" />

<ResponseField name="offerId" type="string | null">
  Present when the summary is per-offer. Null on aggregate-by-channel rows.
</ResponseField>

<ResponseField name="creativeId" type="string | null" />

<ResponseField name="channelId" type="string | null" />

<ResponseField name="impressions" type="number">
  Count of impression-class outcomes in this period.
</ResponseField>

<ResponseField name="positive" type="number">
  Count of outcomes whose `OutcomeType.classification` is `"positive"`.
</ResponseField>

<ResponseField name="negative" type="number">
  Count of outcomes whose classification is `"negative"`.
</ResponseField>

<ResponseField name="neutral" type="number">
  Count of outcomes whose classification is `"neutral"`.
</ResponseField>

<ResponseField name="converts" type="number">
  Count of conversions specifically (typically a subset of `positive`).
</ResponseField>

<ResponseField name="totalValue" type="number">
  Sum of `conversionValue` across all positive outcomes in this period.
</ResponseField>

<ResponseField name="lastContactAt" type="string | null">
  ISO timestamp of the most recent interaction in this period.
</ResponseField>

<ResponseField name="lastOutcomeKey" type="string | null">
  `OutcomeType.key` of the most recent interaction.
</ResponseField>

<ResponseField name="direction" type="string | null">
  `"inbound"` or `"outbound"` — the direction of the most recent interaction.
</ResponseField>

### Status codes

| Code | When                                         | Source           |
| ---- | -------------------------------------------- | ---------------- |
| 200  | Returns matching rows (possibly empty)       | `route.ts:54`    |
| 400  | Missing `customerId` query parameter         | `route.ts:31-33` |
| 401  | Caller is not authenticated                  | `requireRole`    |
| 403  | Caller is not `viewer`, `editor`, or `admin` | `route.ts:26`    |
| 500  | Unexpected error                             | `route.ts:75`    |

### Required headers

| Header        | Required             | Read at         | Purpose           |
| ------------- | -------------------- | --------------- | ----------------- |
| `X-API-Key`   | Yes (one of the two) | `tenant.ts:97`  | API key (`krn_…`) |
| `X-Tenant-Id` | Yes (one of the two) | `tenant.ts:113` | Direct tenant id  |

## Honest limits

* The `total` field counts rows in the current response, not total matching rows. Callers needing a true count must run a separate `COUNT(*)` query against the underlying table.
* No cursor pagination. With `limit` capped at 200, a customer with > 200 daily rows must narrow the query (e.g., add `periodType=monthly` or `offerId=...`) to read the rest.
* The `400` and `500` envelopes use the legacy `{ title, detail }` shape (`route.ts:32`) rather than the standard `apiError` envelope from `lib/api-error.ts`. Callers parsing errors should accept both shapes.

## Related

* [Interaction Summary](/api-reference/interaction-summary) — the materialized-aggregate writer + decay/retention helpers.
* [Interaction History](/api-reference/interaction-history) — the raw event-by-event read surface.
* [Contact Policies](/api-reference/contact-policies) — frequency-cap and suppression rules that consume these summaries.
* [Behavioral Metrics](/api-reference/behavioral-metrics) — derived metrics that aggregate from this table.
