Skip to content

Diagnostic Slack

time_in_past: the scheduled send is behind the clock

The digest is scheduled for nine in the morning. Most days it goes out. Some days the scheduler logs {"ok": false, "error": "time_in_past"} and there is no digest, and nobody notices until somebody asks where Tuesday went.

Nothing is intermittent about the code. post_at is a Unix timestamp in seconds, it must still be in the future when Slack processes the call, and it must be inside 120 days. Every way of getting it wrong is arithmetic: milliseconds where seconds were wanted, a local wall clock treated as UTC, or a target so close to now that the queue eats the difference before the request lands.

Read-only token Python and Node.js Tests included
Green circuit board with a central processor and glowing components.
Photo by Brecht Corbeel on Unsplash
The short answer

chat.scheduleMessage takes post_at as whole Unix seconds. Slack refuses it with time_in_past if that moment has already passed by the time the request is processed, with invalid_time if the value is not a usable timestamp, and with time_too_far if it is more than 120 days ahead.

Four causes cover almost all of it. Date.now() returns milliseconds, so passing it directly produces a date tens of thousands of years out, and dividing it carelessly produces one in the past. Timezone arithmetic that builds a local wall-clock value and hands it over as if it were UTC is off by a whole number of hours, which is why this fails for some of your users and not others. Scheduling now plus a few seconds loses to queueing latency. And a rota that computes dates a year ahead sails past the 120-day horizon without anything local complaining.

The queue itself is readable. chat.scheduledMessages.list returns every pending send with its id, channel_id, post_at and date_created, which is enough to find the values that will never fire and the ones that only fired because they got lucky. Listing is a read; cancelling anything you find is a write, and this script prints that rather than doing it.

The problem in plain words

The reason this is filed as flaky rather than as broken is that most of these bugs only fail near a boundary. A job that schedules five minutes from now succeeds every time the queue is warm and fails on the mornings the worker cold-starts, because Slack evaluates post_at when it processes the request rather than when you computed it. Nothing in the code distinguishes the good days from the bad ones, so it gets a retry wrapped around it and the retry recomputes the same too-close value.

The unit bug is the loudest and the easiest to miss in review. Date.now() is milliseconds and time.time() is float seconds, and the two get mixed anywhere a timestamp crosses a language boundary. Passing milliseconds gives a moment roughly fifty thousand years from now, which Slack rejects as too far. The half-fix is worse: someone divides by a thousand somewhere else in the chain, the value gets divided twice, and now it is 1970 plus a few hundred thousand seconds, which is firmly in the past. Same variable, two opposite errors, both reported as a scheduling failure.

Timezones fail with a signature you can actually recognise. Code that builds nine in the morning as a local datetime and then takes its epoch as if it were UTC is wrong by exactly the offset, so the value is off by a whole number of hours: one or two for most of Europe, five to eight across the Americas, and thirty or forty five minutes for India, Nepal and the Chatham Islands. Every one of those is a clean multiple, which is what tells it apart from a value that is merely stale.

The horizon catches the opposite kind of code: careful, correct, and planning further ahead than the API allows. A quarterly reminder scheduled at the start of the year is beyond 120 days for three quarters of its entries, and the failures arrive as a batch of time_too_far that looks like an outage.

What all four share is that the number is wrong before the call is made, so all four are checkable without making the call. That is the entire argument for reading the queue rather than watching the error log: the error log tells you a send failed, and the queue tells you which of the pending ones is going to.

Nine in themorningcomputed locallyHanded over asUTCoff by the offsetWorker coldstartsforty seconds latetime_in_pastno digest todayRetry, samenumbernearer the targetrecompute, resend, lose the same race
Slack reads post_at when it processes the request rather than when you computed it, so every second of queueing spends the margin you left, and a retry starts from a worse position than the first try.

Why it happens

The value is evaluated when Slack processes the request, not when you build it. This is why a margin matters and why now plus five seconds is not a schedule, it is a race. The script reports a value inside the margin as a finding even though it has not failed yet, because it is going to.

Milliseconds are recognisable by digit count and nothing else needs to be inferred. A ten-digit value is seconds and a thirteen-digit one is milliseconds. That single comparison catches the most common cause outright and does not need a token, a network call, or any knowledge of what you intended.

A whole number of hours is a timezone bug, and anything else is not. This is the finding that saves the most time, because a value that is off by exactly five hours has a completely different cause from one that is off by four minutes. Reporting only the time is wrong loses the one detail that names the bug.

A value under a billion is a duration, not a timestamp. Passing 300 for five minutes from now is not an edge case, it is what happens when a delay and a deadline share a variable name. Slack reads it as 1970 plus five minutes, and the error is time_in_past for a value nobody thought of as a time at all.

The 120-day horizon is a design constraint rather than a bug. If your calendar reaches further than that, the answer is a durable store and a job that schedules as the horizon rolls forward, not a retry. The script names too-far separately so that this conclusion is reachable from the output.

Listing is a read and cancelling is not. chat.scheduledMessages.list answers every question this note asks. Removing a bad entry from the queue changes your workspace, so the script prints the id and the method and leaves the decision with you, which is the same promise every other script in this section makes.

The fix, as a flow

The only chain here with a loop under it, and the loop is the real shape of the bug: the send fails, something retries it, the retry recomputes the same number a few seconds nearer the target, and it loses the same race again. The red box is the fourth one because the arithmetic is fine on a warm morning. The fix branch sorts by cause, and the bottom two are the rows where nothing about the code is wrong at all.

The queue, and one numberpost_at, date_created, nowThirteen digitsmilliseconds, so fifty thousand yearsBehind the clockcomputed before a queue got involvedA clean five hours outa wall clock handed over as UTCPast one hundred and twenty dayscorrect, and further than the API goesA minute of room, in secondsand it survives a cold start
The units are decided before the clock, because a value in milliseconds is not early or late, it is the wrong kind of number, and a bare wrong sends somebody to look at a horizon that has nothing to do with it.

How to fix it

Check the computed value before you check the queue

--post-at 1755000000 needs no token at all. post_at_verdict compares the number against the clock and against the shape a Unix second has, and returns milliseconds, duration-not-timestamp, message-ts, in-past, too-soon, too-far or plausible.

Give the script what you meant, and let it name the timezone

--intended 2026-09-01T09:00:00+00:00 turns the check into a comparison. offset_smell reports whole-hour-offset when the difference is a clean number of hours, which is a timezone conversion and not a rounding error, and half-hour-offset for the zones that are not on the hour.

Read the whole queue, following the cursor

chat.scheduledMessages.list pages. queue_report folds the pages into counts by verdict and names the nearest pending send, so the output answers what is going to break rather than what broke.

Treat the gap between date_created and post_at as its own signal

creation_margin looks at how long the scheduler gave itself. An entry created eight seconds before it is due did not fail, and the next one like it will. That is a different finding from too-soon, which is about the clock now, and the two are reported separately.

Separate too-far from every other finding

Everything else on this list is a bug in the arithmetic. too-far is arithmetic that is entirely correct against an API that will not take it, and the repair is a job that schedules as the horizon rolls forward rather than a fix to the calculation.

Cancel the bad entries yourself

The script prints each unusable entry's id and the method that removes it. It does not call it. A scheduled message is a pending change to your workspace, and this section's scripts do not make changes to your workspace even when the change is a deletion you asked for.

How to check it worked

Run it against the pending queue. Every line names an entry, a cause and whether it can still fire.

python3 slack_scheduled_in_past.py --queue
# queue      6 pending send(s) read across 1 page(s)
# entry      milliseconds   Q1298ABCD C024BE91L post_at=1756713600000  thirteen digits is
#                           Date.now(); Slack reads seconds and refuses this as too far
# entry      too-soon       Q1298ABCE C024BE91L post_at=1756713612  12s from now, and
#                           Slack evaluates post_at when it processes the call
# entry      too-far        Q1298ABCF C07J4K2QT post_at=1790000000  386 days out and the
#                           horizon is 120
# margin     tight-window   Q1298ABCE created 9s before it is due; the next one like this
#                           loses the race
# entry      plausible      Q1298ABCG C024BE91L post_at=1756800000
# verdict    3 of 6 pending send(s) will not fire
#   repair: compute post_at as whole seconds and assert post_at > now + 60 before sending
#   repair: do the arithmetic in UTC and convert only for display

The full code

Four pure functions and one paginated read. post_at_verdict is the whole unit argument in one table and runs against a value you type on the command line. offset_smell is the function that earns the note, because a difference of exactly five hours and a difference of four minutes are two unrelated bugs and only one of them is about timezones. creation_margin reads the gap the scheduler gave itself, which is the leading indicator. queue_report folds the pages into an answer.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_scheduled_in_past.py
"""Audit the pending scheduled sends for values that will never fire.

Read only. chat.scheduledMessages.list is a read and is the only method this
script calls; scheduling a message and cancelling one are both writes and
neither is here. When an entry is unusable the script prints its id and the
method that removes it, and leaves the call to you.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_scheduled_in_past")

API = "https://slack.com/api/"

# Slack takes a schedule up to 120 days out and no further.
HORIZON_SECONDS = 120 * 86400
# Below this the value is not an absolute time at all: 1e9 seconds is 2001, and
# anything smaller is a duration somebody handed over as a deadline.
SMALLEST_PLAUSIBLE = 1_000_000_000
# Ten digits is seconds, thirteen is milliseconds, sixteen is microseconds.
MILLISECONDS = 1_000_000_000_000
MICROSECONDS = 1_000_000_000_000_000
DEFAULT_MARGIN = 60


def post_at_verdict(post_at, now, margin=DEFAULT_MARGIN, horizon=HORIZON_SECONDS):
    """Will Slack accept this post_at, and if not, which mistake was it? Pure.

    Returns (verdict, detail). The unit verdicts come before the clock verdicts
    because a value in milliseconds is not early or late, it is the wrong kind
    of number, and reporting it as too-far sends somebody to look at a horizon
    that has nothing to do with it.
    """
    if post_at is None or post_at == "":
        return ("missing", "no post_at. chat.scheduleMessage has no default and will "
                           "not treat an absent value as send now")
    try:
        value = float(post_at)
    except (TypeError, ValueError):
        return ("not-a-number", "%r is not a number. An ISO 8601 string is the usual "
                                "cause; post_at is Unix seconds, not a formatted date"
                % (post_at,))
    if value != int(value):
        return ("message-ts", "%s has a fractional part, which is the shape of a message "
                              "ts rather than of a post_at. post_at is whole seconds"
                % post_at)
    value = int(value)
    if value >= MICROSECONDS:
        return ("microseconds", "%d digits. This is microseconds, which is what a "
                                "database timestamp column hands back on some drivers"
                % len(str(abs(value))))
    if value >= MILLISECONDS:
        return ("milliseconds", "%d digits, so this is milliseconds. Date.now() returns "
                                "milliseconds and Slack reads seconds, which puts this "
                                "roughly fifty thousand years out and gets it refused "
                                "as too far" % len(str(abs(value))))
    if value < SMALLEST_PLAUSIBLE:
        return ("duration-not-timestamp",
                "%d is not an absolute time. Slack reads it as 1970 plus %d seconds, "
                "which is what happens when a delay and a deadline share a variable name"
                % (value, value))
    delta = value - int(now)
    if delta <= 0:
        return ("in-past", "%d seconds behind the clock already. Slack evaluates post_at "
                           "when it processes the request, so a value computed a while "
                           "ago can be fine at build time and late on arrival"
                % abs(delta))
    if delta <= margin:
        return ("too-soon", "%ds from now, inside the %ds margin. Queueing latency, a "
                            "retry, or a cold worker will push this past the target "
                            "before Slack sees it" % (delta, margin))
    if delta > horizon:
        return ("too-far", "%d days out and the horizon is %d. The arithmetic is right "
                           "and the API will not take it, so this wants a job that "
                           "schedules as the horizon rolls forward"
                % (delta // 86400, horizon // 86400))
    return ("plausible", "%d days, %d hours out and inside every limit"
            % (delta // 86400, (delta % 86400) // 3600))


def offset_smell(post_at, intended_epoch, tolerance=90):
    """Is the difference a timezone conversion or something else? Pure.

    A value that is wrong by exactly five hours and a value that is wrong by
    four minutes have unrelated causes, and only the first one is about
    timezones. Reporting the size of the error without its shape loses the one
    detail that names the bug.
    """
    try:
        diff = int(float(post_at)) - int(float(intended_epoch))
    except (TypeError, ValueError):
        return ("uncomparable", "one of these is not a number, so there is nothing to "
                                "compare")
    size = abs(diff)
    direction = "ahead of" if diff > 0 else "behind"
    if size <= tolerance:
        return ("aligned", "within %ds of the moment you meant" % size)
    hours = round(size / 3600.0)
    if 1 <= hours <= 14 and abs(size - hours * 3600) <= tolerance:
        return ("whole-hour-offset",
                "exactly %d hour(s) %s the moment you meant. A clean multiple of an "
                "hour is a timezone conversion, not a rounding error: a local wall "
                "clock was handed over as if it were UTC" % (hours, direction))
    remainder = size % 3600
    if abs(remainder - 1800) <= tolerance or abs(remainder - 2700) <= tolerance:
        return ("half-hour-offset",
                "%ds %s the moment you meant, which lands on a 30 or 45 minute "
                "boundary. India, Nepal and the Chatham Islands are not on the hour, "
                "and this is what their users see" % (size, direction))
    return ("drifted", "%ds %s the moment you meant, and not a clean timezone offset. "
                       "This is a stale value or a clock problem rather than a "
                       "conversion" % (size, direction))


def creation_margin(post_at, date_created, margin=DEFAULT_MARGIN):
    """How much room did the scheduler give itself? Pure.

    Different from too-soon, which is about the clock now. This is about the
    gap the code chose, and it is the leading indicator: an entry created eight
    seconds before it was due did not fail, and the next one like it will.
    """
    if date_created in (None, "") or post_at in (None, ""):
        return ("unknown", "no date_created on this entry, so the gap the scheduler "
                           "gave itself cannot be read")
    try:
        gap = int(float(post_at)) - int(float(date_created))
    except (TypeError, ValueError):
        return ("unknown", "date_created or post_at is not a number")
    if gap <= 0:
        return ("created-after", "scheduled for %ds before it was created, which is a "
                                 "sign computation and not a scheduling one" % abs(gap))
    if gap < margin:
        return ("tight-window", "created %ds before it is due, inside the %ds margin. "
                                "This one got through and the next one like it loses "
                                "the race" % (gap, margin))
    return ("comfortable", "created %ds before it is due" % gap)


def queue_report(entries, now, margin=DEFAULT_MARGIN, horizon=HORIZON_SECONDS):
    """Fold the pending queue into counts and rows. Pure."""
    rows, counts = [], {}
    nearest = None
    for entry in entries or []:
        item = entry if isinstance(entry, dict) else {}
        post_at = item.get("post_at")
        verdict, detail = post_at_verdict(post_at, now, margin, horizon)
        gap_verdict, gap_detail = creation_margin(post_at, item.get("date_created"),
                                                  margin)
        counts[verdict] = counts.get(verdict, 0) + 1
        rows.append({"id": str(item.get("id") or "?"),
                     "channel": str(item.get("channel_id") or "?"),
                     "post_at": post_at, "verdict": verdict, "detail": detail,
                     "margin": gap_verdict, "margin_detail": gap_detail})
        try:
            seconds = int(float(post_at))
        except (TypeError, ValueError):
            seconds = None
        if seconds is not None and seconds > int(now):
            if nearest is None or seconds < nearest[1]:
                nearest = (str(item.get("id") or "?"), seconds)
    unusable = sum(n for verdict, n in counts.items() if verdict != "plausible")
    return {"total": len(rows), "unusable": unusable, "by_verdict": counts,
            "nearest": nearest, "rows": rows}


def read_queue(session, page_size=100):
    """Follow the cursor. Listing is a read; cancelling is not, and is not here."""
    pages, cursor = [], ""
    while True:
        params = {"limit": str(page_size)}
        if cursor:
            params["cursor"] = cursor
        page = session.get(API + "chat.scheduledMessages.list", timeout=30,
                           params=params).json()
        pages.append(page)
        if page.get("ok") is not True:
            return pages
        cursor = ((page.get("response_metadata") or {}).get("next_cursor") or "").strip()
        if not cursor:
            return pages


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--post-at", action="append", default=[],
                    help="a computed post_at to check with no token at all; repeatable")
    ap.add_argument("--intended", default="",
                    help="the moment you meant, ISO 8601, to name a timezone offset")
    ap.add_argument("--queue", action="store_true",
                    help="read the pending scheduled sends and audit every one")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the token")
    ap.add_argument("--margin", type=int, default=DEFAULT_MARGIN,
                    help="seconds of room a send should leave itself")
    args = ap.parse_args()

    now = time.time()
    findings = 0

    intended = None
    if args.intended:
        try:
            intended = dt.datetime.fromisoformat(args.intended).timestamp()
        except ValueError:
            log.warning("intended   unusable       %s is not ISO 8601", args.intended)

    for raw in args.post_at:
        verdict, detail = post_at_verdict(raw, now, args.margin)
        (log.info if verdict == "plausible" else log.warning)(
            "value      %-22s post_at=%s  %s", verdict, raw, detail)
        if verdict != "plausible":
            findings += 1
        if intended is not None:
            smell, why = offset_smell(raw, intended)
            (log.info if smell == "aligned" else log.warning)(
                "offset     %-22s %s", smell, why)
            if smell != "aligned":
                findings += 1

    if not args.queue:
        if args.post_at:
            return 1 if findings else 0
        log.error("pass --post-at VALUE to check a number, or --queue to read the queue")
        return 2

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s, or use --post-at alone to check a value with no token",
                  args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    pages = read_queue(s)
    if pages and pages[-1].get("ok") is not True:
        log.error("queue      unavailable    %s. Listing the queue needs a token in the "
                  "chat:write family, which is a write scope used here only to read",
                  pages[-1].get("error"))
        return 2

    entries = [e for page in pages for e in (page.get("scheduled_messages") or [])]
    log.info("queue      %d pending send(s) read across %d page(s)", len(entries),
             len(pages))
    report = queue_report(entries, now, args.margin)
    for row in report["rows"]:
        (log.info if row["verdict"] == "plausible" else log.warning)(
            "entry      %-22s %s %s post_at=%s  %s", row["verdict"], row["id"],
            row["channel"], row["post_at"], row["detail"])
        if row["margin"] in ("tight-window", "created-after"):
            log.warning("margin     %-22s %s %s", row["margin"], row["id"],
                        row["margin_detail"])
    if report["nearest"]:
        log.info("nearest    %s fires next", report["nearest"][0])

    findings += report["unusable"]
    if findings:
        log.warning("verdict    %d of %d pending send(s) will not fire",
                    report["unusable"], report["total"])
        log.warning("  repair: compute post_at as whole seconds and assert "
                    "post_at > now + %d before sending", args.margin)
        log.warning("  repair: do the arithmetic in UTC and convert only for display; a "
                    "clean multiple of an hour is always a conversion")
        log.warning("  repair: for anything beyond 120 days, keep the schedule in your "
                    "own store and enqueue as the horizon rolls forward")
        log.warning("  repair: remove the entries named above yourself with "
                    "chat.deleteScheduledMessage; this script does not write")
        return 1
    log.info("verdict    clean          every pending send is inside every limit")
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-scheduled-in-past.mjs
/**
 * Audit the pending scheduled sends for values that will never fire.
 *
 * Read only. chat.scheduledMessages.list is a read and is the only method this
 * script calls; scheduling a message and cancelling one are both writes and
 * neither is here. When an entry is unusable the script prints its id and the
 * method that removes it, and leaves the call to you.
 */

const API = 'https://slack.com/api/';

// Slack takes a schedule up to 120 days out and no further.
const HORIZON_SECONDS = 120 * 86400;
// Below this the value is not an absolute time at all: 1e9 seconds is 2001.
const SMALLEST_PLAUSIBLE = 1_000_000_000;
const MILLISECONDS = 1_000_000_000_000;
const MICROSECONDS = 1_000_000_000_000_000;
const DEFAULT_MARGIN = 60;

/**
 * Will Slack accept this post_at, and if not, which mistake was it? Pure.
 * The unit verdicts come before the clock verdicts on purpose.
 */
export function postAtVerdict(postAt, now, margin = DEFAULT_MARGIN,
  horizon = HORIZON_SECONDS) {
  if (postAt === null || postAt === undefined || postAt === '') {
    return ['missing', 'no post_at. chat.scheduleMessage has no default and will not '
      + 'treat an absent value as send now'];
  }
  const value = Number(postAt);
  if (!Number.isFinite(value)) {
    return ['not-a-number', `${postAt} is not a number. An ISO 8601 string is the usual `
      + 'cause; post_at is Unix seconds, not a formatted date'];
  }
  if (!Number.isInteger(value)) {
    return ['message-ts', `${postAt} has a fractional part, which is the shape of a `
      + 'message ts rather than of a post_at. post_at is whole seconds'];
  }
  const digits = String(Math.abs(value)).length;
  if (value >= MICROSECONDS) {
    return ['microseconds', `${digits} digits. This is microseconds, which is what a `
      + 'database timestamp column hands back on some drivers'];
  }
  if (value >= MILLISECONDS) {
    return ['milliseconds', `${digits} digits, so this is milliseconds. Date.now() `
      + 'returns milliseconds and Slack reads seconds, which puts this roughly fifty '
      + 'thousand years out and gets it refused as too far'];
  }
  if (value < SMALLEST_PLAUSIBLE) {
    return ['duration-not-timestamp', `${value} is not an absolute time. Slack reads it `
      + `as 1970 plus ${value} seconds, which is what happens when a delay and a `
      + 'deadline share a variable name'];
  }
  const delta = value - Math.trunc(now);
  if (delta <= 0) {
    return ['in-past', `${Math.abs(delta)} seconds behind the clock already. Slack `
      + 'evaluates post_at when it processes the request, so a value computed a while '
      + 'ago can be fine at build time and late on arrival'];
  }
  if (delta <= margin) {
    return ['too-soon', `${delta}s from now, inside the ${margin}s margin. Queueing `
      + 'latency, a retry, or a cold worker will push this past the target before '
      + 'Slack sees it'];
  }
  if (delta > horizon) {
    return ['too-far', `${Math.floor(delta / 86400)} days out and the horizon is `
      + `${Math.floor(horizon / 86400)}. The arithmetic is right and the API will not `
      + 'take it, so this wants a job that schedules as the horizon rolls forward'];
  }
  return ['plausible', `${Math.floor(delta / 86400)} days, `
    + `${Math.floor((delta % 86400) / 3600)} hours out and inside every limit`];
}

/**
 * Is the difference a timezone conversion or something else? Pure.
 * A clean multiple of an hour is a conversion; four minutes is not.
 */
export function offsetSmell(postAt, intendedEpoch, tolerance = 90) {
  const a = Number(postAt);
  const b = Number(intendedEpoch);
  if (!Number.isFinite(a) || !Number.isFinite(b)) {
    return ['uncomparable', 'one of these is not a number, so there is nothing to '
      + 'compare'];
  }
  const diff = Math.trunc(a) - Math.trunc(b);
  const size = Math.abs(diff);
  const direction = diff > 0 ? 'ahead of' : 'behind';
  if (size <= tolerance) return ['aligned', `within ${size}s of the moment you meant`];
  const hours = Math.round(size / 3600);
  if (hours >= 1 && hours <= 14 && Math.abs(size - hours * 3600) <= tolerance) {
    return ['whole-hour-offset', `exactly ${hours} hour(s) ${direction} the moment you `
      + 'meant. A clean multiple of an hour is a timezone conversion, not a rounding '
      + 'error: a local wall clock was handed over as if it were UTC'];
  }
  const remainder = size % 3600;
  if (Math.abs(remainder - 1800) <= tolerance || Math.abs(remainder - 2700) <= tolerance) {
    return ['half-hour-offset', `${size}s ${direction} the moment you meant, which lands `
      + 'on a 30 or 45 minute boundary. India, Nepal and the Chatham Islands are not on '
      + 'the hour, and this is what their users see'];
  }
  return ['drifted', `${size}s ${direction} the moment you meant, and not a clean `
    + 'timezone offset. This is a stale value or a clock problem rather than a '
    + 'conversion'];
}

/** How much room did the scheduler give itself? Pure. */
export function creationMargin(postAt, dateCreated, margin = DEFAULT_MARGIN) {
  if (dateCreated === null || dateCreated === undefined || dateCreated === ''
      || postAt === null || postAt === undefined || postAt === '') {
    return ['unknown', 'no date_created on this entry, so the gap the scheduler gave '
      + 'itself cannot be read'];
  }
  const a = Number(postAt);
  const b = Number(dateCreated);
  if (!Number.isFinite(a) || !Number.isFinite(b)) {
    return ['unknown', 'date_created or post_at is not a number'];
  }
  const gap = Math.trunc(a) - Math.trunc(b);
  if (gap <= 0) {
    return ['created-after', `scheduled for ${Math.abs(gap)}s before it was created, `
      + 'which is a sign computation and not a scheduling one'];
  }
  if (gap < margin) {
    return ['tight-window', `created ${gap}s before it is due, inside the ${margin}s `
      + 'margin. This one got through and the next one like it loses the race'];
  }
  return ['comfortable', `created ${gap}s before it is due`];
}

/** Fold the pending queue into counts and rows. Pure. */
export function queueReport(entries, now, margin = DEFAULT_MARGIN,
  horizon = HORIZON_SECONDS) {
  const rows = [];
  const counts = new Map();
  let nearest = null;
  for (const entry of entries ?? []) {
    const item = (entry && typeof entry === 'object') ? entry : {};
    const postAt = item.post_at;
    const [verdict, detail] = postAtVerdict(postAt, now, margin, horizon);
    const [marginVerdict, marginDetail] = creationMargin(postAt, item.date_created,
      margin);
    counts.set(verdict, (counts.get(verdict) ?? 0) + 1);
    rows.push({ id: String(item.id ?? '?'), channel: String(item.channel_id ?? '?'),
      postAt, verdict, detail, margin: marginVerdict, marginDetail });
    const seconds = Number(postAt);
    if (Number.isFinite(seconds) && seconds > Math.trunc(now)
        && (nearest === null || seconds < nearest[1])) {
      nearest = [String(item.id ?? '?'), Math.trunc(seconds)];
    }
  }
  let unusable = 0;
  for (const [verdict, n] of counts) if (verdict !== 'plausible') unusable += n;
  return { total: rows.length, unusable, byVerdict: counts, nearest, rows };
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

function argAll(args, name) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
  }
  return out;
}

async function readQueue(headers, pageSize = 100) {
  const pages = [];
  let cursor = '';
  for (;;) {
    const params = { limit: String(pageSize) };
    if (cursor) params.cursor = cursor;
    const qs = new URLSearchParams(params).toString();
    const page = await (await fetch(`${API}chat.scheduledMessages.list?${qs}`,
      { headers })).json();
    pages.push(page);
    if (page.ok !== true) return pages;
    cursor = String((page.response_metadata ?? {}).next_cursor ?? '').trim();
    if (!cursor) return pages;
  }
}

async function main() {
  const args = process.argv.slice(2);
  const now = Date.now() / 1000;
  const margin = Number(arg(args, '--margin', String(DEFAULT_MARGIN)));
  const values = argAll(args, '--post-at');
  const wantQueue = args.includes('--queue');
  let findings = 0;

  let intended = null;
  const intendedText = arg(args, '--intended', '');
  if (intendedText) {
    const parsed = Date.parse(intendedText);
    if (Number.isNaN(parsed)) {
      console.warn(`intended   unusable       ${intendedText} is not ISO 8601`);
    } else {
      intended = parsed / 1000;
    }
  }

  for (const raw of values) {
    const [verdict, detail] = postAtVerdict(raw, now, margin);
    const line = `value      ${verdict.padEnd(22)} post_at=${raw}  ${detail}`;
    if (verdict === 'plausible') console.log(line);
    else { console.warn(line); findings += 1; }
    if (intended !== null) {
      const [smell, why] = offsetSmell(raw, intended);
      const offsetLine = `offset     ${smell.padEnd(22)} ${why}`;
      if (smell === 'aligned') console.log(offsetLine);
      else { console.warn(offsetLine); findings += 1; }
    }
  }

  if (!wantQueue) {
    if (values.length) { if (findings) process.exitCode = 1; return; }
    console.error('pass --post-at VALUE to check a number, or --queue to read the queue');
    process.exitCode = 2;
    return;
  }

  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv}, or use --post-at alone to check a value with `
      + 'no token');
    process.exitCode = 2;
    return;
  }
  const headers = { Authorization: `Bearer ${token}` };
  const pages = await readQueue(headers);
  if (pages.length && pages[pages.length - 1].ok !== true) {
    console.error(`queue      unavailable    ${pages[pages.length - 1].error}. Listing `
      + 'the queue needs a token in the chat:write family, which is a write scope used '
      + 'here only to read');
    process.exitCode = 2;
    return;
  }

  const entries = pages.flatMap((p) => p.scheduled_messages ?? []);
  console.log(`queue      ${entries.length} pending send(s) read across `
    + `${pages.length} page(s)`);
  const report = queueReport(entries, now, margin);
  for (const row of report.rows) {
    const line = `entry      ${row.verdict.padEnd(22)} ${row.id} ${row.channel} `
      + `post_at=${row.postAt}  ${row.detail}`;
    if (row.verdict === 'plausible') console.log(line); else console.warn(line);
    if (row.margin === 'tight-window' || row.margin === 'created-after') {
      console.warn(`margin     ${row.margin.padEnd(22)} ${row.id} ${row.marginDetail}`);
    }
  }
  if (report.nearest) console.log(`nearest    ${report.nearest[0]} fires next`);

  findings += report.unusable;
  if (findings) {
    console.warn(`verdict    ${report.unusable} of ${report.total} pending send(s) `
      + 'will not fire');
    console.warn('  repair: compute post_at as whole seconds and assert post_at > now + '
      + `${margin} before sending`);
    console.warn('  repair: do the arithmetic in UTC and convert only for display; a '
      + 'clean multiple of an hour is always a conversion');
    console.warn('  repair: for anything beyond 120 days, keep the schedule in your own '
      + 'store and enqueue as the horizon rolls forward');
    console.warn('  repair: remove the entries named above yourself with '
      + 'chat.deleteScheduledMessage; this script does not write');
    process.exitCode = 1;
  } else {
    console.log('verdict    clean          every pending send is inside every limit');
  }
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests pin a fixed now, because a scheduling audit whose assertions depend on the clock is a test that fails on a slow morning. The ones that matter are the ordering: a value in milliseconds must report as milliseconds and never as too-far, even though it is also very far, since the horizon is not the bug. And offset_smell has to split a clean five hours from an untidy four minutes, because those are two different investigations and the numbers are the only way to tell them apart.

test_slack_scheduled_in_past.py
from slack_scheduled_in_past import (creation_margin, offset_smell, post_at_verdict,
                                     queue_report)

# Fixed so the assertions do not depend on the clock: 2025-08-12T12:00:00Z.
NOW = 1755000000


def test_a_sensible_future_value_is_plausible():
    verdict, detail = post_at_verdict(NOW + 86400, NOW)
    assert verdict == "plausible"
    assert "1 days" in detail


def test_milliseconds_are_named_by_their_digit_count():
    verdict, detail = post_at_verdict(NOW * 1000, NOW)
    assert verdict == "milliseconds"
    assert "13 digits" in detail
    assert "Date.now()" in detail


def test_milliseconds_beat_too_far_because_the_horizon_is_not_the_bug():
    assert post_at_verdict(NOW * 1000, NOW)[0] != "too-far"


def test_microseconds_have_their_own_verdict():
    assert post_at_verdict(NOW * 1000000, NOW)[0] == "microseconds"


def test_a_delay_passed_as_a_deadline_is_caught():
    verdict, detail = post_at_verdict(300, NOW)
    assert verdict == "duration-not-timestamp"
    assert "1970 plus 300 seconds" in detail


def test_a_value_already_behind_the_clock_is_in_past():
    verdict, detail = post_at_verdict(NOW - 5, NOW)
    assert verdict == "in-past"
    assert "when it processes the request" in detail
    assert post_at_verdict(NOW, NOW)[0] == "in-past"


def test_a_send_with_no_room_is_a_finding_before_it_fails():
    verdict, detail = post_at_verdict(NOW + 12, NOW)
    assert verdict == "too-soon"
    assert "cold worker" in detail


def test_the_margin_boundary_is_inclusive_and_the_next_second_is_fine():
    assert post_at_verdict(NOW + 60, NOW)[0] == "too-soon"
    assert post_at_verdict(NOW + 61, NOW)[0] == "plausible"


def test_past_the_horizon_is_separated_from_every_arithmetic_bug():
    verdict, detail = post_at_verdict(NOW + 121 * 86400, NOW)
    assert verdict == "too-far"
    assert "horizon rolls forward" in detail
    assert post_at_verdict(NOW + 119 * 86400, NOW)[0] == "plausible"


def test_a_message_ts_handed_over_as_a_post_at_is_recognised():
    verdict, detail = post_at_verdict("1755000000.000200", NOW)
    assert verdict == "message-ts"
    assert "whole seconds" in detail


def test_a_formatted_date_is_not_a_post_at():
    assert post_at_verdict("2026-09-01T09:00:00Z", NOW)[0] == "not-a-number"
    assert post_at_verdict(None, NOW)[0] == "missing"
    assert post_at_verdict("", NOW)[0] == "missing"


def test_a_clean_multiple_of_an_hour_is_named_as_a_timezone_bug():
    verdict, detail = offset_smell(NOW + 5 * 3600, NOW)
    assert verdict == "whole-hour-offset"
    assert "exactly 5 hour(s) ahead of" in detail
    assert "as if it were UTC" in detail


def test_the_offset_is_reported_in_both_directions():
    assert "behind" in offset_smell(NOW - 3600, NOW)[1]


def test_the_zones_that_are_not_on_the_hour_are_still_timezone_bugs():
    assert offset_smell(NOW + 5 * 3600 + 1800, NOW)[0] == "half-hour-offset"
    assert offset_smell(NOW + 5 * 3600 + 2700, NOW)[0] == "half-hour-offset"


def test_an_untidy_difference_is_not_blamed_on_timezones():
    verdict, detail = offset_smell(NOW + 240, NOW)
    assert verdict == "drifted"
    assert "not a clean timezone offset" in detail


def test_a_value_that_matches_what_you_meant_is_aligned():
    assert offset_smell(NOW + 10, NOW)[0] == "aligned"
    assert offset_smell("nine o'clock", NOW)[0] == "uncomparable"


def test_the_gap_the_scheduler_gave_itself_is_its_own_signal():
    verdict, detail = creation_margin(NOW + 9, NOW)
    assert verdict == "tight-window"
    assert "loses the race" in detail
    assert creation_margin(NOW + 3600, NOW)[0] == "comfortable"


def test_an_entry_due_before_it_was_created_is_a_sign_error():
    assert creation_margin(NOW - 10, NOW)[0] == "created-after"
    assert creation_margin(NOW + 60, None)[0] == "unknown"


def test_the_queue_report_counts_by_cause_and_names_the_next_to_fire():
    entries = [
        {"id": "Q1", "channel_id": "C1", "post_at": NOW * 1000, "date_created": NOW},
        {"id": "Q2", "channel_id": "C1", "post_at": NOW + 12, "date_created": NOW + 3},
        {"id": "Q3", "channel_id": "C2", "post_at": NOW + 86400, "date_created": NOW},
        {"id": "Q4", "channel_id": "C2", "post_at": NOW + 200 * 86400,
         "date_created": NOW},
    ]
    report = queue_report(entries, NOW)
    assert report["total"] == 4
    assert report["unusable"] == 3
    assert report["by_verdict"]["plausible"] == 1
    assert report["nearest"] == ("Q2", NOW + 12)
    assert report["rows"][1]["margin"] == "tight-window"


def test_an_empty_queue_is_not_a_finding():
    report = queue_report([], NOW)
    assert report["total"] == 0
    assert report["unusable"] == 0
    assert report["nearest"] is None
slack-scheduled-in-past.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  creationMargin, offsetSmell, postAtVerdict, queueReport,
} from './slack-scheduled-in-past.mjs';

// Fixed so the assertions do not depend on the clock: 2025-08-12T12:00:00Z.
const NOW = 1755000000;

test('a sensible future value is plausible', () => {
  const [verdict, detail] = postAtVerdict(NOW + 86400, NOW);
  assert.equal(verdict, 'plausible');
  assert.match(detail, /1 days/);
});

test('milliseconds are named by their digit count', () => {
  const [verdict, detail] = postAtVerdict(NOW * 1000, NOW);
  assert.equal(verdict, 'milliseconds');
  assert.match(detail, /13 digits/);
  assert.match(detail, /Date\.now\(\)/);
});

test('milliseconds beat too-far because the horizon is not the bug', () => {
  assert.notEqual(postAtVerdict(NOW * 1000, NOW)[0], 'too-far');
});

test('microseconds have their own verdict', () => {
  assert.equal(postAtVerdict(NOW * 1000000, NOW)[0], 'microseconds');
});

test('a delay passed as a deadline is caught', () => {
  const [verdict, detail] = postAtVerdict(300, NOW);
  assert.equal(verdict, 'duration-not-timestamp');
  assert.match(detail, /1970 plus 300 seconds/);
});

test('a value already behind the clock is in-past', () => {
  const [verdict, detail] = postAtVerdict(NOW - 5, NOW);
  assert.equal(verdict, 'in-past');
  assert.match(detail, /when it processes the request/);
  assert.equal(postAtVerdict(NOW, NOW)[0], 'in-past');
});

test('a send with no room is a finding before it fails', () => {
  const [verdict, detail] = postAtVerdict(NOW + 12, NOW);
  assert.equal(verdict, 'too-soon');
  assert.match(detail, /cold worker/);
});

test('the margin boundary is inclusive and the next second is fine', () => {
  assert.equal(postAtVerdict(NOW + 60, NOW)[0], 'too-soon');
  assert.equal(postAtVerdict(NOW + 61, NOW)[0], 'plausible');
});

test('past the horizon is separated from every arithmetic bug', () => {
  const [verdict, detail] = postAtVerdict(NOW + 121 * 86400, NOW);
  assert.equal(verdict, 'too-far');
  assert.match(detail, /horizon rolls forward/);
  assert.equal(postAtVerdict(NOW + 119 * 86400, NOW)[0], 'plausible');
});

test('a message ts handed over as a post_at is recognised', () => {
  const [verdict, detail] = postAtVerdict('1755000000.000200', NOW);
  assert.equal(verdict, 'message-ts');
  assert.match(detail, /whole seconds/);
});

test('a formatted date is not a post_at', () => {
  assert.equal(postAtVerdict('2026-09-01T09:00:00Z', NOW)[0], 'not-a-number');
  assert.equal(postAtVerdict(null, NOW)[0], 'missing');
  assert.equal(postAtVerdict('', NOW)[0], 'missing');
});

test('a clean multiple of an hour is named as a timezone bug', () => {
  const [verdict, detail] = offsetSmell(NOW + 5 * 3600, NOW);
  assert.equal(verdict, 'whole-hour-offset');
  assert.match(detail, /exactly 5 hour\(s\) ahead of/);
  assert.match(detail, /as if it were UTC/);
});

test('the offset is reported in both directions', () => {
  assert.match(offsetSmell(NOW - 3600, NOW)[1], /behind/);
});

test('the zones that are not on the hour are still timezone bugs', () => {
  assert.equal(offsetSmell(NOW + 5 * 3600 + 1800, NOW)[0], 'half-hour-offset');
  assert.equal(offsetSmell(NOW + 5 * 3600 + 2700, NOW)[0], 'half-hour-offset');
});

test('an untidy difference is not blamed on timezones', () => {
  const [verdict, detail] = offsetSmell(NOW + 240, NOW);
  assert.equal(verdict, 'drifted');
  assert.match(detail, /not a clean timezone offset/);
});

test('a value that matches what you meant is aligned', () => {
  assert.equal(offsetSmell(NOW + 10, NOW)[0], 'aligned');
  assert.equal(offsetSmell("nine o'clock", NOW)[0], 'uncomparable');
});

test('the gap the scheduler gave itself is its own signal', () => {
  const [verdict, detail] = creationMargin(NOW + 9, NOW);
  assert.equal(verdict, 'tight-window');
  assert.match(detail, /loses the race/);
  assert.equal(creationMargin(NOW + 3600, NOW)[0], 'comfortable');
});

test('an entry due before it was created is a sign error', () => {
  assert.equal(creationMargin(NOW - 10, NOW)[0], 'created-after');
  assert.equal(creationMargin(NOW + 60, null)[0], 'unknown');
});

test('the queue report counts by cause and names the next to fire', () => {
  const entries = [
    { id: 'Q1', channel_id: 'C1', post_at: NOW * 1000, date_created: NOW },
    { id: 'Q2', channel_id: 'C1', post_at: NOW + 12, date_created: NOW + 3 },
    { id: 'Q3', channel_id: 'C2', post_at: NOW + 86400, date_created: NOW },
    { id: 'Q4', channel_id: 'C2', post_at: NOW + 200 * 86400, date_created: NOW },
  ];
  const report = queueReport(entries, NOW);
  assert.equal(report.total, 4);
  assert.equal(report.unusable, 3);
  assert.equal(report.byVerdict.get('plausible'), 1);
  assert.deepEqual(report.nearest, ['Q2', NOW + 12]);
  assert.equal(report.rows[1].margin, 'tight-window');
});

test('an empty queue is not a finding', () => {
  const report = queueReport([], NOW);
  assert.equal(report.total, 0);
  assert.equal(report.unusable, 0);
  assert.equal(report.nearest, null);
});

FAQ

It works most of the time. Why would post_at be in the past only sometimes?

Because Slack checks the value when it processes the request, not when you computed it. Anything that adds delay between those two moments (a queue, a retry, a cold start, a slow DNS lookup) eats into whatever margin you left. A target five seconds out is not a schedule, it is a race you win most mornings. Assert at least a minute of room, and for anything nearer than that, post immediately instead.

How do I tell a units bug from a timezone bug from a stale value?

By the size and shape of the error. Milliseconds show up as thirteen digits and a date tens of thousands of years out. A timezone conversion is off by a clean multiple of an hour, or by thirty or forty five minutes in a handful of zones. Anything else, a few minutes or a few days, is a stale value or a clock problem. The three want three different fixes, which is why the script never reports a bare wrong.

Why is there a 120-day limit, and what do I do about a yearly reminder?

It is a documented ceiling on the API rather than something you can raise. Keep the schedule in your own store, and run a job that enqueues into Slack as the horizon rolls forward, for example every night scheduling everything now due inside the next 120 days. That also gives you somewhere to cancel and reschedule from, which the Slack queue on its own does not.

Can this script cancel the bad entries it finds?

No, and that is deliberate. Listing the queue is a read; removing an entry changes your workspace. The script prints each unusable entry's id and names chat.deleteScheduledMessage so you can run it yourself. Every script in this section holds a token that could write and none of them does.

Does a scheduled message reserve anything, or count against a limit?

It sits in a per-app, per-channel queue that you can enumerate with chat.scheduledMessages.list, and it fires as an ordinary message at the appointed second. Once it has fired it is a normal message with a ts and an author, which means the rules about who can edit it apply from that moment on, and it is no longer visible in the scheduled list at all.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.