Skip to content

feat: support scheduled messages (CHA-5934) - #1915

Open
nijeesh-stream wants to merge 3 commits into
masterfrom
feat/cha-5934-scheduled-messages
Open

nijeesh-stream wants to merge 3 commits into
masterfrom
feat/cha-5934-scheduled-messages

Conversation

@nijeesh-stream

@nijeesh-stream nijeesh-stream commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Note

The backend side (CHA-5924) is merged: edit, delete, send-now, query and include_scheduled_messages (GetStream/chat #18597, #18655, #18656) shipped in chat v239.72.0. Scheduling stays behind a per-app flag that is off by default. Check that the target shards run v239.72.0 or later before relying on this in production.

TLDR

  • Problem: The backend is adding scheduled messages: send a message later, then edit, cancel or send it now. The JS SDK has no way to use them, and the React SDK needs this before it can build any UI.
  • What/how: channel.sendMessage(message, { scheduled_at }) schedules a message. Four new client methods call the new endpoints. The scheduled_message.* events are typed. A new client.scheduledMessages store, built like client.reminders, holds the current user's scheduled messages. It loads up to 100 with one query, then stays in sync from events: created and updated messages are upserted, failed ones are marked failed, and sent or deleted ones are removed. It reloads after a connection recovery.
  • Risks: Low. Everything is additive and nothing runs unless the app calls it. One behavior change: a send with scheduled_at skips the offline queue, because replaying a queued scheduled send would add the message to the channel as if it had been sent. The API contract is final only once the backend PRs merge; see the open questions below.

Ticket

API surface

Sending

  • SendMessageOptions.scheduled_at?: string | Date. When the response has scheduled_message, the message is stored to be sent later. response.message is the content as it will be sent; it is not in the channel, and its id is the scheduled message ID. The response's scheduled_message is upserted into client.scheduledMessages.
  • ChannelQueryOptions.include_scheduled_messages?: boolean (client-side only). ChannelAPIResponse.scheduled_messages? replaces that channel's entries in the store. If the field is absent (the server did not know the list, e.g. more than 100), the store is left as it is.

Client methods

Method Endpoint
updateScheduledMessage(id, { message?, scheduled_at? }) PATCH /scheduled_messages/{id}
deleteScheduledMessage(id) (cancels a scheduled message or removes a failed one) DELETE /scheduled_messages/{id}
sendScheduledMessageNow(id) POST /scheduled_messages/{id}/send_now
queryScheduledMessages({ filter: { cid?, status? }, sort?, limit?, next?, user_id? }) POST /scheduled_messages/query

Each returns { scheduled_message }, except the query, which returns { scheduled_messages, next? }.

Store: client.scheduledMessages (ScheduledMessageManager)

  • state: StateStore<{ scheduledMessages: Map<id, ScheduledMessageResponse>, hasMore, isLoading, isLoaded, lastQueryError?, next? }>
  • registerSubscriptions() / unregisterSubscriptions(), the same opt-in as ReminderManager
  • load() (limit 100, replaces the store) and loadMore()
  • getForChannel(cid, { parentId? }), sorted by scheduled_at. parentId: '<id>' returns that thread's replies, parentId: null only messages outside threads, and leaving it out returns both
  • updateScheduledMessage, deleteScheduledMessage and sendScheduledMessageNow, which call the API and then update the store
  • The store is reset on disconnectUser(). IDs of sent and deleted messages are remembered, so a late response or event (e.g. the send-now response arriving after scheduled_message.sent) cannot bring a message back.
  • Responses and events can arrive out of order. An update older than the stored version (by updated_at) is ignored, so the send-now response or its .updated event cannot turn a message that already failed back into scheduled: the backend queues the send before it answers. load() and channel query hydration keep changes that arrived while they were in flight. Responses to requests made before disconnectUser() are dropped, so the next user never sees the previous user's messages.

Types and events

  • Types: ScheduledMessageResponse, ScheduledMessageContent, ScheduledMessageStatus (scheduled | sending | failed | canceled | sent), ScheduledMessageFailureReason, ScheduledMessageChannelResponse, ScheduledMessageLocation, and the request and response types.
  • Events: scheduled_message.created, .updated, .deleted, .sent (with a top-level message_id, the final message ID) and .failed. Event.scheduled_message is added.
  • Error codes 114-122 (ScheduledMessagesDisabledError ... ScheduledMessageConflictError) are added to APIErrorCodes. Only the conflict error (122) is marked retryable. APIErrorResponse.exception_fields is typed, because the backend puts the error name in exception_fields.reason.

Backend contract notes (CHA-5924)

Resolved by merged backend PRs:

  • Messages waiting for a send retry now report status: "sending" (GetStream/chat#18730). The query's status filter matches the reported status, and 409 scheduled_message_sending happens exactly when the status is sending.
  • pending: false can now be sent together with scheduled_at (#18727); only pending: true is rejected.
  • In the scheduled SendMessage response, message.mentioned_users now holds user objects (#18731). scheduled_message.message.mentioned_users still holds user IDs.
  • include_scheduled_messages is client-side only (confirmed).
  • Custom fields are flat in scheduled_message.message in v1, the same as on regular messages.

Still worth knowing:

  1. In the SendMessage response, message.id is the scheduled message ID while the message is not in the channel (by design). The React SDK must drop its optimistic message when scheduled_message is present.
  2. A user can have 100 pending messages plus 7 days of failed ones, which can exceed one query page of 100. The store exposes hasMore and loadMore() for this.

Target branch

This PR targets v9 (master). It will need porting to release-v10 for React SDK v15.

Testing

  • New unit tests in test/unit/scheduledMessages/: the store (upsert, remove, terminal statuses, overtaken updates, channel hydration, disconnect reset, all five events, reload on recovery, load and loadMore, errors) and the API (URLs and payloads, sort normalization, scheduled_at passthrough, offline queue bypass, include_scheduled_messages hydration).
  • Ordering tests: send-now response and .updated event after .failed, a message scheduled or failed while load() or a channel query is in flight, and load, send-now, sendMessage and channel query responses arriving after disconnectUser(). Without the ordering fix, 10 of them fail.
  • Local runs: yarn types, yarn lint, yarn build and the full unit suite (3252 passed; the scheduled message suites have 45 tests) all pass. The suite also reports one unhandled rejection from test/unit/poll.test.js, which comes from master and not from this change.

CLA

  • I have signed the Stream CLA (required). (Stream employee: left for the author to confirm.)
  • Code changes are tested

Changelog

None written by hand: semantic-release builds the changelog from the feat: commit.

🤖 Generated with Claude Code

Add scheduled_at to sendMessage options, the update/delete/send_now/query
scheduled message endpoints, the scheduled_message.* events and a
ScheduledMessageManager (client.scheduledMessages) that keeps the current
user's scheduled messages in sync from the query, channel queries with
include_scheduled_messages, and events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nijeesh-stream and others added 2 commits October 15, 2026 04:34
Messages awaiting a send retry report status sending, pending: false is
accepted with scheduled_at, and the scheduled send response hydrates
mentioned_users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Ignore an upsert older (by updated_at) than the stored version, so the
  send-now response or its updated event cannot revert a failed message
  to scheduled.
- load() and channel query hydration no longer undo changes that arrived
  while they were in flight.
- Drop responses to requests made before disconnectUser(), so a new user
  never sees the previous user's scheduled messages.
- getForChannel accepts parentId: null for messages outside threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant