Sending interactions — guide for the app team
Audience: Android, iOS and web developers. Status: built on the backend (branch fet/feed_baking, 2026-09-17).
The recommendation model learns from what people do. It can only learn from what the app sends, and anything not sent today is lost for good: a watch time nobody recorded cannot be recovered later. Please send everything below from the first release that has the endpoint.
The same action must mean the same thing on every platform. A VIEW on Android and a VIEW on iOS are counted together; if their definitions differ, the model learns the difference between the apps instead of what people like.
1. The endpoint
POST /api/v1/feed/interactions/batch
Authorization: Bearer <access token>
Content-Type: application/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-17T10: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-17T10:22:41.100Z",
"watchMs": 9000, "mediaDurationMs": 6000, "loopCount": 1, "soundOn": true, "mediaIndex": 0,
"context": { "surface": "REELS", "position": 3, "feedSessionId": "fs-77aa", "source": "RECO" } }
]
}
Answer: 202
{ "data": { "accepted": 2, "dropped": 0, "rejected": [] } }
rejectedlists events that were invalid, each with areason. Fix the app; do not resend them.droppedcounts valid events the server did not keep (rate limit or a temporary broker problem). Do not resend them either.- Only resend a batch when the request itself failed (no connection, timeout,
5xx). Resending is safe: the server recognises repeats byclientEventId. - Never put an account id in the body. The account always comes from the token; any id in the body is ignored.
- Unknown fields are ignored, so an older server never rejects a newer app.
2. When to send
| Rule | Value |
|---|---|
| Send every | 10 seconds |
| …or as soon as the buffer holds | 50 events |
| …and always when | the app goes to the background |
| Maximum per request | 100 events (extra events are rejected with BATCH_LIMIT_100) |
| Offline | keep up to 500 unsent events on disk; beyond that drop the oldest |
| Rate limit | 600 events per minute per account; normal use never reaches it |
Never send one request per event.
3. Fields
Batch
| Field | Required | Notes |
|---|---|---|
sessionId |
recommended | A new id each time the app comes to the foreground after ≥ 30 min away. ≤ 64 characters |
client.platform |
recommended | ANDROID, IOS, WEB |
client.appVersion |
recommended | e.g. 2.4.1 |
client.networkType |
recommended | WIFI, CELLULAR, OFFLINE, UNKNOWN. A clip abandoned on a slow connection means something different from one skipped on wifi |
Event
| Field | Required | Notes |
|---|---|---|
clientEventId |
yes | Unique per event on this device, ≤ 64 characters. A counter or a UUID. Reuse it when resending the same event |
action |
yes | See §4 |
targetType |
yes, except SEARCH |
POST, PRODUCT, SHOP, EVENT, PROFILE. A reel is a POST |
targetId |
yes, except SEARCH |
The item's UUID |
occurredAt |
recommended | When it happened on the device, ISO-8601 UTC (2026-09-17T10:22:31.900Z). Omitted → the time the server received it. More than 24 h old or more than 5 min ahead is pulled into that window |
dwellMs |
for VIEW of images, products, events, shops, profiles |
Time the item was on screen (≥ 50 % visible) |
watchMs |
for VIEW / SKIP of video |
Total time actually played, including repeats. 9 s on a 6 s clip that looped once is 9000 |
mediaDurationMs |
for video | The clip's length |
loopCount |
for video | How many times it restarted from the beginning |
soundOn |
for video | Whether sound was on while playing |
mediaIndex |
for carousels | Which item (0-based) of a post with several images or videos |
context.surface |
recommended | FEED, REELS, SEARCH, PRODUCT_PAGE, SHOP_PAGE, EVENT_PAGE, PROFILE_PAGE, RECOMMENDATION, NOTIFICATION, CHAT |
context.position |
recommended in lists | 0-based rank on screen. Without it the model cannot tell a tap earned by relevance from a tap earned by being first |
context.feedSessionId |
when the item came from the feed | The feed page's session id (the feed API will return it) |
context.source |
when the item came from the feed | FOLLOWING, CELEBRITY, RECO, INTEREST, FALLBACK, SPONSORED, TRENDING, SEARCH, as returned by the feed API |
context.viaPostId |
when acting on something attached to a post | The post's id, e.g. tapping a product tagged in a post |
context.query |
for SEARCH |
What was searched; trimmed, cut at 200 characters |
Send numbers as whole milliseconds. Leave a field out rather than sending 0 when it was not measured.
4. Actions — exact definitions
| Action | Send when | Carries |
|---|---|---|
IMPRESSION |
The item is ≥ 50 % visible for ≥ 300 ms. Once per item per feed session, however often it scrolls back into view | context |
VIEW |
Image, product, event, shop, profile: ≥ 50 % visible for ≥ 1 s. Video: played ≥ 2 s. Send once, when the item leaves the screen, with the total time | dwellMs or watchMs/mediaDurationMs/loopCount/soundOn, mediaIndex |
SKIP |
A video was scrolled away less than 1 s after it started | watchMs, mediaDurationMs |
CLICK |
The detail page, shop or profile was opened from the item | context |
SEARCH |
A query was submitted | context.query, no target |
NOT_INTERESTED |
"Show fewer like this" | |
HIDE |
"Hide this" |
Do not send LIKE, UNLIKE, COMMENT, BOOKMARK, SHARE, ADD_TO_CART, REMOVE_FROM_CART, PURCHASE, BLOCK, REPORT or MUTE_AUTHOR. Just call the feature's own API (like a post, add to cart, pay). The backend sends the interaction itself once the change is saved, so it is counted exactly once, even if the app crashes before its next batch, and never for a like that failed. The batch API rejects these actions with SENT_BY_SERVER.
What the backend cannot know is where the tap happened. Keep sending CLICK with context (surface, position, source, viaPostId) when a product, event or profile is opened; that is how the model learns a post led to a purchase.
Clips (reels)
- A clip is a
POST. Use the clip'spostIdastargetId, not itsid(that is the media id);mediaIndexsays which video of the post. mediaDurationMs=media.durationMsfrom the clip response.POST /api/v1/e-social/clip/{id}/viewonly counts the view (once per person). It ignoreswatchDurationMsandsource: watch time reaches the model only through this batch.- A clip with
visibility: INTERSTITIALgets a "sensitive content" cover; play only after the viewer taps through.
Worked examples
A reel watched to the end and replayed partly, sound on, on cellular
VIEW, watchMs: 9000, mediaDurationMs: 6000, loopCount: 1, soundOn: true, surface REELS.
A reel flicked past
IMPRESSION (if it met 50 % / 300 ms), then SKIP with watchMs: 400, mediaDurationMs: 15000.
Third image of a carousel post looked at for 2.5 s
VIEW, targetType: POST, mediaIndex: 2, dwellMs: 2500.
A product tagged in a post, opened then added to cart
CLICK, targetType: PRODUCT, context.viaPostId = the post. The add to cart itself is sent by the backend.
Search
SEARCH, context: { surface: "SEARCH", query: "running shoes" }; then an IMPRESSION per result with surface: SEARCH and its position.
5. Rejection reasons
| Reason | Meaning |
|---|---|
CLIENT_EVENT_ID_REQUIRED_MAX_64 |
Missing or too long |
UNKNOWN_ACTION |
Not in §4 |
SENT_BY_SERVER |
An action the backend already sends (like, comment, cart, purchase, …); call the feature API only |
UNKNOWN_TARGET_TYPE / TARGET_ID_NOT_UUID |
Target missing or malformed |
DUPLICATE_CLIENT_EVENT_ID |
Same clientEventId twice in one batch |
OCCURRED_AT_NOT_ISO_8601 |
Unparseable time |
DURATION_OUT_OF_RANGE |
A duration below 0 or above 6 h (12 h for mediaDurationMs) |
LOOP_COUNT_OUT_OF_RANGE / MEDIA_INDEX_OUT_OF_RANGE |
Out of 0–1000 / 0–100 |
UNKNOWN_SURFACE / UNKNOWN_SOURCE / POSITION_OUT_OF_RANGE / VIA_POST_ID_NOT_UUID |
Context fields malformed |
BATCH_LIMIT_100 |
More than 100 events in one request |
EMPTY_EVENT |
A null in the list |
A rejection is a bug in the app. Log it in development builds.