Scheduled Messages

Scheduled messages let a user write a message now and have it sent later. The user picks a time, and Stream sends the message to the channel at that time on the user's behalf. Until then the user can edit the message, move it to another time, send it right away, or cancel it.

A scheduled message is private until it is sent. Other channel members see nothing, receive no events and get no push notifications until the message is actually sent. When it is sent, it is a normal message: it goes through the same checks, moderation, webhooks and push notifications as a message sent at that moment.

Scheduled messages are built for one user scheduling one message into one channel, direct message or thread. To send the same message to many users or channels, use Campaigns instead.

Info:

Client-side support is available in the JavaScript SDK (and the React and React Native SDKs built on it). From a backend, use one of the server-side SDKs to schedule messages on behalf of your users.

Overview

  • Off by default. Scheduled messages are enabled per app by Stream. See Enabling scheduled messages.
  • Scheduled with the send message endpoint. Add scheduled_at to a normal send message request. Anything you can send as a message, including thread replies, attachments, mentions and polls, can be scheduled. Slash commands cannot.
  • Sent on the minute. A future send time is moved to the next whole minute, at least 1 minute from now. A send time that is now or in the past sends the message right away, so a request queued while the device was offline still goes out, as long as scheduled messages are still enabled for the app.
  • Checked twice. The request is checked when it is scheduled (permissions, channel, content, limits), and the message goes through the full send path again at the scheduled time. A message that was valid when scheduled can still fail to send, for example when the user was banned or lost the permission to send in the meantime.
  • Owned by one user. Each scheduled message belongs to the user who scheduled it. Client-side, users can only see and change their own scheduled messages. Server-side, you can manage the scheduled messages of any user.
  • Events go to the owner only. scheduled_message.* events are delivered only to the connections of the user who scheduled the message.

Enabling scheduled messages

Scheduled messages are an app-level setting that is off by default. To enable them for your app, contact support.

While the feature is off, scheduling a message or editing a scheduled message fails with error code 114 (scheduled_messages_disabled). Listing, deleting and sending scheduled messages now keep working, so users can still manage the messages they already scheduled.

Permissions

Scheduling a message requires the ScheduleMessage permission in addition to the permissions needed to send the same message right away (CreateMessage, CreateReply for thread replies, attachment, mention and pin permissions, and so on). By default, ScheduleMessage is granted to the same roles as CreateMessage. See the permissions reference for the default grants.

To show or hide a "schedule" option in your UI, check the schedule-message capability in the channel's own_capabilities. The capability is only returned when scheduled messages are enabled for the app.

A user without the ScheduleMessage permission gets the usual permission error: HTTP 403 with error code 17.

Limits

Limit Default Channel config field
Pending scheduled messages per user in a single channel 10 scheduled_messages_max_per_channel
Pending scheduled messages per user across the app 100 scheduled_messages_max_per_user
Earliest send time 1 minute scheduled_messages_min_lead_time_seconds
Latest send time 120 days scheduled_messages_max_lead_time_seconds
  • A pending scheduled message is one with status scheduled or sending. Failed and deleted scheduled messages don't count toward the limits.
  • The earliest send time is fixed at 1 minute and cannot be changed. The other values are defaults that Stream can change for your app.
  • A future send time closer than 1 minute is moved to 1 minute from now, rounded up to the minute. It is never rejected.
  • A send time later than the latest send time is rejected with error code 116.

The app's limits are returned in every channel's config, in the fields listed above, so your UI can respect them before the API refuses a request, for example to bound a date picker or disable the schedule option when the user has reached the limit. The fields are only present when scheduled messages are enabled for the app. The values are the same for every channel of the app: they are app settings, not channel type settings.

Scheduling a message

To schedule a message, send it with scheduled_at set to the time it should be sent. Times are in UTC.

var resp = await chat.SendMessageAsync("messaging", "general",
    new SendMessageRequest
    {
        Message = new MessageRequest
        {
            Text = "Happy birthday!",
            UserID = "john",
            Custom = new Dictionary<string, object> { ["priority"] = "high" }
        },
        ScheduledAt = new DateTime(2026, 12, 24, 9, 0, 0, DateTimeKind.Utc)
    });

var scheduledMessage = resp.Data.ScheduledMessage;

The response contains scheduled_message, and nothing is added to the channel. The response's message field is only a preview of what will be sent: it is not in the channel, and its id is the ID of the scheduled message, not the ID the sent message will have. Don't add it to the channel's message list or render it as sent.

Custom data is set on a scheduled message the same way as on a regular message, and the sent message carries it like any other message.

To schedule a thread reply, set parent_id on the message, as you would when replying in a thread.

Send time

Messages are sent on whole minutes. A future scheduled_at is moved to the first whole minute (UTC, seconds set to zero) that is not earlier than the requested time and at least 1 minute from now. A message is never sent earlier than requested.

Requested scheduled_at (now is 10:20:00) Stored scheduled_at
10:30:00 10:30:00
10:30:20 10:31:00
10:20:30 10:21:00
10:20:00 or earlier Sent right away

scheduled_message.scheduled_at in the response holds the stored time. Show that time to the user, and send it back unchanged when you update the message.

A scheduled_at that is now or in the past sends the message right away, as if scheduled_at was not set: the response is a normal send message response with the sent message and no scheduled_message. This lets a client that queued a schedule request while offline replay it after reconnecting, even when the time has passed. Sending right away needs only the permissions of a normal send, not ScheduleMessage. It still needs scheduled messages to be enabled for the app: while the feature is off, a request with scheduled_at fails with error code 114, whatever the time. A request with scheduled_at cannot set pending: true, even when the time has passed.

Retrying a schedule request

If you set message.id when scheduling, it becomes the ID of the scheduled message. Sending the same request again with the same ID, from the same user and to the same channel, returns the existing scheduled message instead of scheduling a second one, as long as it is still scheduled, sending or failed. This makes it safe to retry a schedule request after a timeout or replay it after the device was offline. Using an ID that already belongs to another user's or another channel's scheduled message fails with error code 119 (scheduled_message_id_taken).

A retry can also find that the scheduled message is no longer there:

  • If it was already sent, the request fails with error code 123 (scheduled_message_already_sent), so the message is not posted twice. Treat this error as success: the message under that ID was sent.
  • If the user canceled it, for example on another device, a retry whose scheduled_at is now or in the past fails with error code 124 (scheduled_message_canceled), so a replay does not send a message the user canceled. A retry with a future scheduled_at schedules the message again under the same ID.

Stream keeps a canceled scheduled message for 7 days. A replay that arrives later, or after the sent message was hard deleted, is treated as a new message.

What is checked when

When a message is scheduled, Stream runs the checks that need no side effects: the feature setting, the send time, the user and the channel, permissions, content rules (text length, attachments, mentions, quoted message, poll, shared location) and the limits. Requests that fail these checks are rejected right away.

At the scheduled time, the message goes through the full send path, which also runs moderation, the before message send webhook, URL enrichment and translation. Slow mode does not apply to scheduled messages.

Because every check runs again at the scheduled time, a message that was valid when it was scheduled fails if it is no longer valid when it is sent. For example, if the message quotes another message and that message is deleted before the send, the scheduled message fails with the invalid_message reason, just as a normal send that quotes a deleted message is rejected.

If the channel is configured to mark new messages as pending but pending messages are not enabled for your app, scheduling fails with HTTP 403, as a normal send would. Otherwise scheduling works, but the send fails with the pending_not_supported reason unless the request sets pending: false explicitly (server-side only). A schedule request that sets pending: true is rejected.

The scheduled message object

Field Type Description
id string ID of the scheduled message. Not the ID of the message once it is sent
cid string The channel the message will be sent to
user object The user who scheduled the message
message object The message that will be sent: text, attachments, parent_id, mentioned_users (user IDs), custom data and more
scheduled_at date When the message will be sent
status string See Statuses
failure_reason string Why the send failed. Only set when status is failed. See Failure reasons
channel object The channel's cid, type, id, name and image. Only set in query results
created_at date When the message was scheduled
updated_at date When the scheduled message was last changed

In the scheduled message's message, mentioned_users holds the user IDs from the request, not user objects. Custom data behaves exactly as on regular messages: set it on the message the same way when scheduling or updating, and it comes back in the same shape your SDK uses for regular messages (top-level fields in the JavaScript SDK, under custom in the server-side SDKs).

Statuses

Status Meaning Allowed actions
scheduled Waiting for its send time Update, send now, delete
sending Being sent, or waiting to retry after a temporary send error None
failed The send failed. failure_reason says why Delete
canceled Deleted by the user. Only returned by the delete call and the scheduled_message.deleted event None
sent Sent. Only reported by the scheduled_message.sent event None

Once a scheduled message is sent, it no longer exists as a scheduled message: it is a normal message in the channel. Failed scheduled messages are kept for 7 days so the user can see what went wrong, and are then removed.

Failure reasons

Reason Meaning
banned The user is banned from the channel or the app
no_permission The user no longer has permission to send this message
channel_frozen The channel is frozen. Only for messages scheduled client-side: like a live send, a message scheduled server-side is not blocked by a freeze
channel_deleted The channel was deleted
user_deleted The user was deleted or deactivated
moderation_blocked Moderation blocked the message
hook_rejected The before message send webhook rejected the message
expired The message could not be sent in time. See below
invalid_message The message is no longer valid, for example because the app's message settings changed or a quoted message was deleted
pending_not_supported The channel marks new messages as pending, and the request did not set pending: false
internal_error The send failed 3 times because of a temporary error

A message expires when it could not be sent within 1 hour of its scheduled time, for example during an outage. When many messages are due at the same minute, the ones that were queued on time are still sent after that hour, but never more than 24 hours late.

failure_reason only ever holds one of the values above, so you can switch on it to show your own text.

Listing scheduled messages

Query scheduled messages to list a user's pending (scheduled and sending) and failed scheduled messages. Pending messages come first, then failed ones, each ordered by scheduled_at. Client-side, the query returns the current user's scheduled messages. Server-side, user_id is required.

You can filter by channel (cid) and by status (one or more of scheduled, sending and failed). The only supported sort is scheduled_at ascending, which is also the default. The page size defaults to 20 and can be at most 100.

var resp = await chat.QueryScheduledMessagesAsync(new QueryScheduledMessagesRequest
{
    UserID = "john",
    Filter = new ScheduledMessagesFilter { Cid = "messaging:general" },
    Limit = 20
});

var scheduledMessages = resp.Data.ScheduledMessages;

A user can have up to 100 pending scheduled messages plus their failed ones, so a single page may not hold all of them. Follow next until it is empty, and remove duplicates by id across pages, because a message can move between pages when its time changes.

Including scheduled messages in channel state

You can load the user's scheduled messages together with the channels themselves. Set include_scheduled_messages when watching or querying a channel, or when querying channels, and each channel's state contains scheduled_messages: the user's scheduled, sending and failed messages in that channel, or an empty list when there are none.

JavaScript
// One channel
const channel = client.channel("messaging", "general");
const state = await channel.watch({ include_scheduled_messages: true });

if (state.scheduled_messages) {
  // The user's scheduled messages in this channel
} else {
  // Not available, use client.queryScheduledMessages instead
}

// A page of channels
const channels = await client.queryChannels(
  { members: { $in: [client.userID] } },
  { last_message_at: -1 },
  { include_scheduled_messages: true },
);

scheduled_messages is always the complete list, never a partial one. When the complete list can't be returned, the field is left out instead, and you should use the query for that channel. A missing field means "not loaded", not "none". This happens when:

  • the feature is off for the app;
  • the list would hold more than 100 scheduled messages. Pending messages are limited to 10 per channel, so this only happens when the user also has many failed messages. When querying channels, the 100 applies to all channels of the page together, and the field is then left out on every channel of the page;
  • the scheduled messages could not be loaded.

Which user's scheduled messages are returned:

  • Client-side: the current user's.
  • Server-side, watching or querying one channel: the user set as created_by or created_by_id in the request's data. Without one, the field is left out.
  • Server-side, querying channels: the option is not supported. Query scheduled messages with user_id and a cid filter instead.

Updating a scheduled message

You can change the content of a scheduled message, its send time, or both. A new message replaces the scheduled message as a whole, so send every field you want to keep. Only messages with status scheduled can be updated.

var resp = await chat.UpdateScheduledMessageAsync(scheduledMessageId, new UpdateScheduledMessageRequest
{
    Message = new MessageRequest { Text = "Happy birthday, John!" },
    ScheduledAt = new DateTime(2026, 12, 24, 8, 0, 0, DateTimeKind.Utc)
});

An update runs the same content and permission checks as scheduling, but not the limits.

A new scheduled_at is rounded up to the minute and checked against the latest send time, as when scheduling. Unlike scheduling, an update never sends the message: a scheduled_at that is now or in the past fails with error code 115 (scheduled_at_too_soon). To send a scheduled message right away, use send now.

When you only change the text, you can send scheduled_at again or leave it out. A scheduled_at equal to the stored one (scheduled_message.scheduled_at from the last response) is treated as unchanged: it is not checked again, even if the send time is less than a minute away. An update without message whose scheduled_at is left out or equal to the stored one changes nothing: it returns the scheduled message without a scheduled_message.updated event. An update that includes message is always saved and sends scheduled_message.updated, even when the message is unchanged.

Sending a scheduled message now

To send a scheduled message right away instead of waiting for its time, use send now. The send happens in the background: the response returns the scheduled message, and the scheduled_message.sent or scheduled_message.failed event reports the outcome. Only messages with status scheduled can be sent now.

var resp = await chat.SendScheduledMessageNowAsync(scheduledMessageId, new SendScheduledMessageNowRequest());

Deleting a scheduled message

Deleting a scheduled message cancels it: it will not be sent, and the response returns it with status canceled. Deleting a failed message removes it, and the response returns it as it was, with status failed. After a delete, the scheduled message no longer appears anywhere.

var resp = await chat.DeleteScheduledMessageAsync(scheduledMessageId);

Deleting the user or the channel also affects their scheduled messages:

  • Hard or pruning user delete: the user's scheduled messages are removed.
  • Soft user delete or deactivation: the scheduled messages are kept, and fail with the user_deleted reason at their send time.
  • Channel delete: scheduled messages that were not sent yet fail with the channel_deleted reason, and their owners get scheduled_message.failed. After a soft delete they are kept for 7 days like other failed scheduled messages; a hard delete removes them. Messages that were already sending are removed without an event.

Messages that are being sent

A message with status sending cannot be updated, sent now or deleted: these calls fail with HTTP 409 and error code 121 (scheduled_message_sending). This includes a message that is waiting to be retried after a temporary send error. Don't retry the call; wait for the scheduled_message.sent or scheduled_message.failed event instead.

If two changes to the same scheduled message happen at the same time, one of them fails with HTTP 409 and error code 122 (scheduled_message_conflict). Load the scheduled message again and retry.

Keeping scheduled messages in sync on the client

The JavaScript SDK keeps the current user's scheduled messages in client.scheduledMessages. It loads them with one query and then keeps them up to date from the scheduled_message.* events: created and updated messages are added, failed ones are marked as failed, and sent or deleted ones are removed. It also takes the scheduled_messages of channels watched or queried with include_scheduled_messages, and the scheduled_message of schedule responses. After a connection recovery, it loads them again.

JavaScript
// Once the user is connected
client.scheduledMessages.registerSubscriptions();
await client.scheduledMessages.load();

// Ordered by scheduled_at
const forChannel = client.scheduledMessages.getForChannel(channel.cid); // excludes thread replies
const forThread = client.scheduledMessages.getForThread(parentMessageId);
const count = client.scheduledMessages.getCountForChannel(channel.cid);

// React to changes
const unsubscribe = client.scheduledMessages.state.subscribeWithSelector(
  (state) => ({ scheduledMessages: state.scheduledMessages }),
  ({ scheduledMessages }) => {
    // scheduledMessages is a Map of scheduled message ID to scheduled message
  },
);

// These call the API and update the store
await client.scheduledMessages.updateScheduledMessage(id, { scheduled_at });
await client.scheduledMessages.sendScheduledMessageNow(id);
await client.scheduledMessages.deleteScheduledMessage(id);

load() fetches up to 100 scheduled messages. When state.next is set, call loadMore() to fetch the next page. When the feature is off for the app, state.isDisabled is true.

Events

scheduled_message.* events are sent only to the connections of the user who scheduled the message, never to other channel members. Only scheduled_message.sent and scheduled_message.failed are also sent to webhooks.

Event Trigger Webhook
scheduled_message.created A message was scheduled No
scheduled_message.updated A scheduled message was updated or sent now No
scheduled_message.deleted A scheduled message was canceled or a failed one removed No
scheduled_message.sent A scheduled message was sent Yes
scheduled_message.failed A scheduled message failed to send Yes

Every event carries cid, user and scheduled_message. scheduled_message.sent reports the status as sent and also carries message_id, the ID of the message that was sent to the channel. The sent message itself is delivered like any other message, with message.new to the channel members.

Messages that expire, or fail because their channel was deleted, also send scheduled_message.failed. If the user who scheduled the message was deleted, the scheduled_message.sent and scheduled_message.failed webhooks are still sent, with the deleted user in user.

JavaScript
client.on("scheduled_message.sent", (event) => {
  console.log(`${event.scheduled_message.id} was sent as ${event.message_id}`);
});

client.on("scheduled_message.failed", (event) => {
  console.log(event.scheduled_message.failure_reason);
});

Errors

Scheduled message errors set exception_fields.reason to the name in the table below, so you can handle them without hardcoding the numeric code. Errors caused by a limit also carry the limit in exception_fields, as a string, so you can write your own error text.

Code Reason HTTP status Meaning Extra exception_fields
114 scheduled_messages_disabled 400 Scheduled messages are not enabled for the app
115 scheduled_at_too_soon 400 An update set scheduled_at to now or a past time. Only returned when updating min_lead_time_seconds
116 scheduled_at_too_far 400 scheduled_at is later than the app's latest send time (120 days by default) max_lead_time_seconds
117 scheduled_limit_channel 400 The user has too many pending scheduled messages in this channel limit
118 scheduled_limit_user 400 The user has too many pending scheduled messages limit
119 scheduled_message_id_taken 400 The scheduled message ID belongs to another user's or channel's message
120 scheduled_message_not_editable 400 The scheduled message failed to send and can only be deleted
121 scheduled_message_sending 409 The scheduled message is being sent and cannot change
122 scheduled_message_conflict 409 Another change to the scheduled message happened first. Reload and retry
123 scheduled_message_already_sent 400 A message with this ID was already sent. When retrying a schedule request, treat as success
124 scheduled_message_canceled 400 The scheduled message with this ID was canceled, so a replay with a past time is not sent

For example:

{
  "code": 117,
  "message": "SendMessage failed with error: \"you can schedule at most 10 messages in this channel\"",
  "exception_fields": { "reason": "scheduled_limit_channel", "limit": "10" }
}

Updating, sending now or deleting a scheduled message that does not exist, was already sent or canceled, or belongs to another user (client-side) returns HTTP 404.

REST endpoints

Action Endpoint
Schedule a message POST /channels/{type}/{id}/message with scheduled_at
Query scheduled messages POST /scheduled_messages/query
Update a scheduled message PATCH /scheduled_messages/{id}
Send a scheduled message now POST /scheduled_messages/{id}/send_now
Delete a scheduled message DELETE /scheduled_messages/{id}