# Ammendments

# Amended Endpoints — what the feed release changes in endpoints you already have

**Author**: Josh S. Sakweli, Backend Lead Team  
**Last Updated**: 2026-09-18  
**Version**: v1.0 (backend branch `fet/feed_baking`)

**Base URL**: `https://dev.api.nexgate.co` (staging) · `https://api.nexgate.co` (production)

**Short Description**: This document lists **only the endpoints that already existed and have now changed**. Nothing here is a new URL. Every path below is one your app already calls today, and every entry says exactly what moved under it, whether the change is visible in the response, and whether you have to do anything about it.

> **Its sibling document is [`09_NEW_ENDPOINTS.md`](09_NEW_ENDPOINTS.md)** — the endpoints and controllers that did not exist before (home feed, reels, marketplace, events, interactions, and the admin/ops APIs). If a path is not in this file, it was not amended; if it is not in that file, it is not new.

**Hints**:

- **No path, verb or field was removed.** Every change is additive, a bug fix, or a stricter parameter.
- Two response fields were **added** (both on `ClipResponse`). An app that ignores them behaves exactly as it does today.
- Three endpoints **return fewer items than before for some viewers**. That is the point: they were leaking private content.
- Four old paths are **being deleted, not migrated from** — see [Old endpoints being deleted](#old-endpoints-being-deleted). There is no production data and no grace period: when the frontend has switched, they go.
- Search this file by path. Every heading is the literal URL.

---

## How to read this document

Each entry carries two chips.

**Change type** — what kind of amendment it is:

<table id="bkmrk-chip-meaning-securit"><thead><tr><th>Chip</th><th>Meaning</th></tr></thead><tbody><tr><td><span style="background-color: #dc3545; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">SECURITY FIX</span></td><td>The endpoint used to return content the viewer was not allowed to see</td></tr><tr><td><span style="background-color: #6f42c1; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">RESPONSE</span></td><td>A field was added, or a field that used to be empty is now filled</td></tr><tr><td><span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span></td><td>Same shape, different contents, ordering or side effect</td></tr><tr><td><span style="background-color: #fd7e14; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">PARAMS</span></td><td>A query parameter is now validated or clamped</td></tr><tr><td><span style="background-color: #343a40; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEING DELETED</span></td><td>Still answers today, but is scheduled for removal — do not build on it</td></tr></tbody></table>

**App action** — what the frontend must do:

<table id="bkmrk-chip-meaning-%F0%9F%9F%A2-none-"><thead><tr><th>Chip</th><th>Meaning</th></tr></thead><tbody><tr><td>🟢 **None**</td><td>Ships safely with no app change</td></tr><tr><td>🟡 **Optional**</td><td>Works untouched, but there is something worth picking up</td></tr><tr><td>🔴 **Required**</td><td>The app must handle this or it will misbehave</td></tr></tbody></table>

---

## Index

<table id="bkmrk-%23-endpoint-change-ty"><thead><tr><th>\#</th><th>Endpoint</th><th>Change type</th><th>App action</th></tr></thead><tbody><tr><td>1</td><td>`GET /api/v1/e-social/feed`</td><td>RESPONSE · **BEING DELETED**</td><td>🔴 Required</td></tr><tr><td>2</td><td>`GET /api/v1/e-social/clip/feed`</td><td>SECURITY FIX · RESPONSE · BEHAVIOUR</td><td>🔴 Required</td></tr><tr><td>3</td><td>`GET /api/v1/e-social/clip/{mediaId}`</td><td>SECURITY FIX · RESPONSE</td><td>🟡 Optional</td></tr><tr><td>4</td><td>`GET /api/v1/e-social/clip/user/{userId}/videos`</td><td>SECURITY FIX · RESPONSE · BEHAVIOUR</td><td>🔴 Required</td></tr><tr><td>5</td><td>`POST /api/v1/e-social/clip/{mediaId}/view`</td><td>BEHAVIOUR</td><td>🟡 Optional</td></tr><tr><td>6</td><td>`GET /api/v1/e-social/hashtags/{hashtag}/posts`</td><td>SECURITY FIX · PARAMS · BEHAVIOUR · RESPONSE</td><td>🟢 None</td></tr><tr><td>7</td><td>`GET /api/v1/e-social/hashtags/trending`</td><td>BEHAVIOUR · PARAMS</td><td>🟢 None</td></tr><tr><td>8</td><td>`GET /api/v1/e-social/hashtags/search`</td><td>BEHAVIOUR · PARAMS</td><td>🟢 None</td></tr></tbody></table>

---

## Standard Response Format

Unchanged. Every endpoint below still answers in the usual envelope:

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Operation completed successfully",
  "action_time": "2026-09-18T10:30:45",
  "data": { }
}

```

Errors are unchanged too: `400` bad request, `401` token problems, `403` permission, `404` not found, `422` validation, `500` server. Where an amendment introduces a *new* reason for an existing status, the entry says so.

---

# Endpoints

## 1. Home feed (old)

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/feed`

**Change type**: <span style="background-color: #6f42c1; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">RESPONSE</span> <span style="background-color: #343a40; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEING DELETED</span> · **App action**: 🔴 Required — move to `/api/v1/feed/home`

> **This endpoint is going away.** It is listed here because it still answers today and its contents changed. Do not build anything new on it.

**Access Level**: 🔒 Protected · **Authentication**: `Authorization: Bearer <access token>`

### What changed

**a) `topComments` is now always populated.**

This endpoint used to call the post mapper with "no top comments", so `topComments` came back empty on every post. The mapper now always fills it (up to 5, most-liked first) because the feed needs it and loading it costs nothing extra in the batched mapper.

**b) A backend switch can make it answer from the new pipeline.** There is a `feed.legacy.fromPipeline` setting that makes this endpoint serve the new feed in the old shape. It was built for a migration that is no longer needed, and it is **not part of the plan** — it will be deleted together with the endpoint. Mentioned only so nobody is surprised to find it in the code.

### Response shape (unchanged)

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Feed retrieved",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "posts": [ { "postId": "…", "topComments": [ { } ] } ],
    "unseenCount": 12,
    "nextCursor": "eyJ0IjoxNzU4…"
  }
}

```

### What the app must do

**Move to `GET /api/v1/feed/home`.** See `09_NEW_ENDPOINTS.md` A1 and `07_FEED_ENDPOINTS_GUIDE.md`. This path is deleted once you have.

Until then it keeps working as it does today.

---

## 2. Clip feed

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/clip/feed`

**Change type**: <span style="background-color: #dc3545; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">SECURITY FIX</span> <span style="background-color: #6f42c1; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">RESPONSE</span> <span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span> · **App action**: 🔴 Required

**Access Level**: 🔒 Protected · **Authentication**: `Authorization: Bearer <access token>`

### What changed

**a) It no longer leaks private videos.** The feed used to return clips from followers-only and mentions-only posts to anyone, and clips from accounts the viewer had blocked. It now filters by the post's visibility, the viewer's follow relationship, mentions, and blocks — in the database, before paging.

**b) Moderation labels apply.** A clip whose post or author carries a moderation label is either dropped from the page or marked for an interstitial, per the rules in `01_ARCHITECTURE.md` §9.2. Followers are treated more leniently than strangers.

**c) Two new fields in each clip** (see the table below).

**d) A page can be shorter than `limit` while `hasMore` is still `true`.** The cursor is taken from the last row the database returned, *before* labels remove anything. So `clips.length < limit` no longer means "end of feed" — only `hasMore === false` and `nextCursor === null` mean that.

**e) `limit` is clamped to 1–20** (it was previously used as given).

### Query Parameters

<table id="bkmrk-parameter-type-requi"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`cursor`</td><td>string</td><td>No</td><td>Opaque page cursor from the previous response</td><td>Invalid cursor → `400`</td><td>none (first page)</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>Clips per page</td><td>**Clamped to 1–20** (new)</td><td>10</td></tr></tbody></table>

### New response fields

<table id="bkmrk-field-type-values-de"><thead><tr><th>Field</th><th>Type</th><th>Values</th><th>Description</th></tr></thead><tbody><tr><td>`visibility`</td><td>string</td><td>`ALLOW`, `INTERSTITIAL`</td><td>`INTERSTITIAL` means: do not autoplay. Show a "sensitive content — tap to view" cover first. Defaults to `ALLOW`</td></tr><tr><td>`media.durationMs`</td><td>integer</td><td>milliseconds</td><td>Clip length. Useful for the progress bar, and needed to report watch time correctly in the interactions batch</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Clip feed fetched",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "clips": [
      {
        "postId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90",
        "visibility": "ALLOW",
        "media": {
          "id": "9b1c2d3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
          "durationMs": 14200,
          "variants": { "hls": "https://…/master.m3u8", "thumb": "https://…/thumb.jpg" }
        },
        "engagement": { },
        "userInteraction": { }
      }
    ],
    "nextCursor": "MjAyNi0wOS0xN1QxMDoyMjozMTo0ZjJh…",
    "hasMore": true
  }
}

```

### What the app must do

1. **Stop using `clips.length < limit` as the end-of-feed test.** Use `hasMore`.
2. **Honour `visibility`.** `INTERSTITIAL` clips must not autoplay behind no cover.
3. Expect **fewer clips than before** for some viewers. That is correct, not a regression.

---

## 3. Single clip

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/clip/{mediaId}`

**Change type**: <span style="background-color: #dc3545; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">SECURITY FIX</span> <span style="background-color: #6f42c1; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">RESPONSE</span> · **App action**: 🟡 Optional

**Access Level**: 🔒 Protected · **Authentication**: `Authorization: Bearer <access token>`

### What changed

- **A clip the viewer may not see now returns `404`**, with the message `Clip not found`. It used to be returned in full to anyone holding the id. This covers followers-only and mentions-only posts, blocked authors, and clips dropped by a moderation label.
- Same two new fields as the feed: `visibility` and `media.durationMs`.

### Error Response JSON Sample

```json
{
  "success": false,
  "httpStatus": "NOT_FOUND",
  "message": "Clip not found",
  "action_time": "2026-09-18T10:30:45",
  "data": "Clip not found"
}

```

### What the app should do

Treat `404` here as "this clip is gone or not for you" and return the user to the previous screen — it is no longer safe to assume a `404` means deleted.

---

## 4. A user's videos

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/clip/user/{userId}/videos`

**Change type**: <span style="background-color: #dc3545; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">SECURITY FIX</span> <span style="background-color: #6f42c1; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">RESPONSE</span> <span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span> · **App action**: 🔴 Required

**Access Level**: 🔒 Protected · **Authentication**: `Authorization: Bearer <access token>`

### What changed

Exactly the same three things as the clip feed (entry 2), on a profile's video grid:

- private and blocked-author videos are filtered out; a viewer looking at someone else's profile now sees only what they are entitled to (the owner still sees their own);
- `visibility` and `media.durationMs` added;
- short pages with `hasMore: true` are possible, and `limit` is clamped to **1–20** (default 12).

### What the app must do

Use `hasMore`, not the page length, to decide whether to load more in the profile grid.

---

## 5. Record a clip view

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/v1/e-social/clip/{mediaId}/view`

**Change type**: <span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span> · **App action**: 🟡 Optional

**Access Level**: 🔒 Protected · **Authentication**: `Authorization: Bearer <access token>`

**Response**: still `204 No Content`, still no body. The request contract has not changed.

### What changed

- **A view is now counted once per viewer**, cheaply, in Redis. Repeat calls from the same viewer are accepted and ignored instead of inflating the count.
- **The `clip_views` table is no longer written.** Per-view rows are gone; the counter is what survives.
- **The request body is accepted and ignored.** Watch time does not belong here — it belongs in the interactions batch, where the recommendation model can read it (`05_INTERACTIONS_CLIENT_GUIDE.md`).

### What the app should do

Keep calling this for the view counter, and send watch time as a `VIEW` event with `watchMs` and `mediaDurationMs` to `POST /api/v1/feed/interactions/batch`. Anything sent in this body is thrown away.

---

## 6. Posts for a hashtag

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/hashtags/{hashtag}/posts`

**Change type**: <span style="background-color: #dc3545; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">SECURITY FIX</span> <span style="background-color: #fd7e14; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">PARAMS</span> <span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span> <span style="background-color: #6f42c1; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">RESPONSE</span> · **App action**: 🟢 None

**Access Level**: 🌐 Public (a signed-in viewer sees more) · **Authentication**: optional bearer token

### What changed

**a) It no longer leaks private posts.** The old version loaded every post id carrying the tag into memory and checked only "published and not deleted" — so followers-only and mentions-only posts reached anyone who opened the tag page. Visibility, mentions and blocks are now enforced in the database query itself.

**b) Paging is real.** The whole tag is no longer loaded into memory; the page and its total count are computed in the database.

**c) Ordering is by `published_at` descending** (it was `createdAt`). For a scheduled post these differ: the post now sorts by when it went live, which is what a reader expects.

**d) `page` and `size` are clamped** instead of being trusted.

**e) `topComments` is now populated** on each post, for the same reason as entry 1.

**f) Hashtags are matched in any script.** Tags are normalised the same way at write and read time, so non-Latin tags (Swahili, Arabic, CJK …) now match instead of silently returning nothing.

### Query Parameters

<table id="bkmrk-parameter-type-requi-1"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`filter`</td><td>enum</td><td>No</td><td>`ALL`, `PRODUCTS`, `SHOPS`, `EVENTS`</td><td>unknown value → `400`</td><td>`ALL`</td></tr><tr><td>`page`</td><td>integer</td><td>No</td><td>1-based page number</td><td>**Below 1 is treated as 1** (new)</td><td>1</td></tr><tr><td>`size`</td><td>integer</td><td>No</td><td>Posts per page</td><td>**Clamped to 1–50** (new)</td><td>20</td></tr></tbody></table>

### What the app should do

Nothing. Expect a signed-out viewer to see fewer posts than a signed-in one, and a blocked author's posts never to appear.

---

## 7. Trending hashtags

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/hashtags/trending`

**Change type**: <span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span> <span style="background-color: #fd7e14; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">PARAMS</span> · **App action**: 🟢 None

**Access Level**: 🌐 Public · **Authentication**: none

**Response shape**: unchanged — a list of `{ hashtag, posts }`.

### What changed

**a) Trending is now hard to game.** Tags are ranked by **how many different accounts** used them, then by how many posts. One account posting the same tag fifty times counts once, and `#sale #sale` inside one post counts once.

**b) Private and draft posts never count.** Only public, published, non-deleted posts inside the window — so a private post can no longer push its tag onto a public page.

**c) Results are cached for 5 minutes** per window. The page is public and read far more often than it changes.

### Query Parameters

<table id="bkmrk-parameter-type-requi-2"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`window`</td><td>string</td><td>No</td><td>`24h`, `7d`, `30d`. Anything else is treated as `24h`</td><td>—</td><td>`24h`</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>How many tags</td><td>**Clamped to 1–50** (new)</td><td>15</td></tr></tbody></table>

---

## 8. Hashtag suggestions (type-ahead)

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/e-social/hashtags/search`

**Change type**: <span style="background-color: #0dcaf0; color: black; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">BEHAVIOUR</span> <span style="background-color: #fd7e14; color: white; padding: 3px 7px; border-radius: 4px; font-family: monospace; font-size: 11px; font-weight: bold;">PARAMS</span> · **App action**: 🟢 None

**Access Level**: 🌐 Public · **Authentication**: none

**Response shape**: unchanged — a list of `{ hashtag, posts }`.

### What changed

- **Answered from a Redis suggestion index**, not from the database. The old query scanned and grouped the entire `post_hashtags` table **on every keystroke**.
- **Suggestions come from public posts only.** Tags that exist only on drafts or private posts are no longer suggested.
- `limit` is **clamped to 1–20** (default 5).
- An empty or blank `q` returns an empty list instead of an error.

### Query Parameters

<table id="bkmrk-parameter-type-requi-3"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`q`</td><td>string</td><td>Yes</td><td>What the user has typed so far</td><td>Blank → empty list</td><td>—</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>How many suggestions</td><td>**Clamped to 1–20** (new)</td><td>5</td></tr></tbody></table>

---

# Old endpoints being deleted

**There is no migration and no grace period.** This is a staging platform with no production users and no data worth keeping — the database will be dropped. So these paths are not being "migrated away from" over time; they are **deleted from the codebase in one commit**, as soon as the frontend confirms it has switched.

What that means for you:

- There is **no fallback**. Once the commit lands, these paths return `404`.
- There is **no dual-running period** to lean on, and no backend flag that will quietly keep them alive.
- Tell the backend team when your calls have moved. The deletion happens on that word.

The strings below are literal — copy them into your project search to find every call site.

<table id="bkmrk-search-for-this-what"><thead><tr><th>Search for this</th><th>What it is</th><th>Replace with</th><th>Notes</th></tr></thead><tbody><tr><td>`api/v1/e-social/feed`</td><td>Old home feed</td><td>`api/v1/feed/home`</td><td>Response shapes differ — see 09 A1</td></tr><tr><td>`api/v1/e-social/feed/stream`</td><td>Feed SSE stream</td><td>*nothing* — use `api/v1/events`</td><td>The chat/events stream now carries a `feed.new` hint; on receiving it, call `api/v1/feed/home/has-new`</td></tr><tr><td>`api/v1/e-social/feed/heartbeat`</td><td>SSE keep-alive</td><td>*nothing*</td><td>Dies with the stream above</td></tr><tr><td>`api/v1/e-social/clip/feed`</td><td>Clip feed</td><td>`api/v1/feed/reels`</td><td>Still amended in the meantime (entry 2), so it behaves correctly right up to the day it goes</td></tr></tbody></table>

**Not going anywhere** — keep calling these: `api/v1/e-social/clip/{mediaId}`, `api/v1/e-social/clip/{mediaId}/view`, `api/v1/e-social/clip/user/{userId}/videos`, and all three `api/v1/e-social/hashtags/…` endpoints.

A one-liner for a frontend repo:

```bash
grep -rn "e-social/feed\|e-social/clip/feed" src/ --include=*.{ts,tsx,js,jsx,dart,kt,swift}

```

Every hit is a call site that must move. When that grep returns nothing, tell the backend team — that is the signal to delete.

---

# What did *not* change

Worth stating plainly, so nobody goes looking:

- **No existing response field was renamed or removed**, anywhere.
- **`PostResponse` is unchanged.** Post, profile, search, bookmark and comment endpoints return exactly what they returned before. They are faster (a page of posts went from about 242 database queries to about 24) and they load the viewer's account once instead of twice, but the JSON is the same.
- **Engagement numbers** (likes, views, comments) keep the same field names. Some values were recounted and corrected as part of an earlier release.
- **No new notification** is produced by any endpoint in this document.
- Authentication, the response envelope and every error code behave as before.

---

**Related documents**

<table id="bkmrk-doc-what-it-covers-0"><thead><tr><th>Doc</th><th>What it covers</th></tr></thead><tbody><tr><td>[`09_NEW_ENDPOINTS.md`](09_NEW_ENDPOINTS.md)</td><td>Every new endpoint and new controller</td></tr><tr><td>[`07_FEED_ENDPOINTS_GUIDE.md`](07_FEED_ENDPOINTS_GUIDE.md)</td><td>The app team's walkthrough of the new feed screens</td></tr><tr><td>[`05_INTERACTIONS_CLIENT_GUIDE.md`](05_INTERACTIONS_CLIENT_GUIDE.md)</td><td>What the app must report back, and exact action definitions</td></tr><tr><td>[`01_ARCHITECTURE.md`](01_ARCHITECTURE.md)</td><td>The design. §A.3 is the migration and the deletion list; §9.2 is the label rule</td></tr></tbody></table>

# New Endpoints — everything the feed release adds

**Author**: Josh S. Sakweli, Backend Lead Team  
**Last Updated**: 2026-09-18  
**Version**: v1.0 (backend branch `fet/feed_baking`)

**Base URL**: `https://dev.api.nexgate.co` (staging) · `https://api.nexgate.co` (production)

**Short Description**: Every endpoint here is **new** — a new URL on a new controller. Nothing in this document replaces a field or changes an existing response. It is in two parts: **Part A** is what the apps call (the five feed surfaces and the interactions endpoint), **Part B** is what the admin panel and the operations team call (sponsored slots, business rules, moderation labels, interest mapping, backfills and the per-account debug trace).

> **Its sibling document is [`08_AMENDED_ENDPOINTS.md`](08_AMENDED_ENDPOINTS.md)** — the endpoints you already have that changed behaviour. If a path is in this file it did not exist before; if it is in that file it is not new.

**Hints**:

- Every endpoint needs `Authorization: Bearer <access token>`. There are no public endpoints here.
- Part B additionally needs an admin role, and says which one per endpoint.
- The five feed surfaces share one contract: **`feedSessionId` + `items[]` + `nextCursor`**. Learn it once.
- `nextCursor: null` means "nothing more right now" — not "never again". Pull-to-refresh starts a new session.
- Feed surfaces never fail loudly. If the recommendation service or Redis is down you get a simpler feed, not an error.
- Reporting back is not optional: a feed you do not report on cannot learn. See `05_INTERACTIONS_CLIENT_GUIDE.md`.

---

## How this document is organised

<table id="bkmrk-part-who-calls-it-co"><thead><tr><th></th><th>Part</th><th>Who calls it</th><th>Controllers</th></tr></thead><tbody><tr><td>🅰️</td><td>**[Part A — App-facing](#part-a--app-facing)**</td><td>Android, iOS, web</td><td>`HomeFeedController`, `ReelsFeedController`, `MarketFeedController`, `EventsFeedController`, `FeedListsController`, `InteractionController`</td></tr><tr><td>🅱️</td><td>**[Part B — Admin &amp; operations](#part-b--admin--operations)**</td><td>Admin panel, ops</td><td>`PromotionAdminController`, `LabelAdminController`, `InterestAdminController`, `FeedBackfillController`</td></tr></tbody></table>

Each entry carries an access chip:

<table id="bkmrk-chip-meaning-%F0%9F%94%92-user-"><thead><tr><th>Chip</th><th>Meaning</th></tr></thead><tbody><tr><td>🔒 **User**</td><td>Any signed-in account</td></tr><tr><td>🛡️ **Admin**</td><td>`ROLE_SUPER_ADMIN` or `ROLE_STAFF_ADMIN`</td></tr><tr><td>🛡️ **Super admin**</td><td>`ROLE_SUPER_ADMIN` only</td></tr></tbody></table>

---

## Index

### Part A — App-facing

<table id="bkmrk-%23-endpoint-purpose-r"><thead><tr><th>\#</th><th>Endpoint</th><th>Purpose</th><th>Replaces</th></tr></thead><tbody><tr><td>A1</td><td>`GET /api/v1/feed/home`</td><td>The home feed: posts and products</td><td>`api/v1/e-social/feed`</td></tr><tr><td>A2</td><td>`GET /api/v1/feed/home/has-new`</td><td>The "new posts" bubble</td><td>the feed SSE stream</td></tr><tr><td>A3</td><td>`GET /api/v1/feed/reels`</td><td>The reels feed</td><td>`api/v1/e-social/clip/feed`</td></tr><tr><td>A4</td><td>`GET /api/v1/feed/marketplace`</td><td>Marketplace "For You"</td><td>—</td></tr><tr><td>A5</td><td>`GET /api/v1/feed/events`</td><td>Events "For You"</td><td>—</td></tr><tr><td>A6</td><td>`GET /api/v1/feed/products/{productId}/similar`</td><td>"Similar items" on a product page</td><td>—</td></tr><tr><td>A7</td><td>`GET /api/v1/feed/shops/suggested`</td><td>"Shops you might like"</td><td>—</td></tr><tr><td>A8</td><td>`POST /api/v1/feed/interactions/batch`</td><td>Report what the user did</td><td>—</td></tr></tbody></table>

### Part B — Admin &amp; operations

<table id="bkmrk-%23-endpoint-purpose-r-1"><thead><tr><th>\#</th><th>Endpoint</th><th>Purpose</th><th>Role</th></tr></thead><tbody><tr><td>B1</td><td>`POST /api/admin/feed/sponsored`</td><td>Start a sponsored campaign</td><td>Admin</td></tr><tr><td>B2</td><td>`GET /api/admin/feed/sponsored`</td><td>List active campaigns</td><td>Admin</td></tr><tr><td>B3</td><td>`DELETE /api/admin/feed/sponsored/{id}`</td><td>Stop a campaign</td><td>Admin</td></tr><tr><td>B4</td><td>`POST /api/admin/feed/business-rules`</td><td>Boost or suppress something for a period</td><td>Admin</td></tr><tr><td>B5</td><td>`GET /api/admin/feed/business-rules`</td><td>List running and upcoming rules</td><td>Admin</td></tr><tr><td>B6</td><td>`DELETE /api/admin/feed/business-rules/{id}`</td><td>End a rule now</td><td>Admin</td></tr><tr><td>B7</td><td>`GET /api/admin/feed/debug/{accountId}`</td><td>Why this person saw this feed</td><td>Admin</td></tr><tr><td>B8</td><td>`POST /api/admin/feed/labels`</td><td>Label a post, product, shop or account</td><td>Admin</td></tr><tr><td>B9</td><td>`DELETE /api/admin/feed/labels/{labelId}`</td><td>Remove a label</td><td>Admin</td></tr><tr><td>B10</td><td>`GET /api/admin/feed/labels`</td><td>Labels on one target, with history</td><td>Admin</td></tr><tr><td>B11</td><td>`GET /api/admin/feed/interests`</td><td>The interest list and every mapping</td><td>Admin</td></tr><tr><td>B12</td><td>`GET /api/admin/feed/interests/unmapped`</td><td>What still needs mapping</td><td>Admin</td></tr><tr><td>B13</td><td>`PUT /api/admin/feed/interests/product-categories/{categoryId}`</td><td>Map a product category</td><td>Admin</td></tr><tr><td>B14</td><td>`PUT /api/admin/feed/interests/event-categories/{categoryId}`</td><td>Map an event category</td><td>Admin</td></tr><tr><td>B15</td><td>`PUT /api/admin/feed/interests/hashtags/{hashtag}`</td><td>Map a hashtag</td><td>Admin</td></tr><tr><td>B16</td><td>`POST /api/admin/feed/backfills`</td><td>Republish existing data into the feed</td><td>Super admin</td></tr><tr><td>B17</td><td>`GET /api/admin/feed/backfills`</td><td>Recent backfill runs</td><td>Super admin</td></tr><tr><td>B18</td><td>`POST /api/admin/feed/backfills/{runId}/cancel`</td><td>Stop a run</td><td>Super admin</td></tr></tbody></table>

---

## Standard Response Format

Same envelope as the rest of the platform.

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Home feed",
  "action_time": "2026-09-18T10:30:45",
  "data": { }
}

```

```json
{
  "success": false,
  "httpStatus": "NOT_FOUND",
  "message": "No active campaign 42",
  "action_time": "2026-09-18T10:30:45",
  "data": "No active campaign 42"
}

```

**Errors used in this document**: `400` bad request (a rule or campaign that makes no sense, an unknown label, an unknown backfill aggregate), `401` missing/expired token, `403` wrong role, `404` not found (unknown id, unknown surface, no debug trace), `422` validation failure with a field map, `500` server error.

---

# Part A — App-facing

## The shared feed contract

A4, A5, A1 and A3 all answer with the same three things:

<table id="bkmrk-field-type-descripti"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`feedSessionId`</td><td>string</td><td>The session this page came from. **Send it back** in every interaction as `context.feedSessionId` — it is how a like is tied to the feed that showed the item</td></tr><tr><td>`items`</td><td>array</td><td>The items, already in display order</td></tr><tr><td>`nextCursor`</td><td>string | null</td><td>Pass it as `cursor` for the next page. `null` = nothing more right now</td></tr></tbody></table>

And every item carries:

<table id="bkmrk-field-type-descripti-1"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`position`</td><td>integer</td><td>The item's place in the feed, from 0. **Send it back** as `context.position`</td></tr><tr><td>`source`</td><td>string</td><td>Why this item is here. **Send it back** as `context.source`</td></tr><tr><td>`visibility`</td><td>string</td><td>`ALLOW`, or `INTERSTITIAL` → show a "sensitive content, tap to view" cover before the media</td></tr></tbody></table>

**`source` values**: `FOLLOWING`, `CELEBRITY`, `RECO`, `INTEREST`, `FALLBACK`, `SPONSORED`, `TRENDING`, `SEARCH`. Do not invent others — the interactions endpoint rejects unknown ones.

---

## A1. Home feed

**Purpose**: The main feed. Mixes posts from people the user follows, recommended posts, and products.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/home`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

**Replaces**: `api/v1/e-social/feed`

### Query Parameters

<table id="bkmrk-parameter-type-requi"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`cursor`</td><td>string</td><td>No</td><td>Cursor from the previous page. Omit for the first page, and omit on pull-to-refresh to start a new session</td><td>opaque</td><td>none</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>Items per page</td><td>clamped to 1–30</td><td>15</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Home feed",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "feedSessionId": "fs-77aa31c8",
    "items": [
      {
        "type": "POST",
        "position": 0,
        "source": "FOLLOWING",
        "visibility": "ALLOW",
        "post": { "postId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90" },
        "product": null
      },
      {
        "type": "PRODUCT",
        "position": 1,
        "source": "RECO",
        "visibility": "ALLOW",
        "post": null,
        "product": { "productId": "8c31a0d2-77e4-4b1a-9f3c-2d5e6a7b8c90" }
      }
    ],
    "nextCursor": "eyJzIjoiZnMtNzdhYTMxYzgiLCJwIjoxNX0"
  }
}

```

### Success Response Fields

<table id="bkmrk-field-description-it"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`items[].type`</td><td>`POST` or `PRODUCT`. Exactly one of `post` / `product` is filled; the other is `null`</td></tr><tr><td>`items[].post`</td><td>A full `PostResponse` — the same object the rest of the API returns for a post</td></tr><tr><td>`items[].product`</td><td>A `ProductSummaryResponse` — the same object the marketplace list returns</td></tr></tbody></table>

### Notes

- The feed is built once per session and paged from it, so a page read is fast and the order is stable while the user scrolls.
- Items are filtered again at page-read time, so something deleted, blocked or muted mid-scroll does not appear.
- If the feed store is unavailable the user gets a simpler feed from recent posts, never an error.

---

## A2. New posts available ("the bubble")

**Purpose**: Tells the app whether to show the "N new posts" bubble at the top of the home feed.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/home/has-new`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

**Replaces**: the old feed SSE stream (`api/v1/e-social/feed/stream`)

### Query Parameters

<table id="bkmrk-parameter-type-requi-1"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th></tr></thead><tbody><tr><td>`since`</td><td>integer</td><td>**Yes**</td><td>`publishedAt` of the newest post the app is currently showing, as epoch **milliseconds**</td><td>must parse as a long</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "New posts",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "count": 7,
    "more": false,
    "authors": [
      { "accountId": "…", "userName": "mariam", "profileMedia": { } }
    ],
    "nextPollIn": 300
  }
}

```

### Success Response Fields

<table id="bkmrk-field-description-co"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`count`</td><td>How many new posts, **capped at 20**</td></tr><tr><td>`more`</td><td>`true` when there were more than 20 — show "20+"</td></tr><tr><td>`authors`</td><td>Up to 3 authors, for the avatars on the bubble</td></tr><tr><td>`nextPollIn`</td><td>Seconds to wait before asking again. **Read this from the response, do not hard-code it** — the server lengthens it under load, with no app release</td></tr></tbody></table>

### When to call it

<table id="bkmrk-moment-action-feed-c"><thead><tr><th>Moment</th><th>Action</th></tr></thead><tbody><tr><td>Feed comes on screen</td><td>Call once</td></tr><tr><td>Feed stays on screen</td><td>Call again after `nextPollIn` seconds</td></tr><tr><td>A `feed.new` event arrives on the `/api/v1/events` stream</td><td>Call immediately</td></tr><tr><td>`count > 0` and the user has scrolled down</td><td>Show the bubble</td></tr><tr><td>`count > 0` and the user is already at the top</td><td>No bubble — fetch and insert the posts</td></tr><tr><td>Bubble tapped</td><td>Call `GET /api/v1/feed/home` **with no cursor** and prepend the result above the existing list</td></tr></tbody></table>

> **The `feed.new` event.** The existing chat/events stream now carries an event named `feed.new` with an empty body on the account's personal channel. It carries no posts and no count — it only means "call `has-new` now". It is throttled to at most one a minute, and it is an optimisation: if the stream is down, the next poll finds the posts anyway.

---

## A3. Reels feed

**Purpose**: The vertical clip feed.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/reels`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

**Replaces**: `api/v1/e-social/clip/feed`

### Query Parameters

<table id="bkmrk-parameter-type-requi-2"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`cursor`</td><td>string</td><td>No</td><td>Cursor from the previous page</td><td>opaque</td><td>none</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>Clips per page</td><td>clamped to 1–30</td><td>10</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Reels",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "feedSessionId": "fs-91bd4410",
    "items": [
      {
        "position": 0,
        "source": "RECO",
        "clip": {
          "postId": "…",
          "visibility": "ALLOW",
          "media": { "id": "…", "durationMs": 14200, "variants": { "hls": "https://…/master.m3u8" } }
        }
      }
    ],
    "nextCursor": "eyJzIjoiZnMtOTFiZDQ0MTAiLCJwIjoxMH0"
  }
}

```

### Notes

- `clip` is the same `ClipResponse` the existing clip screen already renders — the reels screen does not need a new renderer.
- Here `visibility` sits **on the clip**, not on the item wrapper.
- Reels have their own "already served" memory: scrolling reels does not use up the home feed, and vice versa.

---

## A4. Marketplace "For You"

**Purpose**: A personalised product feed for the marketplace tab.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/marketplace`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

### Query Parameters

<table id="bkmrk-parameter-type-requi-3"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`cursor`</td><td>string</td><td>No</td><td>Cursor from the previous page</td><td>opaque</td><td>none</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>Products per page</td><td>clamped to 1–30</td><td>20</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Marketplace for you",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "feedSessionId": "fs-2210ab7e",
    "items": [
      { "position": 0, "source": "RECO", "visibility": "ALLOW", "product": { "productId": "…" } },
      { "position": 1, "source": "SPONSORED", "visibility": "ALLOW", "product": { "productId": "…" } }
    ],
    "nextCursor": null
  }
}

```

**`source` here means**: `RECO` (the model), `FOLLOWING` (a shop the user subscribes to), `SPONSORED` (a paid slot — label it in the UI), `FALLBACK` (trending, new shops, or products seen in posts).

---

## A5. Events "For You"

**Purpose**: Event discovery.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/events`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

### Query Parameters

<table id="bkmrk-parameter-type-requi-4"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`cursor`</td><td>string</td><td>No</td><td>Cursor from the previous page</td><td>opaque</td><td>none</td></tr><tr><td>`limit`</td><td>integer</td><td>No</td><td>Events per page</td><td>clamped to 1–30</td><td>20</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Events for you",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "feedSessionId": "fs-55c0d913",
    "items": [
      { "position": 0, "source": "FOLLOWING", "visibility": "ALLOW", "event": { "eventId": "…" } }
    ],
    "nextCursor": "eyJzIjoiZnMtNTVjMGQ5MTMiLCJwIjoyMH0"
  }
}

```

**`source` here means**: `RECO`, `FOLLOWING` (an organiser the user follows), `FALLBACK` (upcoming events, by bookings). `event` is the usual `EventSummaryResponse`.

---

## A6. Similar items

**Purpose**: The "Similar items" strip on a product page.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/products/{productId}/similar`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

### Path Parameters

<table id="bkmrk-parameter-type-requi-5"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td>`productId`</td><td>UUID</td><td>Yes</td><td>The product being viewed</td></tr></tbody></table>

### Query Parameters

<table id="bkmrk-parameter-type-requi-6"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`limit`</td><td>integer</td><td>No</td><td>How many products</td><td>clamped to 1–50</td><td>20</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Similar items",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "items": [
      { "source": "RECO", "visibility": "ALLOW", "product": { "productId": "…" } },
      { "source": "CATEGORY", "visibility": "ALLOW", "product": { "productId": "…" } }
    ]
  }
}

```

**No paging and no `feedSessionId`** — this is a strip, not a feed. `source` is `RECO` (the model's similar items) or `CATEGORY` (best sellers of the same category, used when the model has nothing).

---

## A7. Suggested shops

**Purpose**: The "Shops you might like" strip.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/v1/feed/shops/suggested`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

### Query Parameters

<table id="bkmrk-parameter-type-requi-7"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th><th>Default</th></tr></thead><tbody><tr><td>`limit`</td><td>integer</td><td>No</td><td>How many shops</td><td>clamped to 1–30</td><td>10</td></tr></tbody></table>

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Suggested shops",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "items": [
      {
        "source": "NEW",
        "shopId": "…",
        "shopName": "Kilimanjaro Coffee",
        "shopSlug": "kilimanjaro-coffee",
        "shopDescription": "Single-origin roasters",
        "logo": { },
        "isVerified": true,
        "city": "Arusha",
        "region": "Arusha",
        "subscriberCount": 1284
      }
    ]
  }
}

```

**`source`**: `RECO`, `NEW` (opened in the last 30 days) or `POPULAR` (most subscribers). **No contact details are returned** — the viewer does not follow this shop yet.

---

## A8. Report interactions

**Purpose**: Send what the user did, in batches. This is the only way the recommendation model learns.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/v1/feed/interactions/batch`

**Access**: 🔒 User · **Authentication**: `Authorization: Bearer <access token>`

**Success status**: `202 Accepted`

> **Full definitions of every action live in [`05_INTERACTIONS_CLIENT_GUIDE.md`](05_INTERACTIONS_CLIENT_GUIDE.md).** That document is the contract; this entry is the reference. What your batch becomes once the backend has it is [`10_EVENT_CATALOGUE.md`](10_EVENT_CATALOGUE.md) §5.

> **This is the only endpoint on the whole platform that puts anything on the event stream from the app.** Everything else the model learns — posts, products, shops, events, profiles, follows, blocks, orders, wishlists, and even likes and purchases — is published automatically from committed database rows. The app never announces that something exists or changed; it only reports what it alone can see: impressions, views, watch time, skips, clicks and searches.

### Request JSON Sample

```json
{
  "sessionId": "s-8812e0c4",
  "client": { "platform": "ANDROID", "appVersion": "2.4.1", "networkType": "CELLULAR" },
  "events": [
    {
      "clientEventId": "c-000184",
      "action": "IMPRESSION",
      "targetType": "POST",
      "targetId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90",
      "occurredAt": "2026-09-18T10:22:31.900Z",
      "context": { "surface": "REELS", "position": 3, "feedSessionId": "fs-77aa", "source": "RECO" }
    },
    {
      "clientEventId": "c-000185",
      "action": "VIEW",
      "targetType": "POST",
      "targetId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90",
      "occurredAt": "2026-09-18T10:22:41.100Z",
      "watchMs": 9000,
      "mediaDurationMs": 6000,
      "loopCount": 1,
      "soundOn": true,
      "mediaIndex": 0,
      "context": { "surface": "REELS", "position": 3, "feedSessionId": "fs-77aa", "source": "RECO" }
    }
  ]
}

```

### Request Body Parameters

<table id="bkmrk-parameter-type-requi-8"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th></tr></thead><tbody><tr><td>`sessionId`</td><td>string</td><td>No</td><td>The app session</td><td>—</td></tr><tr><td>`client.platform`</td><td>string</td><td>No</td><td>`ANDROID`, `IOS`, `WEB`</td><td>enum</td></tr><tr><td>`client.appVersion`</td><td>string</td><td>No</td><td>e.g. `2.4.1`</td><td>—</td></tr><tr><td>`client.networkType`</td><td>string</td><td>No</td><td>`WIFI`, `CELLULAR`, `OFFLINE`, `UNKNOWN`</td><td>enum</td></tr><tr><td>`events`</td><td>array</td><td>Yes</td><td>The batch</td><td>**max 100 per request**</td></tr><tr><td>`events[].clientEventId`</td><td>string</td><td>Yes</td><td>Your own id, unique within the batch</td><td>max 64 chars</td></tr><tr><td>`events[].action`</td><td>string</td><td>Yes</td><td>See the action table below</td><td>enum</td></tr><tr><td>`events[].targetType`</td><td>string</td><td>Yes\*</td><td>`POST`, `PRODUCT`, `SHOP`, `EVENT`, `PROFILE`</td><td>\*not required for `SEARCH`</td></tr><tr><td>`events[].targetId`</td><td>string</td><td>Yes\*</td><td>UUID of the target</td><td>must be a UUID</td></tr><tr><td>`events[].occurredAt`</td><td>string</td><td>Yes</td><td>When it happened, on the device</td><td>ISO 8601</td></tr><tr><td>`events[].dwellMs` / `watchMs`</td><td>integer</td><td>No</td><td>Time on screen / watched</td><td>0 – 6 h</td></tr><tr><td>`events[].mediaDurationMs`</td><td>integer</td><td>No</td><td>Length of the media</td><td>0 – 12 h</td></tr><tr><td>`events[].loopCount`</td><td>integer</td><td>No</td><td>Replays</td><td>0 – 1000</td></tr><tr><td>`events[].soundOn`</td><td>boolean</td><td>No</td><td>Was sound on</td><td>—</td></tr><tr><td>`events[].mediaIndex`</td><td>integer</td><td>No</td><td>Which item of a carousel</td><td>0 – 100</td></tr><tr><td>`events[].context.surface`</td><td>string</td><td>No</td><td>`FEED`, `REELS`, `SEARCH`, `PRODUCT_PAGE`, `SHOP_PAGE`, `EVENT_PAGE`, `PROFILE_PAGE`, `RECOMMENDATION`, `NOTIFICATION`, `CHAT`</td><td>enum</td></tr><tr><td>`events[].context.position`</td><td>integer</td><td>No</td><td>The item's position in the feed</td><td>—</td></tr><tr><td>`events[].context.feedSessionId`</td><td>string</td><td>No</td><td>From the feed response</td><td>—</td></tr><tr><td>`events[].context.source`</td><td>string</td><td>No</td><td>From the item</td><td>enum</td></tr><tr><td>`events[].context.viaPostId`</td><td>string</td><td>No</td><td>The post a product was reached through</td><td>UUID</td></tr><tr><td>`events[].context.query`</td><td>string</td><td>No</td><td>The search text, for `SEARCH`</td><td>—</td></tr></tbody></table>

**Unknown fields are ignored**, deliberately: a newer app adding a field must never lose the batch.

### Actions the app sends

`IMPRESSION`, `VIEW`, `SKIP`, `CLICK`, `SEARCH`, `NOT_INTERESTED`, `HIDE`.

### Actions the app must NOT send

`LIKE`, `UNLIKE`, `SHARE`, `BOOKMARK`, `COMMENT`, `ADD_TO_CART`, `REMOVE_FROM_CART`, `PURCHASE`, `BLOCK`, `REPORT`, `MUTE_AUTHOR`.

**The backend sends these itself** when the change commits, so an app that also sent them would count every like twice. They come back rejected with `SENT_BY_SERVER`. Call the normal feature API (the like endpoint, the cart endpoint) and the interaction is recorded for you.

### Success Response JSON Sample

```json
{
  "success": true,
  "httpStatus": "ACCEPTED",
  "message": "Interactions received",
  "action_time": "2026-09-18T10:30:45",
  "data": { "accepted": 2, "dropped": 0, "rejected": [] }
}

```

<table id="bkmrk-field-description-ac"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`accepted`</td><td>Kept</td></tr><tr><td>`dropped`</td><td>Valid but not kept (rate limit, or the event log was briefly unavailable). **Do not resend** — these are signal, not records</td></tr><tr><td>`rejected`</td><td>Invalid. **Do not resend.** Each entry is `{ clientEventId, reason }` — a rejection is a bug in the app; log it in debug builds</td></tr></tbody></table>

### Rejection reasons

`CLIENT_EVENT_ID_REQUIRED_MAX_64`, `UNKNOWN_ACTION`, `SENT_BY_SERVER`, `UNKNOWN_TARGET_TYPE`, `TARGET_ID_NOT_UUID`, `DUPLICATE_CLIENT_EVENT_ID`, `OCCURRED_AT_NOT_ISO_8601`, `DURATION_OUT_OF_RANGE`, `LOOP_COUNT_OUT_OF_RANGE`, `MEDIA_INDEX_OUT_OF_RANGE`, `UNKNOWN_SURFACE`, `UNKNOWN_SOURCE`, `POSITION_OUT_OF_RANGE`, `VIA_POST_ID_NOT_UUID`, `BATCH_LIMIT_100`, `EMPTY_EVENT`.

---

# Part B — Admin &amp; operations

Everything below is on `/api/admin/feed`. A signed-in account without the role gets `403`.

## Sponsored campaigns

A sponsored campaign places one product at fixed slots in **home and marketplace** sessions, while it is inside its window. A viewer sees the same campaign at most `feed.sponsored.capPerDay` times in 24 hours (a `feed_params` setting), so a campaign cannot follow someone down the page.

Campaigns and rules are re-read from the database **at most every 60 seconds**, so a change can take up to a minute to show up in feeds.

> **Not built yet**: billing, budgets and self-serve. A campaign today is created by staff and runs between two dates, free.

### B1. Start a campaign

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/admin/feed/sponsored` · **Access**: 🛡️ Admin

**Request JSON Sample**

```json
{
  "productId": "8c31a0d2-77e4-4b1a-9f3c-2d5e6a7b8c90",
  "startsAt": "2026-10-01T00:00:00Z",
  "endsAt": "2026-10-15T00:00:00Z"
}

```

<table id="bkmrk-parameter-type-requi-9"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Validation</th></tr></thead><tbody><tr><td>`productId`</td><td>UUID</td><td>Yes</td><td>the product must exist, else `400 Product not found`</td></tr><tr><td>`startsAt`</td><td>ISO 8601 instant</td><td>Yes</td><td>—</td></tr><tr><td>`endsAt`</td><td>ISO 8601 instant</td><td>Yes</td><td>must be after `startsAt`, else `400 endsAt must be after startsAt`</td></tr></tbody></table>

**Success Response**

```json
{
  "success": true, "httpStatus": "OK", "message": "Campaign added",
  "action_time": "2026-09-18T10:30:45",
  "data": { "id": 42, "productId": "8c31a0d2-…", "startsAt": "2026-10-01T00:00:00Z", "endsAt": "2026-10-15T00:00:00Z", "active": false }
}

```

`active` is `true` only while the campaign is inside its window.

### B2. List campaigns

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/sponsored` · **Access**: 🛡️ Admin

Returns an array of the object above — running and upcoming campaigns.

### B3. Stop a campaign

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `/api/admin/feed/sponsored/{id}` · **Access**: 🛡️ Admin

<table id="bkmrk-parameter-type-requi-10"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td>`id`</td><td>integer</td><td>Yes</td><td>The campaign id from B1/B2</td></tr></tbody></table>

Ends it immediately. An id that is not an active campaign gives `404 No active campaign {id}`.

---

## Business rules

A business rule multiplies the feed score of everything matching it, for a period. It is the lever for "push this category this week" or "quieten this shop while we investigate" — without touching code. **Several matching rules multiply together**, and like campaigns they are re-read at most every 60 seconds.

### B4. Add a rule

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/admin/feed/business-rules` · **Access**: 🛡️ Admin

**Request JSON Sample**

```json
{
  "matchType": "CATEGORY",
  "matchValue": "b81d9a34-5f2c-4c7e-9a10-3e5d7f9b1c22",
  "multiplier": 1.4,
  "startsAt": "2026-10-01T00:00:00Z",
  "endsAt": "2026-10-08T00:00:00Z"
}

```

<table id="bkmrk-parameter-type-requi-11"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th></tr></thead><tbody><tr><td>`matchType`</td><td>string</td><td>Yes</td><td>`PRODUCT`, `SHOP`, `CATEGORY` (all three match products) or `ACCOUNT` (matches posts by, and events organised by, that account)</td><td>enum</td></tr><tr><td>`matchValue`</td><td>string</td><td>Yes</td><td>The id of that product / shop / category / account</td><td>36-character UUID</td></tr><tr><td>`multiplier`</td><td>number</td><td>Yes</td><td>Above 1 boosts, below 1 suppresses</td><td>**0.1 – 3.0**</td></tr><tr><td>`startsAt`</td><td>ISO 8601 instant</td><td>Yes</td><td>—</td><td>—</td></tr><tr><td>`endsAt`</td><td>ISO 8601 instant</td><td>Yes</td><td>—</td><td>must be after `startsAt`</td></tr></tbody></table>

**Success Response**

```json
{
  "success": true, "httpStatus": "OK", "message": "Rule added",
  "action_time": "2026-09-18T10:30:45",
  "data": { "id": 17, "matchType": "CATEGORY", "matchValue": "b81d9a34-…", "multiplier": 1.4,
            "startsAt": "2026-10-01T00:00:00Z", "endsAt": "2026-10-08T00:00:00Z" }
}

```

### B5. List rules

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/business-rules` · **Access**: 🛡️ Admin

Running and upcoming rules, as an array of the object above. Expired rules are not listed.

### B6. End a rule now

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `/api/admin/feed/business-rules/{id}` · **Access**: 🛡️ Admin

`404 No running or upcoming rule {id}` if it has already finished.

---

## B7. Why did this person see this feed?

**Purpose**: The per-account debug trace. For support and for tuning: it shows every item the last feed build produced, and **every item it threw away and why**.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/debug/{accountId}` · **Access**: 🛡️ Admin

### Path &amp; Query Parameters

<table id="bkmrk-parameter-type-requi-12"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Default</th></tr></thead><tbody><tr><td>`accountId`</td><td>UUID</td><td>Yes</td><td>Whose feed</td><td>—</td></tr><tr><td>`surface`</td><td>string</td><td>No</td><td>`HOME`, `REELS`, `MARKET`, `EVENTS`</td><td>`HOME`</td></tr></tbody></table>

### How to switch it on

Tracing is **off** unless the account is listed. Add the account id to the `feed.debug.accounts` row in the `feed_params` table, then have that person (or a test account) open the feed. The trace is kept for **1 hour**.

### Success Response JSON Sample

```json
{
  "success": true, "httpStatus": "OK", "message": "Last session trace",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "surface": "HOME",
    "builtAt": 1789412345678,
    "shown": [
      {
        "position": 0, "type": "POST", "id": "4f2a77e1-…", "source": "FOLLOWING",
        "baseScore": 1.81, "score": 2.35, "visibility": "ALLOW",
        "trace": ["NewShopBoost x1.3"]
      }
    ],
    "removed": [ { "type": "POST", "id": "9b10…", "source": "FALLBACK", "filter": "Seen" } ]
  }
}

```

### Errors

<table id="bkmrk-status-when-404-unkn"><thead><tr><th>Status</th><th>When</th></tr></thead><tbody><tr><td>`404 Unknown surface {x}`</td><td>`surface` is not one of the four</td></tr><tr><td>`404 No trace: add the account to feed_params 'feed.debug.accounts', then open that feed (kept 1 h)`</td><td>Tracing was off, or the trace expired</td></tr></tbody></table>

---

## Moderation labels

A label is a moderation outcome attached to a post, product, shop or account. The feed reads labels; it has no special cases of its own. **Labels do not delete anything** — they decide who stops seeing it.

**The rule** (first match wins, and strangers are treated more strictly than followers, because following was the user's choice and discovery is ours):

<table id="bkmrk-label-viewer-follows"><thead><tr><th>Label</th><th>Viewer follows the author</th><th>Viewer does not follow</th></tr></thead><tbody><tr><td>`suspended` (account or item), `prohibited_item`</td><td>removed</td><td>removed</td></tr><tr><td>`counterfeit_suspected`</td><td>shown</td><td>removed</td></tr><tr><td>`spam_suspected` (account)</td><td>shown</td><td>removed</td></tr><tr><td>`reported_threshold`</td><td>shown</td><td>removed</td></tr><tr><td>`low_quality_media`</td><td>shown</td><td>removed</td></tr><tr><td>`sensitive_media`</td><td>shown behind a cover (`INTERSTITIAL`)</td><td>removed</td></tr></tbody></table>

`reported_threshold` is applied automatically when a post passes the report threshold. The rest are applied by staff, by a rule, or by a model.

### B8. Add a label

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/admin/feed/labels` · **Access**: 🛡️ Admin

**Request JSON Sample**

```json
{
  "targetType": "POST",
  "targetId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90",
  "label": "sensitive_media",
  "expiresInHours": 72
}

```

<table id="bkmrk-parameter-type-requi-13"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th></tr></thead><tbody><tr><td>`targetType`</td><td>string</td><td>Yes</td><td>`POST`, `PRODUCT`, `SHOP`, `ACCOUNT`</td><td>else `400 Unknown target type`</td></tr><tr><td>`targetId`</td><td>UUID</td><td>Yes</td><td>What to label</td><td>—</td></tr><tr><td>`label`</td><td>string</td><td>Yes</td><td>`suspended`, `spam_suspected`, `counterfeit_suspected`, `sensitive_media`, `prohibited_item`, `reported_threshold`, `low_quality_media`</td><td>else `400 Unknown label '…'`</td></tr><tr><td>`expiresInHours`</td><td>integer</td><td>No</td><td>**Omit for a permanent label**</td><td>must be positive</td></tr></tbody></table>

### B9. Remove a label

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `/api/admin/feed/labels/{labelId}` · **Access**: 🛡️ Admin

`404` if that label id does not exist.

### B10. Labels on one target

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/labels?targetType={type}&targetId={id}` · **Access**: 🛡️ Admin

**Success Response JSON Sample**

```json
{
  "success": true, "httpStatus": "OK", "message": "Labels",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "targetType": "POST",
    "targetId": "4f2a77e1-…",
    "active": ["sensitive_media"],
    "history": [
      {
        "id": 91, "label": "sensitive_media", "source": "MANUAL", "created_by": "…",
        "created_at": "2026-09-18T09:00:00Z", "expires_at": "2026-09-21T09:00:00Z", "removed_at": null
      }
    ]
  }
}

```

`active` is what is in force now; `history` includes removed and expired labels, so a decision can be explained later. `source` is `MANUAL`, `RULE`, `REPORT_THRESHOLD` or `MODEL`.

---

## Interest mapping

Interests are the shared language between social, marketplace and events: one person has one interest profile, and a like on a post can move what they see in the shop. For that to work, **every product category, event category and popular hashtag must be mapped to interests**.

> ⚠️ **This mapping is not finished.** Use B12 to see what is still missing. Until a category is mapped, its items reach people only through follows, trending and fallbacks — not through interests.

### B11. The interest list and all mappings

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/interests` · **Access**: 🛡️ Admin

```json
{
  "success": true, "httpStatus": "OK", "message": "Interest mappings",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "interests": [ { "id": "…", "name": "Fashion" } ],
    "productCategories": [ { "key": "b81d9a34-…", "name": "Sneakers", "interestIds": ["…"] } ],
    "eventCategories":   [ { "key": "…", "name": "Live music", "interestIds": ["…"] } ],
    "hashtags":          [ { "key": "mitumba", "name": "#mitumba", "interestIds": ["…"] } ]
  }
}

```

For a hashtag, `key` is the tag **without** the `#`.

### B12. What still needs mapping

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/interests/unmapped` · **Access**: 🛡️ Admin

```json
{
  "success": true, "httpStatus": "OK", "message": "Still to map",
  "action_time": "2026-09-18T10:30:45",
  "data": {
    "productCategories": [ { "id": "…", "name": "Sneakers" } ],
    "eventCategories":   [ { "id": "…", "name": "Workshops" } ],
    "hashtags":          [ { "hashtag": "mitumba", "posts": 812 } ]
  }
}

```

A product category counts as mapped when a parent category is mapped. Hashtags listed are the most used unmapped tags on public posts of the last 30 days — the ones worth mapping first.

### B13–B15. Map a category or hashtag

**Endpoints**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `/api/admin/feed/interests/product-categories/{categoryId}` · `/event-categories/{categoryId}` · `/hashtags/{hashtag}` · **Access**: 🛡️ Admin

**Request JSON Sample**

```json
{ "interestIds": ["a1b2c3d4-…", "e5f6a7b8-…"] }

```

<table id="bkmrk-parameter-type-requi-14"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th></tr></thead><tbody><tr><td>`interestIds`</td><td>array of UUID</td><td>Yes</td><td>**Replaces** the whole mapping for that key. An **empty array removes** the mapping</td><td>at most **5**; every id must exist, else `404 Unknown interest id in […]`</td></tr></tbody></table>

For `/hashtags/{hashtag}`, pass the tag with or without `#` — it is normalised. A value that is not a usable tag gives `404 Not a hashtag: …`. An unknown category id gives `404 … not found: {id}`.

---

## Backfills

A backfill republishes data that already exists into the feed's event log — used when a new field is added, after an incident, or when a topic is rebuilt from scratch. Existing rows are read and re-sent; **nothing is modified**.

### B16. Start a backfill

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/admin/feed/backfills` · **Access**: 🛡️ Super admin

**Request JSON Sample**

```json
{ "aggregate": "product" }

```

<table id="bkmrk-parameter-type-requi-15"><thead><tr><th>Parameter</th><th>Type</th><th>Required</th><th>Description</th><th>Validation</th></tr></thead><tbody><tr><td>`aggregate`</td><td>string</td><td>Yes</td><td>`post`, `follow`, `block`, `product`, `shop`, `event`, `profile`, `order`, `wishlist`, or `all`</td><td>else `400 Unknown aggregate '…'`</td></tr></tbody></table>

**Only one run per aggregate at a time.** Asking again while one is pending or running returns that existing run instead of starting a second.

### B17. Recent runs

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `/api/admin/feed/backfills` · **Access**: 🛡️ Super admin

```json
{
  "success": true, "httpStatus": "OK", "message": "Backfill runs",
  "action_time": "2026-09-18T10:30:45",
  "data": [
    {
      "id": 7, "aggregate": "product", "status": "RUNNING", "lastKey": "8c31a0d2-…",
      "keysDone": 18400, "keysFailed": 0, "requestedBy": "…",
      "createdAt": "2026-09-18T10:00:00Z", "startedAt": "2026-09-18T10:00:02Z",
      "finishedAt": null, "error": null
    }
  ]
}

```

<table id="bkmrk-field-description-st"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`status`</td><td>`PENDING`, `RUNNING`, `DONE`, `CANCELLED`, `FAILED`</td></tr><tr><td>`lastKey`</td><td>Where it has reached — a cancelled or failed run resumes from here rather than starting over</td></tr><tr><td>`keysDone` / `keysFailed`</td><td>Progress</td></tr><tr><td>`error`</td><td>Why it failed, when it did</td></tr></tbody></table>

### B18. Cancel a run

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `/api/admin/feed/backfills/{runId}/cancel` · **Access**: 🛡️ Super admin

Stops the run at its current key. `404` if that run id does not exist.

---

**Related documents**

<table id="bkmrk-doc-what-it-covers-0"><thead><tr><th>Doc</th><th>What it covers</th></tr></thead><tbody><tr><td>[`08_AMENDED_ENDPOINTS.md`](08_AMENDED_ENDPOINTS.md)</td><td>Existing endpoints that changed, and the old endpoints being deleted</td></tr><tr><td>[`07_FEED_ENDPOINTS_GUIDE.md`](07_FEED_ENDPOINTS_GUIDE.md)</td><td>The app team's walkthrough: paging, the bubble, failure behaviour, switch checklist</td></tr><tr><td>[`05_INTERACTIONS_CLIENT_GUIDE.md`](05_INTERACTIONS_CLIENT_GUIDE.md)</td><td>Exact definition of every interaction the app sends</td></tr><tr><td>[`06_RECOMMENDATION_DEVELOPER_GUIDE.md`](06_RECOMMENDATION_DEVELOPER_GUIDE.md)</td><td>For the AI developer: topics, and the lists the feed reads back</td></tr><tr><td>[`01_ARCHITECTURE.md`](01_ARCHITECTURE.md)</td><td>The design behind all of it</td></tr></tbody></table>