Follow and Unfollow

Follow

The source feed should have a group that has "following" activity selector enabled, for example the built-in timeline group. The target feed should have a group that has "current" activity selector enabled, for example the built-in user group.

Tip:

For most use cases, prefer getOrCreateFollow over follow. It returns the existing follow row when the pair is already followed instead of erroring, so retries and double-clicks are safe. Use follow only when you specifically want a duplicate-follow to fail.

Info:

This section covers the live follow API call, which derives the creator of the source and target feeds automatically. If you're bulk importing follows with the CLI importer instead, source_created_by_id and target_created_by_id are required fields — see Importing Follows.

# Follow a user
response = self.client.feeds.follow(
    source=f"{self.USER_FEED_TYPE}:{self.test_user_id}",
    target=f"{self.USER_FEED_TYPE}:{self.test_user_id_2}",
)

Trying to follow a feed that is already followed will result in an error. Use getOrCreateFollow for an idempotent single-follow call, or getOrCreateFollows for the batch variant.

Info:

You do not need to call getOrCreate on the source or target feed before following. If either feed does not exist yet, the feed is created automatically when you call follow. This applies to both the single follow and batch follow endpoints.

Auto-creating users

follow, followBatch, and getOrCreateFollows support an opt-in create_users flag (default: false).

  • Server-side only: client-side callers that set create_users are ignored and still receive user not found if referenced users are missing.
  • Missing users only: create_users creates users that do not exist yet; it does not update existing users.
  • Best-effort behavior: missing-user creation happens before follow creation and is not transactional with the follow write. A user can be created even if a later follow validation fails.
  • User ID inference: when create_users is true, user IDs are derived from the feed IDs in source and target (for example, timeline:alice infers alice, user:bob infers bob).

For a single follow call, set create_users on the request body.

response = self.client.feeds.follow(
    source="timeline:alice",
    target="user:bob",
    create_users=True,
)

For followBatch / getOrCreateFollows, set create_users only at the top level. follows[i].create_users is rejected.

response = self.client.feeds.get_or_create_follows(
    create_users=True,
    follows=[
        {"source": "timeline:alice", "target": "user:bob"},
        {"source": "timeline:alice", "target": "user:charlie"},
    ],
)

Limiting copied activities

By default, following a feed adds all of the target feed's existing activities to the source feed. Use activity_copy_limit to add only the most recent ones:

  • Not set: all existing activities are added (default).
  • 0: no existing activities are added. Only activities added after the follow appear.
  • 1–1000: only that many of the most recent existing activities are added.

The limit only affects activities that already exist when the follow is created; activities added to the target feed afterwards always appear. It is supported on follow, followBatch, getOrCreateFollow, and getOrCreateFollows (set per follow in batch requests).

# Follow without adding existing activities
response = self.client.feeds.follow(
    source="timeline:alice",
    target="user:bob",
    activity_copy_limit=0,
)

# Add only the 20 most recent activities
response = self.client.feeds.follow(
    source="timeline:alice",
    target="user:charlie",
    activity_copy_limit=20,
)

Unfollow

When unfollowing a feed, all previous activities of that feed are removed from the timeline.

Tip:

For most use cases, prefer getOrCreateUnfollow over unfollow. It silently no-ops when the follow does not exist instead of erroring, so retries and double-clicks are safe. Use unfollow only when you specifically want unfollowing a non-existent follow to fail.

Trying to unfollow a feed that is not followed, will result in an error. You can also use the getOrCreateUnfollow endpoint for an idempotent single-unfollow call, or getOrCreateUnfollows for the batch variant.

unfollow_response = self.client.feeds.unfollow(
    f"{self.USER_FEED_TYPE}:{self.test_user_id}",
    f"{self.USER_FEED_TYPE}:{self.test_user_id_2}",
)

Update follow

You can update an existing follow relationship (for example to change push preference, custom data, or on server-side the follower role):

The endpoint performs a partial update: only the fields you include in the request are changed, and each of those fields is completely overwritten.

follower_role is server-side only: a client-side request that includes it is rejected. It can be either built-in follower role, feed_follower (the default) or feed_member_viewer, or any custom role you created for your app. It selects the permissions the follower is evaluated against: on visible and followers feeds, feed_member_viewer keeps reading, reactions and votes but drops commenting and querying a feed's follows and members.

A feed group can also set default_follower_role, which gives every new follow of a feed in that group its role automatically, so you do not need an updateFollow call per follow. An explicit follower_role here still wins over the group default. See Default follower role.

On every level except public, where anyone can post regardless, neither built-in role can post activities into a feed the follower does not own. To give followers contributor access on those levels, either add them as members or assign a custom role that you granted add-activities at that visibility level. See Custom follower roles. Note that feed_member_viewer is not uniformly more restrictive: on members and private feeds it grants read access that feed_follower does not have, which is how you admit a read-only audience to a restricted feed. See Follower permission tiers.

client.feeds.update_follow(
    source=f"timeline:{source_feed_id}",
    target=f"user:{target_feed_id}",
    push_preference="none",
    follower_role="feed_member_viewer",
    custom={"note": "Updated follow"},
)

Querying Follows

# Query follows
response = self.client.feeds.query_follows(
    limit=10,
    filter={
        "source_feed": f"{self.USER_FEED_TYPE}:{self.test_user_id}"
    }
)

Follows Queryable Built-In Fields

name type description supported operations example
source_feed string or list of strings The feed ID that is following $in, $eq { source_feed: { $eq: 'messaging:general' } }
target_feed string or list of strings The feed ID being followed $in, $eq { target_feed: { $in: [ 'sports:news', 'tech:updates' ] } }
status string or list of strings The follow status $in, $eq { status: { $in: [ 'accepted', 'pending', 'rejected' ] } }
created_at string, must be formatted as an RFC3339 timestamp The time the follow relationship was created $eq, $gt, $gte, $lt, $lte { created_at: { $gte: '2023-12-04T09:30:20.45Z' } }

Follow Requests

Some apps require the user's approval for following them.

Accepting a request can also set the follower's role, which decides the permissions that follower is evaluated against. That field is server-side only: a client-side acceptFollow that includes it is rejected. It can be either built-in follower role, feed_follower (the default) or feed_member_viewer, or a custom role you created for your app. Follow requests only arise on followers-visibility feeds, and neither built-in can post there, so approving a request with one of those grants read access; it does not let the follower post to the feed.

# Sara needs to configure the feed with visibility = followers for enabling follow requests
feeds.get_or_create_feed(
    feed_group="user",
    feed_id="sara",
    user_id="sara",
    data={"visibility": "followers"}
)

# Adam requesting to follow the feed
feeds.get_or_create_feed(
    feed_group="timeline",
    feed_id="adam",
    user_id="adam"
)

# Create follow request from adamTimeline to saraFeed
follow_response = feeds.follow(
    source="timeline:adam",
    target="user:sara"
)
print(follow_response["follow"]["status"])  # pending

# Sara accepting
feeds.accept_follow(
    source="timeline:adam",
    target="user:sara",
    follower_role="feed_member_viewer"  # optional
)

# or rejecting the request
feeds.reject_follow(
    source="timeline:adam",
    target="user:sara"
)

Push Preferences on Follow

Understanding the difference between push_preference, skip_push and create_notification_activity:

When following a feed, you can set push_preference to control push notifications for future activities from that feed:

  • all - Receive push notifications for all activities from the followed feed
  • none (default) - Don't receive push notifications for activities from the followed feed

The skip_push controls whether the follow action itself triggers a notification.

The create_notification_activity controls whether the follow action creates an activity on the source feed author's notification feed.

Note: You usually don't want to set skip_push and create_notification_activity true at the same time, for more information see the Push Overview page

// Scenario 1: Follow a user and receive notifications for their future activities
await timeline.follow("user:alice", {
  push_preference: "all", // You'll get push notifications for Alice's future posts
});

// Scenario 2: Follow a user but don't get notifications for their activities
await timeline.follow("user:bob", {
  push_preference: "none", // You won't get push notifications for Bob's future posts
});

// Scenario 3: Follow a user silently
await timeline.follow("user:charlie", {
  skip_push: true, // Charlie won't get a "you have a new follower" notification
  push_preference: "all", // But you'll still get notifications for Charlie's future posts
});

// Scenario 4: Silent follow with no future notifications
await timeline.follow("user:diana", {
  skip_push: true, // Diana won't know you followed her
  push_preference: "none", // And you won't get notifications for her posts
});

// Scenario 5: Follow a user and create notification activity for Charile
await timeline.follow("user:charlie", {
  skip_push: true, // Charlie won't get a "you have a new follower" notification
  create_notification_activity: true, // Charlie's notification feed will have a new activity
  push_preference: "all", // But you'll still get notifications for Charlie's future posts
});

Built-in fields of follows

FollowResponse

NameTypeDescriptionConstraints
created_atnumberWhen the follow relationship was createdRequired
customobjectCustom data for the follow relationship-
follower_rolestringRole of the follower (source user) in the follow relationship, as stored. A reserved name, or a role your app no longer defines, is reported as stored but evaluated as 'feed_follower'.Required
push_preferenceFollowPushPreference (all, none)Push preference for notifications. One of: all, noneRequired
request_accepted_atnumberWhen the follow request was accepted-
request_rejected_atnumberWhen the follow request was rejected-
source_feedFeedResponseSource feed objectRequired
statusFollowStatus (accepted, pending, rejected)Status of the follow relationship. One of: accepted, pending, rejectedRequired
target_feedFeedResponseTarget feed objectRequired
updated_atnumberWhen the follow relationship was last updatedRequired

Follow Suggestions

Stream provides intelligent follow suggestions to help users discover feeds they might want to follow based on their activity and social graph.

Note: The maximum limit for follow suggestions is 50. If a higher limit is requested, it will be automatically capped at 50.

# Get follow suggestions for a user
suggestions = client.feeds.get_follow_suggestions(
    feed_group_id="user",
    limit=10,
    user_id="john"
)

print(f"Algorithm used: {suggestions.algorithm_used}")
print(f"Duration: {suggestions.duration}")

for suggestion in suggestions.suggestions:
    print(f"Suggested feed: {suggestion.fid}")
    print(f"Name: {suggestion.name}")
    print(f"Description: {suggestion.description}")
    print(f"Follower count: {suggestion.follower_count}")
    print(f"Recommendation score: {suggestion.recommendation_score}")
    print(f"Reason: {suggestion.reason}")
    print(f"Algorithm scores: {suggestion.algorithm_scores}")

Response Fields

The follow suggestions response includes:

  • suggestions: Array of suggested feeds to follow
    • feed: Feed identifier
    • name: Feed name
    • description: Feed description
    • visibility: Feed visibility setting
    • member_count: Number of accepted members (pending and declined invitations are not counted)
    • follower_count: Number of followers
    • following_count: Number of feeds this feed follows
    • created_at: When the feed was created
    • updated_at: When the feed was last updated
    • recommendation_score: Combined recommendation score (0-1)
    • reason: Comma-separated names of the algorithms behind the suggestion, strongest first, for example friend-of-friend, popularity. popularity is listed only for feeds with a log-scale popularity of at least 0.5, ln(1 + follower_count) / ln(1 + max_follower_count), whichever algorithms your app uses (about 100 followers when your most-followed feed has 10,000). If no algorithm qualifies, the value is unknown. Treat any value you don't recognize as a generic suggestion
    • algorithm_scores: The score from each algorithm (0-1), keyed by algorithm name
  • algorithm_used: The algorithm used to generate suggestions
  • duration: Request processing time

Algorithm Types

By default, follow suggestions combine two algorithms, each producing a score from 0.0 to 1.0:

  • popularity (Weight: 0.3): Based on follower count

    • Compares the feed's follower count with the most-followed feed in your app
    • Score = min(follower_count / max_follower_count, 1.0)
    • Helps surface popular feeds
  • friend-of-friend (Weight: 0.7): Based on social connections and mutual follows

    • Analyzes how many of the people you follow also follow the suggested feed
    • Score = mutual_follows / your_total_follows
    • Leverages social proof and network effects
  • combined: Uses both algorithms with weighted scoring

    • Final score = (popularity_score × 0.3 + friend_of_friend_score × 0.7) / 1.0

Engaged-authors suggestions (opt-in)

Apps can opt in to suggestions based on whose content each user interacts with. Contact support to enable it for your app. When it's enabled, suggestions use three algorithms:

  • popularity (Weight: 0.2): Compares the feed's follower count with the most-followed feed in your app, on a log scale

    • Score = min(ln(1 + follower_count) / ln(1 + max_follower_count), 1.0)
    • A few very large feeds don't dominate the results
  • friend-of-friend (Weight: 0.4): Same as above

  • engaged-authors (Weight: 0.4): Based on whose content you interact with

    • Looks at your comments, reactions and bookmarks on other users' activities from the last 30 days
    • Comments count most, then reactions, then bookmarks; recent interactions count more than older ones
    • Suggests the feeds of authors you engage with but don't follow yet
    • Score grows with how much and how recently you engaged with the author, up to 1.0
  • combined: Final score = (popularity_score × 0.2 + friend_of_friend_score × 0.4 + engaged_authors_score × 0.4) / applicable_weight

    • applicable_weight is the sum of the weights of the algorithms that have data for the user. For example, for a user who follows nobody and hasn't engaged with anyone yet, only popularity applies, so popular feeds can still be suggested

With engaged-authors enabled, reason and algorithm_scores can include engaged-authors.

Scoring System

The recommendation system uses a weighted scoring mechanism:

  1. Individual Algorithm Scores: Each algorithm calculates a score from 0.0 to 1.0
  2. Weighted Combination: Scores are combined using the fixed weights above
  3. Normalization: The weighted sum is divided by the total weight. With engaged-authors enabled, only algorithms that have data for the user count towards it, so a missing signal doesn't lower every score
  4. Filtering: Only feeds with positive combined scores are included
  5. Ranking: Results are sorted by combined score in descending order

Features

  • Excludes feeds already followed by the user
  • Excludes user's own feeds
  • Results are cached per user and feed group for up to an hour. Following, unfollowing or accepting a follow request refreshes them right away; other changes are reflected once the cached results expire

Idempotent follow & unfollow

getOrCreateFollow / getOrCreateUnfollow are the idempotent single-pair variants of follow / unfollow. Calling them on a pair that is already followed (or already not followed) does not error — the response's created / deleted boolean tells you whether this call actually changed state.

getOrCreateFollows / getOrCreateUnfollows are the batch variants, accepting up to 100 pairs per call. Their response includes a created list with the subset of follows newly inserted by this call.

// Idempotent follow: returns the existing follow if one exists, otherwise creates it.
const { follow, created } = await client.feeds.getOrCreateFollow({
  source: "timeline:alice",
  target: "user:bob",
});
console.log(created ? "newly created" : "already existed", follow);

// Idempotent unfollow: no error if the follow does not exist.
const { follow: removed, deleted } = await client.feeds.getOrCreateUnfollow({
  source: "timeline:alice",
  target: "user:bob",
});
console.log(deleted ? "removed" : "was not following", removed);

Batch follow & unfollow

getOrCreateFollows/getOrCreateUnfollows endpoints allow creating a maximum of 100 follow/unfollow at once.

These are idempotent endpoints (as opposed to follow and unfollow), trying to follow/unfollow a feed that's already/not yet followed won't cause errors.

# Batch create follows
response = client.feeds.get_or_create_follows(
    follows=[
        {
            "source": "timeline:john",
            "target": "user:tom",
            # Optional
            "push_preference": "all",
            "custom": {
                "reason": "investment"
            }
        },
        {
            "source": "timeline:john",
            "target": "stock:apple"
        }
    ]
)

print(f"Created follows: {len(response['created'])}")
print(f"Total follows: {len(response['follows'])}")

# Batch remove follows
unfollow_response = client.feeds.get_or_create_unfollows(
    follows=[
        {
            "source": "timeline:john",
            "target": "user:tom"
        }
    ]
)

print(f"Follows that were removed: {len(unfollow_response['follows'])}")