Event Catalogue — every message NexGate publishes to Kafka
Cluster: Redpanda (Kafka API) · Schema registry: JSON Schema, subject <topic>-value, BACKWARD compatibility
Short Description: The complete list of what the backend puts on Kafka: 12 topics, 27 event types. For each one: when it fires, what the key is, what the payload carries, and — just as important — what does not cause it. This is the reference for the recommendation developer, and the contract the backend is held to.
Read
06_RECOMMENDATION_DEVELOPER_GUIDE.mdfirst. That guide is how to connect, what to do with the messages and how to write recommendations back. This document is the catalogue — everything that arrives, in detail.06links here rather than repeating it.
Hints:
- Nothing is emitted from service code. Events come from committed database rows, so what you receive always matches what is in the database — a rolled-back transaction publishes nothing.
- A payload is always the full current state, never a diff. Replace what you stored.
- Order per key is guaranteed; order across keys is not. Use
entityVersion, neveroccurredAt. - Fields are only ever added. Ignore what you don't recognise.
- If a topic is ever lost, everything can be republished from the database:
POST /api/admin/feed/backfills.
1. The envelope
Every message on the nine entity topics has the same eight fields:
{
"eventId": "9f2c1e8a-5b3d-4a91-8c77-2e6f0b4d1a35",
"eventType": "product.updated",
"entityId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90",
"entityVersion": 184022,
"occurredAt": "2026-09-18T10:22:33.120Z",
"schemaVersion": 1,
"source": "nexgate-backend",
"payload": { }
}
| Field | Meaning |
|---|---|
eventId |
Unique per event. Seen twice = a retry; drop the second |
eventType |
One of the 27 in this document |
entityId |
The thing this is about |
entityVersion |
Only ever increases per entity. Keep the highest you have seen and ignore anything lower |
occurredAt |
When it happened. Not an ordering key — clocks and retries make it unreliable for that |
schemaVersion |
1 today |
source |
Always nexgate-backend |
payload |
Full current state, or, for a *.deleted, the id plus a reason |
The interactions and feed-served topics use a slightly different envelope — noted in their sections.
2. How a delete arrives
Always two messages, in this order:
<entity>.deletedwith areason— the entity is gone for you, and the reason says why.- A tombstone: same key,
value = null— so log compaction can drop the key.
"Deleted" does not always mean the row was deleted. It means this entity is no longer something to recommend. A post set to private, a shop that closed, an account that got locked — all arrive as *.deleted with a reason that tells them apart. If it becomes visible again later, you get *.created again.
Tombstones are kept for 1 day. A consumer reading a compacted topic from offset 0 after that sees only live entities.
3. The topics at a glance
| # | Topic | Key | Cleanup | Partitions | Event types |
|---|---|---|---|---|---|
| 1 | nexgate.posts.v1 |
postId |
compact | 24 | post.created · post.updated · post.deleted |
| 2 | nexgate.products.v1 |
productId |
compact | 24 | product.created · product.updated · product.deleted |
| 3 | nexgate.shops.v1 |
shopId |
compact | 12 | shop.created · shop.updated · shop.deleted |
| 4 | nexgate.events.v1 |
eventId |
compact | 12 | event.created · event.updated · event.deleted |
| 5 | nexgate.profiles.v1 |
accountId |
compact | 24 | profile.created · profile.updated · profile.deleted |
| 6 | nexgate.follows.v1 |
followerId:followedId |
compact | 24 | follow.created · follow.deleted |
| 7 | nexgate.blocks.v1 |
blockerId:blockedId |
compact | 12 | block.created · block.deleted |
| 8 | nexgate.orders.v1 |
orderId |
compact | 24 | order.created · order.updated · order.deleted |
| 9 | nexgate.wishlist.v1 |
accountId:productId |
compact | 24 | wishlist.created · wishlist.updated · wishlist.deleted |
| 10 | nexgate.interactions.v1 |
accountId |
delete, 90 d | 96 | interaction |
| 11 | nexgate.feed-served.v1 |
accountId |
delete, 14 d | 48 | feed.served |
| 12 | nexgate.fanout-tasks.v1 |
postId:chunk |
delete, 3 d | 48 | (internal — not for consumers) |
Partition counts are fixed for good. Adding partitions later would move keys to different partitions and break per-key ordering.
Compacted topics are a live catalogue: read from offset 0 and you have the current state of every entity, then keep consuming to stay current.
4. Entity topics
4.1 nexgate.posts.v1 — posts and reels
A post exists for you while it is PUBLISHED, not deleted, and PUBLIC or FOLLOWERS.
| Event | Fires when |
|---|---|
post.created |
A draft becomes published, or a hidden post becomes visible again |
post.updated |
A visible post, or any of its child rows, changes |
post.deleted |
It stops being visible — only if it was visible before |
Delete reasons: DELETED (soft-deleted) · UNPUBLISHED (no longer PUBLISHED) · VISIBILITY_RESTRICTED (switched to MENTIONED-only)
What counts as a change. Not just the post row — eleven child tables are watched, and a change to any of them republishes the whole post: media, hashtags, attached products / shops / events, user and shop mentions, links, collaborators, and the poll.
What does NOT publish anything: likesCount, commentsCount, repostsCount, bookmarksCount, viewsCount, quotesCount, sharesCount, shortClipCount, lastVerifiedAt, updatedAt. Engagement counters move constantly; republishing every post on every like would be most of the traffic on the topic for none of the value. Counts in the payload are therefore a snapshot from the last real change, not live. For live behaviour use nexgate.interactions.v1.
A draft that is saved, edited and never published produces nothing at all.
Payload
postId · postType · status · author{id, userName} · content
contentParsed{hashtags[], mentionedUserIds[], mentionedShopIds[]}
media[]{fileId, mediaType, order, status, variants, shortClip, mediaId, durationMs}
attachments{products[], shops[], events[]} · engagement{…} · privacySettings{visibility, …}
quotedPostId · collaboratorIds[] · hasPoll · externalLinkDomain · isEdited
createdAt · publishedAt · updatedAt · interests[]{id, w}
A reel is a post whose media has
shortClip: true. There is no separate reel topic.
4.2 nexgate.products.v1 — products
A product exists for you while it is ACTIVE or OUT_OF_STOCK, not deleted, and its shop is visible.
Running out of stock is an update, not a disappearance — stock is in the payload, so you decide what to do with it.
| Event | Fires when |
|---|---|
product.created |
It becomes visible, including when its shop comes back |
product.updated |
The product or its installment plan changes |
product.deleted |
It stops being visible |
Delete reasons: DELETED · UNPUBLISHED (DRAFT, INACTIVE, ARCHIVED) · SHOP_UNAVAILABLE (the shop was suspended, closed, unapproved or deleted)
The shop cascade. When a shop's visibility flips, every product of that shop is republished — in pages, right at that moment. One shop suspension can therefore produce thousands of product.deleted messages. This is deliberate: a consumer that only watched the product topic would otherwise keep recommending products from a suspended shop.
What does NOT publish: viewCount, cartAddCount, updatedAt.
Payload
productId · productName · productSlug · productDescription · productType · productMedia[]
categoryId · categoryName · parentCategoryId
price · comparePrice · isOnSale · discountPercentage
stockQuantity · isInStock · isLowStock · showStockToPublic · stockInfo
status · condition · urgencyTag · specifications · colors
hasGroupBuying · groupPrice · hasInstallments
shopId · shopName · createdAt · updatedAt · interests[]{id, w}
4.3 nexgate.shops.v1 — shops
A shop exists for you while it is ACTIVE or TEMPORARILY_OFFLINE, approved, and not deleted.
| Event | Fires when |
|---|---|
shop.created |
It becomes visible |
shop.updated |
Anything meaningful on the shop changes |
shop.deleted |
It stops being visible |
Delete reasons: DELETED · SUSPENDED · CLOSED (permanently closed) · UNPUBLISHED
Every one of these also republishes the shop's products (§4.2).
What does NOT publish: subscriberCount, lastSeenTime, updatedAt.
Payload
shopId · shopName · shopSlug · shopDescription · logo · banner · ownerId
status · tempOfflineUntil · city · district · region · countryCode
isVerified · verificationBadge · trustScore · subscriberCount
approvedAt · createdAt · updatedAt
Never published: phone, email, street address, landmark, coordinates. Many shops are run from someone's home. Region, district and city stay because the feed filters by them.
4.4 nexgate.events.v1 — events
An event exists for you while it is PUBLISHED or HAPPENING, PUBLIC, and not deleted.
| Event | Fires when |
|---|---|
event.created |
It becomes visible |
event.updated |
The event or its tickets change — including selling out, which is an update, not a removal |
event.deleted |
It stops being visible |
Delete reasons: DELETED · ENDED (COMPLETED) · CANCELLED · UNPUBLISHED (draft or any other status) · VISIBILITY_RESTRICTED (PRIVATE or UNLISTED)
Ticket statistics count sold tickets only. Reservations change on every checkout attempt and expire by themselves, so publishing them would be noise.
What does NOT publish: updatedAt, currentStage, completedStages, rsaKeys.
Payload
eventId · title · slug · description · category{id, name} · bannerMedia
eventFormat · visibility · status
schedule{startDateTime, endDateTime, timezone}
venue{name, address, latitude, longitude} · pricing · stats{…, isSoldOut}
organizerId · organizerName · linkedProductIds[] · linkedShopIds[]
createdAt · publishedAt · updatedAt · interests[]{id, w}
Never published: the virtual meeting link, meeting id or passcode (they admit people into the room), and never the event's signing keys.
4.5 nexgate.profiles.v1 — public profile facts
A profile exists for you once sign-up is complete (the account has a real username, not a temp_… placeholder) and while the account is not locked.
| Event | Fires when |
|---|---|
profile.created |
Sign-up completes, or a locked account is unlocked |
profile.updated |
One of the fields below changes, or the person's declared interests change |
profile.deleted |
The account is locked, or the row is gone |
Delete reasons: LOCKED · DELETED
Only these changes republish: userName, accountType, accountTier, locked, isVerified, verificationBadgeType, and rows in the user's declared interests. A login, a password change or a profile-photo change publishes nothing.
Payload
accountId · userName · accountType · accountTier
isVerified · verificationBadgeType
followerCount · followingCount · declaredInterestIds[] · createdAt
Never published: first, middle or last name, bio, location, phone, email, birth date, profile photo.
⚠️ Do not use the
activecolumn if you ever see it. Sign-up sets itfalseand nothing ever sets ittrue, so it isfalseon every real account. It is not in the payload for that reason; relying on it would have tombstoned every profile on the platform.
4.6 nexgate.follows.v1 — who follows whom
Only ACCEPTED follows exist for you. Key is followerId:followedId.
| Event | Fires when |
|---|---|
follow.created |
A follow is accepted, or a public account is followed |
follow.deleted |
Unfollowed, or a request is withdrawn — only if it had been accepted |
A follow request (PENDING) publishes nothing. If it is never accepted, you never hear about it.
Payload: followerId · followedId · followedAt
Deleted payload: followerId · followedId · reason
4.7 nexgate.blocks.v1 — who blocked whom
Key is blockerId:blockedId. A block exists for you the moment the row is written — there is no visibility condition.
| Event | Fires when |
|---|---|
block.created |
Someone blocks someone |
block.deleted |
They unblock |
This topic is not optional. The model must never recommend a blocked author's content in either direction — the block hides both ways.
Payload: blockerId · blockedId · blockedAt
Deleted payload: blockerId · blockedId · reason
4.8 nexgate.orders.v1 — what was actually bought
An order exists for you from creation. This is the strongest purchase signal on the platform.
| Event | Fires when |
|---|---|
order.created |
An order is placed |
order.updated |
One of the fields below changes |
order.deleted |
The order is soft-deleted (reason DELETED) |
Only these changes republish: productOrderStatus, deliveryStatus, totalAmount, currency, shippedAt, deliveredAt, completedAt, cancelledAt, isDeleted.
Payload
orderId · orderNumber · buyer{accountId} · shopId
items[]{productId, quantity, unitPrice, subtotal}
productOrderStatus · deliveryStatus · productOrderSource
totalAmount · currency
orderedAt · shippedAt · deliveredAt · completedAt · cancelledAt · updatedAt
Never published: delivery address or instructions, order notes, cancellation text, payment method, escrow or fee figures, tracking number, confirmation code, chat ids.
Event ticket bookings are not on this topic yet. They arrive as a
PURCHASEinteraction (§5) but there is no booking entity stream. Noted as a gap in §7.
4.9 nexgate.wishlist.v1 — saved products
Key is accountId:productId — a product can be in a person's wishlist once, whatever group it is filed under.
| Event | Fires when |
|---|---|
wishlist.created |
A product is added |
wishlist.updated |
The entry changes (for example, moved to another group) |
wishlist.deleted |
It is removed |
Payload: wishlistId · accountId · productId · groupId · addedAt
Never published: the group's name. It is free text a person writes about themselves ("Gifts for Mum"). The group id is enough to tell groups apart.
5. nexgate.interactions.v1 — what people did
One event type, interaction, keyed by accountId so one person's actions stay in order. Retained 90 days.
This is the only topic anything outside the backend can write to, and it does so through exactly one endpoint: POST /api/v1/feed/interactions/batch (09_NEW_ENDPOINTS.md A8). Every other topic is written from committed database rows, so no client can announce that an entity exists or changed.
{
"eventId": "…",
"eventType": "interaction",
"occurredAt": "2026-09-18T10:22:41.100Z",
"receivedAt": "2026-09-18T10:22:41.480Z",
"schemaVersion": 1,
"source": "nexgate-backend",
"payload": {
"accountId": "…", "sessionId": "s-8812e0c4", "clientEventId": "c-000185",
"action": "VIEW", "targetType": "POST", "targetId": "…",
"context": { "surface": "REELS", "position": 3, "feedSessionId": "fs-77aa", "source": "RECO", "viaPostId": null, "query": null },
"media": { "dwellMs": null, "watchMs": 9000, "mediaDurationMs": 6000, "loopCount": 1, "soundOn": true, "mediaIndex": 0 },
"client": { "platform": "ANDROID", "appVersion": "2.4.1", "networkType": "CELLULAR" },
"origin": "CLIENT"
}
}
occurredAt is the device's time, clamped so it can never be in the future; receivedAt is the server's.
5.1 Two origins, never overlapping
origin |
Who produced it | clientEventId |
|---|---|---|
CLIENT |
The app, in a batch to POST /api/v1/feed/interactions/batch |
the app's own id |
SERVER |
The backend, watching committed rows | srv:<action>:<rowId> |
The split is enforced: the batch API rejects every server-owned action with SENT_BY_SERVER, so nothing is ever counted twice.
5.2 Every action, and who sends it
| Action | Origin | Meaning |
|---|---|---|
IMPRESSION |
CLIENT | The item was on screen. Use it as the denominator |
VIEW |
CLIENT | Real attention — dwell ≥ 5 s, or a reel watched ≥ 50 % |
SKIP |
CLIENT | Scrolled past quickly |
CLICK |
CLIENT | Opened the item |
SEARCH |
CLIENT | The only action with no target — what matters is context.query |
NOT_INTERESTED |
CLIENT | Explicit negative |
HIDE |
CLIENT | Explicit negative, stronger |
LIKE / UNLIKE |
SERVER | A post_likes row was inserted / deleted |
COMMENT |
SERVER | A post_comments row was inserted |
BOOKMARK |
SERVER | A post_bookmarks row was inserted |
SHARE |
SERVER | A post_shares row was inserted |
REPORT |
SERVER | A post_reports row was inserted |
BLOCK |
SERVER | A user_blocks row was inserted (target PROFILE) |
MUTE_AUTHOR |
SERVER | A user_mutes row was inserted (target PROFILE) |
ADD_TO_CART / REMOVE_FROM_CART |
SERVER | A cart_items row was inserted / deleted |
PURCHASE |
SERVER | An order became paid — one event per order item — or an event booking became CONFIRMED |
Target types: POST, PRODUCT, SHOP, EVENT, PROFILE.
Surfaces: FEED, REELS, SEARCH, PRODUCT_PAGE, SHOP_PAGE, EVENT_PAGE, PROFILE_PAGE, RECOMMENDATION, NOTIFICATION, CHAT.
Sources (why the item was shown): FOLLOWING, CELEBRITY, RECO, INTEREST, FALLBACK, SPONSORED, TRENDING, SEARCH.
5.3 Media measurements are raw, on purpose
watchMs: 9000 against mediaDurationMs: 6000 with loopCount: 1 is a rewatch, not bad data. Nothing is clamped to "100%" before you see it, because the fact that someone watched a clip one and a half times is exactly the signal worth having.
5.4 What you will not find here
Interactions are best-effort by design. If the event log is briefly unavailable, interactions are dropped and counted, not queued — they are signal, not records. A missing view is a missing sample; a queued backlog of stale views would be worse. Entity topics are the opposite: those are never lost.
6. nexgate.feed-served.v1 — what the feed actually showed
One event type, feed.served, keyed by accountId. Retained 14 days. This is the ground truth for ranking evaluation: what was shown, in what order, and why.
{
"eventId": "…",
"eventType": "feed.served",
"occurredAt": "2026-09-18T10:22:31.900Z",
"schemaVersion": 1,
"source": "nexgate-backend",
"payload": {
"feedSessionId": "fs-77aa31c8",
"accountId": "…",
"surface": "FEED",
"items": [
{ "type": "POST", "id": "4f2a77e1-…", "position": 0, "source": "FOLLOWING" },
{ "type": "PRODUCT", "id": "8c31a0d2-…", "position": 1, "source": "RECO" }
]
}
}
Join it to the interactions topic on feedSessionId and you have, per session: what we showed, in which position, from which source — and what the person did about it.
⚠️ Today this is published by the home feed only, and
surfaceis always the literal"FEED". Reels, marketplace and events do not publish it yet. See §7.
7. Known gaps
Stated plainly so nobody builds on an assumption:
| Gap | Consequence |
|---|---|
feed-served is published by the home feed only, with surface hard-coded to "FEED" |
No served-ground-truth for reels, marketplace or events. Evaluation on those surfaces has to rely on impressions |
| Event ticket bookings have no entity topic | A booking arrives as a PURCHASE interaction with targetType: EVENT, but there is no order-like stream for bookings. nexgate.orders.v1 is product orders only |
No JSON Schema is registered for nexgate.feed-served.v1 or nexgate.fanout-tasks.v1 |
The nine entity topics and interactions have schemas under src/main/resources/feed-schemas/; these two do not |
| Interest weights are empty until an admin maps categories | interests: [{id, w}] on posts, products and events is [] for any unmapped category or hashtag. 128 product categories and 12 event categories are still unmapped |
Follows and shop subscriptions are not yet interest signals, and SEARCH is not matched to an interest |
The backend's own interest profile is weaker than the design describes. Does not affect what you receive |
8. nexgate.fanout-tasks.v1 — internal
Backend to backend: when a post is published, the work of writing it into followers' timelines is chunked and sent over this topic. Key is postId:chunk, retained 3 days, no schema registered.
Ignore it. It carries no signal a model can use, and its shape may change without notice.
| Doc | What it covers |
|---|---|
06_RECOMMENDATION_DEVELOPER_GUIDE.md |
How to connect, consume, and write your lists back into Redis |
02_KAFKA_CONTRACT.md |
The original agreement: why these topics, deletes, ordering, versioning, personal data |
01_ARCHITECTURE.md |
The design. §4 is the write path and the outbox; §4.6 is what is deliberately left out of payloads |
05_INTERACTIONS_CLIENT_GUIDE.md |
The app's side of the interactions topic: exact definitions of every action |