Activities
An activity is a verb + object (plus optional target, foreign_id, time, and a
custom payload) posted to a feed. On write, it fans out along every follow edge
pointing at that feed (spec §1) — that’s how a post on user:diego ends up in
timeline:maya if maya follows diego. Deletes are soft: the activity row is marked
deleted, not removed, while its fan-out copies are hard-deleted so the read path never
has to filter deleted_at (spec design).
Activity shape
Section titled “Activity shape”Every read — feed().get(), useFeed, a webhook body — hands back this shape. You
write five of these fields; the rest the server computes.
| Field | Type | |
|---|---|---|
id |
string |
Server-assigned. Use it as your React key and for reactions/deletes. |
actor |
string |
Who did it — conventionally user:<id>, though a server token may set any non-empty string. A user token can’t set it at all: the feed service overwrites it with the token’s own subject. |
verb |
string |
What happened, your vocabulary: workout, post, booked. |
object |
string |
What it happened to, as type:id. Opaque to us. |
target |
string | null |
Optional second object — the to in “moved X to Y”. |
foreign_id |
string | null |
Your id for this activity. With time, it’s the dedupe identity — see below. |
time |
string |
ISO 8601. Sort key (with id) for the whole feed. Defaults to now. A user token’s value is clamped into the last 10 minutes — never the future, silently, not rejected — which is what lets a browser retry resend the same time and dedupe. A server token is unclamped; that’s what makes backfill possible. |
custom |
TCustom |
Your payload, verbatim. Typed by the generic you pass to useFeed<T> / addActivity<T>. Immutable data belongs here; anything that changes later belongs in refs → objects. |
refs |
string[] |
Up to 4 type:id pointers at mutable objects, resolved into the page’s objects sidecar on read. |
origin_feed |
string |
The feed it was posted to, as group:id — not the feed you’re reading. This is the authority for deletes and how you tell “maya posted this” from “it reached my timeline”. |
reaction_counts |
Record<string, number> |
Denormalized per kind, e.g. { like: 4 }. Seed useReactions with it — never count reactions yourself. |
comment_count |
number |
Denormalized integer of comments plus replies. The text is not on this read. |
own_reactions |
string[] | undefined |
The kinds the reading caller has added. Present whenever the caller has a user identity — a user token, or a server token impersonating one via X-Dropin-User-Id. A bare server token omits the field, since there’s no “own”. |
actor_user |
{ id, custom } | null |
The actor’s profile, pre-enriched from users.custom — name, avatar, whatever you stored. Null when actor isn’t a user: ref. Saves the N+1 you’d otherwise write. |
edited_at |
string | null |
Null until someone patches the activity. Marks patches only. |
version |
number |
Starts at 1, increments on any change — a patch, a reaction count moving, a soft delete. This is the field to compare when deciding whether a copy you’re holding is stale; edited_at misses the thing that changes most. |
A page wraps them as { results: Activity[], next: string \| null }, plus optional
objects and promoted sidecars. Deleted activities never appear at all.
Add an activity
Section titled “Add an activity”const activity = await client.feed('user', 'maya').addActivity({ verb: 'workout', object: 'workout:1234', foreign_id: 'maya-w0', // optional — stable id makes retries idempotent time: new Date().toISOString(), // required WITH foreign_id (dedupe is on the pair) custom: { sport: 'run', durationMin: 45 },})import { useFeedActions } from '@dropinnodex/react'
function Composer() { const { addActivity } = useFeedActions<{ sport: string; durationMin: number }>('user', 'maya') return ( <button onClick={() => addActivity({ verb: 'workout', object: 'workout:1234', custom: { sport: 'run', durationMin: 45 } })}> Post </button> )}A feed already loaded with useFeed/useTimeline/useUserFeed also returns its own
addActivity, which prepends the new activity into that hook’s activities list
immediately — reach for that one when you’re posting into a feed you’re also rendering.
const activity = await server.feed('user', 'maya').addActivity({ verb: 'workout', object: 'workout:1234', foreign_id: 'maya-w0', custom: { sport: 'run', durationMin: 45 },})The server SDK’s addActivity takes the same typed generic shape as the client’s —
custom is TCustom either way. A server token additionally sets actor explicitly
(a user token has it overwritten by the gateway) and is unclamped on time, which is
what makes historical backfill possible.
Either SDK also accepts refs — objects this activity points at, as type:id, up to
4 — see Keeping feed data fresh for when to reach
for it instead of custom.
Upsert the user first
Section titled “Upsert the user first”Call server.upsertUser() for an actor before you post activities as them. A write for an actor with no user record still succeeds — 201, real activity,
normal fan-out — but the response comes back with actor_user: null, so every card
renders with no name and no avatar, and stays that way until that user is upserted.
The response tells you when this happened:
const activity = await server.feed('user', 'maya').addActivity({ verb: 'workout', object: 'workout:1234' })
activity.actor_user // nullactivity.warnings // ['actor_user_unresolved']warnings is present only on the response to a write, and absent — not empty — when
there is nothing to report. Treat it as a loud log line in development: it is the only
signal that separates a correct integration from one that renders blank cards.
This bites hardest when activities come from database triggers or a job queue, where there is no natural moment at which anyone thinks to register a user with a feed vendor. Two ways out:
- Upsert on user creation and on profile change, from the same trigger that owns your user table. Cheapest, and it keeps names and avatars fresh.
- Upsert lazily right before the first activity for that actor.
upsertUseris idempotent, so an unconditional call costs one request.
Importing existing users in bulk? Use
batch.users — 100 per call, quiet by design.
What goes in a user’s custom
Section titled “What goes in a user’s custom”custom is yours and we never interpret it — but the SDKs render nothing on your
behalf either, so pick a convention before your backend and your UI invent two:
await server.upsertUser({ id: 'maya', custom: { name: 'Maya Ortiz', image: 'https://cdn.example.com/u/maya.jpg' },})name and image are the recommended keys — they mirror GetStream’s, so example code
you find elsewhere lines up. Whatever you choose, it arrives verbatim as
activity.actor_user.custom on every read, so your card component reads one shape.
A non-user: actor (system:digest) never enriches at all, by design — actor_user
is always null for one and no warning is raised. Use it for content with no human
author, and render the name yourself.
Resolving actors yourself
Section titled “Resolving actors yourself”Skipping upsertUser entirely is a supported pattern, not a mistake. If you already
hold names and avatars in your own database and render them from there, you may never
want our copy — one less thing to keep in sync, and no profile data leaving your system.
The warning you’ll then see on every server-token write —
warnings: ["actor_user_unresolved"] — is aimed at the integration that meant to
upsert and forgot. If you opted out deliberately, it is expected and safe to ignore.
Decide once, and don’t log it per write.
Do not branch on actor_user === null. It is null only while nothing has ever
provisioned that user. Any write or reaction made with a user token provisions one
automatically — the feed service inserts the row before the write — so the same actor
flips from null to { id, custom: {} } the first time they post or react from the
browser, permanently, without anyone calling upsertUser. A client-side “if actor_user
is null, look the name up myself” branch therefore works until a user’s first reaction
and silently stops afterwards, rendering blank cards.
Branch on the field you actually render instead:
const name = activity.actor_user?.custom?.name ?? myLookup(activity.actor)What you give up: the enrichment is a join we already do on the read path, so resolving actors yourself means one lookup per distinct actor on a page, in your code. Worth it if your user data is sensitive or changes constantly; not worth it otherwise.
Idempotency & foreign_id
Section titled “Idempotency & foreign_id”The pair (foreign_id, time) is the dedupe identity of an activity inside
one origin feed. The same pair on another feed is a different activity. What that
buys you, exactly:
- Re-POSTing the same
(foreign_id, time)pair to the same feed returns the SAME activity — the original row comes back unchanged, no duplicate is created, and no second fan-out runs. Retrying a timed-out request is safe. - A pair whose activity was deleted returns
409 CONFLICT. Deletes are soft, so the identity is burned — re-posting it cannot resurrect the activity. Pick a newforeign_id(or a newtime) if you genuinely want to post again. - Omitting
foreign_idopts out of dedupe entirely — every POST creates a new activity, retries included. foreign_idwithouttimeis rejected (VALIDATION_FAILED). Dedupe is on the pair — aforeign_idwithout a caller-pinnedtimecould never dedupe (each retry would get a fresh server timestamp and silently duplicate), so the API refuses the combination instead of letting an idempotency assumption fail quietly. Send the sametimevalue on every retry. This is a deliberate safety deviation from GetStream, which accepts the combination and duplicates silently.
Also worth knowing: on a user-token write, time is clamped to
[now − 10 min, now] — a client can backdate up to ten minutes but can never pin an
activity into the future. Server-token writes are not clamped — that’s what lets a
batch import carry true historical timestamps.
Delete an activity
Section titled “Delete an activity”// Hits DELETE /v1/activities/:id. The gateway enforces authority = origin_feed —// deleting an activity you don't own returns FORBIDDEN.await client.feed('user', 'maya').removeActivity(activity.id)const { deleteActivity } = useFeedActions('user', 'maya')await deleteActivity(activity.id)await server.feed('user', 'maya').removeActivity(activity.id)A server token may remove an activity from any feed — the origin-feed authority
check applies to user tokens only. That is what makes moderation possible, and what
lets your backend clean up feed content when the underlying object is deleted in your
own database. Removing by your own foreign_id instead of dropin’s id? See
below.
Delete by your own id
Section titled “Delete by your own id”When the thing behind an activity is deleted in your app, you usually know your own id
for it — the foreign_id and time you posted with — not dropin’s. Remove it with that:
const { removed } = await client.feed('user', 'maya').removeActivity({ foreign_id: 'workout:123', time })const { removed } = await server.feed('user', 'maya').removeActivity({ foreign_id: 'workout:123', time })- It removes every live activity that lives in that feed with that
foreign_id— and, when you passtime, only the one posted at that instant. Passtimewhenever you have it. removedlists the ids it deleted. It is empty, not an error, when nothing matched — so calling it again for something already gone is safe.- It is a delete like any other: gone from every timeline, and the
(foreign_id, time)pair stays burned, so re-posting it returns409 CONFLICT. - On a user-token write the stored
timemay be the clamped value rather than the one you sent — passactivity.timefrom the add response. - A user token may only call this on its own
userfeed —403 FORBIDDENotherwise; a server token may target any feed. timemust be a UTC ISO-8601 string ending inZ(as returned by the add) — an offset like+02:00is rejected with400.
Related
Section titled “Related”- Feeds — where an activity lands once posted, and how fan-out reaches followers.
- Reactions — reacting to an activity; counts ride along on every read.
- Comments — one comment on an activity, and one reply under that comment.
- Migrating existing data — posting historical activities in bulk, quietly, with true timestamps.
- Webhooks —
activity.added/activity.removeddelivered to your backend. - Limits — page-size caps and per-tenant rate limits.