Skip to content

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).

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.

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 },
})

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 // null
activity.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. upsertUser is idempotent, so an unconditional call costs one request.

Importing existing users in bulk? Use batch.users — 100 per call, quiet by design.

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.

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.

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 new foreign_id (or a new time) if you genuinely want to post again.
  • Omitting foreign_id opts out of dedupe entirely — every POST creates a new activity, retries included.
  • foreign_id without time is rejected (VALIDATION_FAILED). Dedupe is on the pair — a foreign_id without a caller-pinned time could 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 same time value 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.

// 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)

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 })
  • It removes every live activity that lives in that feed with that foreign_id — and, when you pass time, only the one posted at that instant. Pass time whenever you have it.
  • removed lists 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 returns 409 CONFLICT.
  • On a user-token write the stored time may be the clamped value rather than the one you sent — pass activity.time from the add response.
  • A user token may only call this on its own user feed — 403 FORBIDDEN otherwise; a server token may target any feed.
  • time must be a UTC ISO-8601 string ending in Z (as returned by the add) — an offset like +02:00 is rejected with 400.
  • 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.removed delivered to your backend.
  • Limits — page-size caps and per-tenant rate limits.