Promoted activities
A reverse-chronological feed is a hard place to put something you need people to see. An announcement sinks below the fold within hours, and an activity only ever reaches the users who follow the feed it landed in. Promoted activities escape both: they are served alongside a feed’s first page for every eligible reader, whether or not they follow anything, until they expire or you retract them.
Four things tenants promote, all the same feature:
- Urgency — “Thursday 20:00, 6 of 12 spots left”, created automatically by your backend when a game is under-filled, expiring at kickoff.
- Announcements — a new venue, a new feature, scheduled maintenance.
- Sponsor content — the post you sold to a sponsor.
- Community spotlight — MVP of the month, member of the week.
This is promoted content, not an ad platform: there is no bidding, no demographic targeting, and no viewability tracking. The label your users see (“Sponsored”, “Featured”, or nothing at all) is yours to render — and so is any disclosure obligation that comes with paid placement. We ship the flag; you ship the word.
Creating one
Section titled “Creating one”Promoted routes are server-token only, so they live in @dropinnodex/server.
Creating one is usually automated rather than something a human does in a
dashboard.
import { DropInServer } from '@dropinnodex/server'
const server = new DropInServer({ tenantId: 'acme', apiKey: process.env.DROPIN_API_KEY!, apiSecret: process.env.DROPIN_API_SECRET!,})
const promo = await server.promoted.create({ actor: 'system:fcurban', verb: 'promote', object: 'game:8842', custom: { text: 'Thursday 20:00 — 6 spots left', image: 'https://…' }, expires_at: kickoff.toISOString(), // stops serving itself})Nothing is fanned out. One row serves every eligible reader, which is why the lifecycle is so cheap:
await server.promoted.list() // your inventory, newest first, with served_countawait server.promoted.remove(promo.id) // retract — effective on the very next feed readThere is no update call. Promoted activities are short-lived; retract and create a new one.
Targeting an area (or any group)
Section titled “Targeting an area (or any group)”We store no attributes about your users — no city, no age, no interests. Your backend owns that data, and targeting works without us ever seeing it: an audience is a list of feeds, and membership is a follow edge.
// 1. A feed per city. Feeds are free and need no creation step.// 2. Your backend follows each user into the one that matches, at signup// or whenever they change city.await server.batch.follows([ { source: 'timeline:alice', target: 'city:belgrade' }, { source: 'timeline:bob', target: 'city:amsterdam' },])
// 3. Target the feed.await server.promoted.create({ actor: 'system:fcurban', verb: 'promote', object: 'game:8842', audience: ['city:belgrade'], // OR across entries; max 20})Alice sees it, Bob does not. The same edge does double duty: organic activities
posted to city:belgrade reach Alice’s timeline too, so you get a city feed and
city targeting from one follow.
Anything you can express as a group works the same way — segment:premium,
team:red, beta-testers. Your backend already knows who belongs; it just
follows them in.
Omit audience entirely to reach everyone, including a brand-new user with an
empty feed. That is often the point: it is the one thing you can put in front of
someone who follows nothing yet.
Reading and rendering
Section titled “Reading and rendering”Eligible rows arrive in the promoted array of a feed read’s first page:
{ "results": [ /* real activities */ ], "promoted": [ { "id": "…", "actor": "system:fcurban", "custom": {}, "promoted": true } ], "next": "eyJ0Ijoi…"}Two rules worth internalising:
- Promoted rows are never inside
results, and never affectnext. The cursor is a position in the real feed; if a promoted row shifted it, paging would skip or repeat real activities. promotedis the eligible set, not a slot assignment. It appears only on an uncursored request — later pages omit the key entirely — and the client caches it and decides placement.
In React that is two props:
import { useFeed } from '@dropinnodex/react'
function Timeline({ uid }) { const { items, trackPromotedClick } = useFeed('timeline', uid, { promotedPosition: 3, // after the 3rd activity (default) promotedRepeatEvery: null, // once; set a number to repeat every N onPromotedImpression: (p, { slot }) => analytics.track('promoted_shown', { id: p.id, slot }), onPromotedClick: (p) => analytics.track('promoted_clicked', { id: p.id }), })
return items.map((item) => 'promoted' in item ? <PromotedCard key={item.id} item={item} onClick={() => trackPromotedClick(item)} /> : <ActivityCard key={item.id} activity={item} />, )}items is activities with promoted rows interleaved; activities itself is
untouched, so nothing you already render changes. Set promotedRepeatEvery: 5 and
slots land at 5, 10, 15 and onward across pages — the cached sidecar keeps
filling them as loadNext() loads more, with no further server call, rotating
through the set when more than one row is eligible.
Prefer to place it yourself? promoted gives you the raw array, and
placePromoted(activities, promoted, opts) is exported as a pure function you can
use anywhere, React or not.
Counting
Section titled “Counting”served_count on each row counts feed opens that received it — deliveries,
not views. A reader who scrolls past four copies in one session counts once, and
whether the card ever entered the viewport is not something a server can see.
That last part is why per-view numbers belong on the client:
onPromotedImpression fires once per placed slot, and you forward it to
whichever analytics stack you already run. Your data stays in your tools, next to
everything else you measure — we do not build a dashboard for it, and we do not
need a connector.
Limits and lifecycle
Section titled “Limits and lifecycle”| Rule | Value |
|---|---|
| Live (non-retracted) rows per tenant | 100 — an abuse guard, not a plan limit |
| Rows returned in one sidecar | 10, newest first |
Feed refs per audience |
20, matched with OR |
| Placement | client-side; the server never merges into results |
starts_at defaults to now and expires_at may be null (“until you retract it”).
Expiry needs no cleanup — nothing was ever copied into a feed, so a promotion that
ends simply stops being served. A row already rendered on a client keeps showing
until the next uncursored read; refresh() re-resolves eligibility.
Related
Section titled “Related”- Follow — the follow edges that express an audience.
- Feeds — reading a feed, and where the sidecar arrives.
- Pagination — why the cursor never sees promoted rows.
- Migrating existing data —
batch.followsfor backfilling audience membership in bulk. - Limits — the caps listed above.