Claiming Guest Users

A visitor often starts chatting with an AI agent as a guest user and signs up later. Claiming the guest moves its conversations to the new account: your backend names the guest and the regular user, and Stream moves the guest's channel memberships, the channels it created, its messages, reactions and threads to that user. The guest is deactivated.

Claiming guest users is in beta. Stream enables it per app: contact support to turn it on. Until it is enabled, the endpoint returns 403.

Why it is server-side only

Only your backend, which just authenticated the person, knows which guest session they came from. Chat cannot tell that a sign-up belongs to a given guest, and a guest token is a weak credential: it carries only a user ID and never expires. The endpoint therefore only accepts server-side requests, and your backend decides when a claim may happen, for example only after the email address is verified.

Claim a guest

Field Required Description
guest_user_id Yes The guest to claim. It must exist, have the guest role, and not be deleted, deactivated or already claimed by another user.
user_id Yes The regular user that claims the guest and receives its data. It must exist, not be a guest, and not be deleted or deactivated.

Create or upsert the regular user first, then claim:

var response = client.claimGuestUser(ClaimGuestUserRequest.builder()
    .guestUserID(guestID)
    .userID("alice")
    .build()).execute();
var taskID = response.getData().getTaskID();

The response carries the ID of the task that moves the guest's data:

{
  "task_id": "...",
  "duration": "12.31ms"
}

Wait for the move before handing out the token

The claim itself is instant, but the guest's data is moved afterwards by a background task. A client that opens the conversation before the task completes still sees the guest as the author of its messages, and can miss channels that only become readable once their ownership has moved.

So your backend should poll GET /tasks/{task_id} until status is completed, and only then give the client the new user's token:

var task = client.getTask(taskID).execute();
System.out.println(task.getData().getStatus());

When the task is completed, its result counts what it moved:

{
  "status": "completed",
  "result": {
    "channels_joined": 3,
    "channels_already_member": 1,
    "channels_ownership_transferred": 4,
    "messages_reassigned": 182,
    "reactions_reassigned": 9,
    "reactions_dropped": 1,
    "drafts_reassigned": 1,
    "pending_messages_reassigned": 0
  }
}

A large move runs in several steps, and the counters describe only the last one, so they can be lower than the totals.

A client that is already connected as the claiming user receives user.claimed over the WebSocket when the move completes. Moved messages keep their updated_at and no message.updated is sent, so refetch the user's channels and messages when that event arrives.

Retrying a claim

A claim is idempotent for the same pair of users. Claiming the same guest again with the same user_id returns the same task_id:

  • While the task is queued or running, no second move starts.
  • When the task failed, the repeat claim starts it again, and it resumes where it stopped.
  • When the task completed, the repeat claim runs the move again, which finds nothing left to move.

A repeat claim does not send a second user.claimed webhook. If two different users claim the same guest at the same time, only one succeeds; the other gets 403.

What happens to the guest

The claim deactivates the guest in the same step, which ends its session. The next request with the guest's token fails, and the guest's open connections stop receiving channel events. The socket is not closed, but reconnecting fails.

Claimed guests stay deactivated. A guest that your app deactivated before the claim cannot be claimed.

What is moved

Data How it moves
Channel memberships The guest is removed from each of its channels. Where the user is not a member yet, it joins with the guest's member data: channel role, custom data, invite state, archived, pinned, hidden, push preferences and hide_messages_before. Where the user is already a member, its own membership is kept.
Channel bans A guest's active ban in a channel follows the claim, so signing up does not lift it. The user gets the stricter of the two bans: a ban wins over a shadow ban, and the later expiry wins, with a permanent ban being the latest.
Channels it created The user becomes the channel's creator, which keeps owner access on agent channels.
Messages The user becomes the author of all the guest's messages, in every channel. updated_at does not change.
Reactions Moved to the user. When the user already has a reaction of the same type on the same message, the guest's is dropped.
Threads The user takes over the guest's thread participations. Where the user already participates, the user's participation is kept.
Drafts Moved to the user. Where the user already has a draft for the same channel or thread, the user's draft is kept.
Pending messages Moved to the user.
Pins and thread creation Moved in the channels where the guest is a member or wrote messages.

The user starts with no unread messages in the channels it joins through the claim. The guest's unread state is not carried over.

What is not moved

  • Message reminders.
  • User mutes, channel mutes, blocks, app-level bans and devices: these belong to the guest identity.
  • Hidden-channel state beyond the membership's hidden flag.
  • Mentions: existing messages that mention the guest keep the guest's ID.
  • Edits the guest made to other users' messages keep the guest as their editor.
  • Polls and votes, shared locations, scheduled messages, files and message history.
  • Pins and threads the guest made in a channel where it has neither a membership nor any message left.

Things to know

  • Distinct channels keep the guest in their ID. A distinct channel encodes its member list in its ID. Its membership moves, but its ID does not change, so a later distinct get-or-create for the user and your agent creates a new channel instead of finding the moved one.
  • A user deactivated during the move receives nothing more. If you deactivate the claiming user while its task runs, the task stops. What it already moved stays with the user. Reactivate the user and claim again with the same guest_user_id and user_id to finish the move.
  • Analytics are not rewritten. Use the user.claimed webhook to link the guest's ID to the user's ID in your own data.

Events

Event Sent to When
user.claimed Webhooks Once, when the guest is first claimed. It carries the guest as user and the claiming user as claimed_by.
user.claimed The claiming user's own connections, WebSocket only When the move completes, including the run of a repeat claim. Refetch channels and messages when it arrives.
member.removed, member.added Clients watching each channel, and webhooks For each channel the guest is a member of, as the move removes the guest and adds the user.
notification.removed_from_channel The guest's connections For each channel the guest is removed from.
notification.added_to_channel The claiming user's connections For each channel the user joins.
{
  "type": "user.claimed",
  "created_at": "2026-11-03T14:02:11.483Z",
  "user": {
    "id": "guest-1b7c4e2a-9d3f-4a6b-8c5e-2f0a1d9e7b34-visitor",
    "role": "guest",
    "deactivated_at": "2026-11-03T14:02:11.479Z"
  },
  "claimed_by": {
    "id": "alice",
    "role": "user"
  }
}

The guest's deactivation sends no user.deactivated webhook. Moving messages, reactions, threads and channel ownership sends no events of its own.

Errors

HTTP When
403 The request is not server-side.
403 Claiming guest users is not enabled for the app.
400 guest_user_id or user_id is missing or not a valid user ID, or both name the same user.
404 The claiming user does not exist, is deleted or is deactivated.
403 The claiming user is a guest.
404 The guest does not exist or is deleted.
400 The user named by guest_user_id is not a guest.
403 The guest was deactivated by your app, or was already claimed by another user.