Skip to content
PN Scripts

Development

Async standups in Slack that people actually answer

  • PN Scripts
  • 8 min read

A daily standup meeting of fifteen minutes costs a team of eight two hours of working time every day, interrupts the morning for everyone and still misses the colleague who starts later or works from another time zone. A written standup in Slack replaces the meeting with three short answers that everyone writes when it suits them. It works well when it is set up with care and becomes a ritual of ignored messages when it is not. This guide covers the setup that keeps people answering, and what a bot that runs it has to do correctly.

#Three questions, one of them about blockers

The classic questions still work: what did you finish, what is next, and is anything blocking you. Keep it to three. Every extra question lowers the number of people who answer, and long answers are not read. Two changes make the questions better:

  • Ask about finished work, not activity. "What did you finish?" produces "the export screen is merged"; "What did you do?" produces "worked on the export".
  • Make the blocker question separate and explicit. Blockers are the reason the standup exists. If they are buried in the second answer, nobody sees them; if they have their own question, they can be collected and shown at the top.

Some teams add an optional fourth question for one week, such as "anything you need from design?", and remove it again. Optional questions should be visibly optional.

#The right time, in the right time zone

Ask at a fixed time each working day, in the team's local time, a little after most people start: 09:30 in Sofia rather than 09:00. A team spread across time zones is better split into teams that each have their own time. Do not ask on weekends or public holidays: a standup question on 3 March or on Easter Monday tells people that the tool does not know where they live. Let people mark themselves away for a holiday or skip a single day, and leave them out of the reminders while they are away.

#One reminder, then close the day

Send one reminder, half an hour or so after the question, and only to those who have not answered. A second and third reminder feel like surveillance, and people learn to answer anything to make them stop. Set a summary time, for example 11:00, when the day closes: the summary lists who answered, who did not and every blocker. Late answers are still welcome and added, but the summary has been sent.

#One thread a day, with the status on top

Posting every answer as a separate message floods the channel and buries everything else. The pattern that works is a single message per day in the team channel, "Product standup, Tuesday 8 December", with the answers as replies in its thread. The message itself is updated as answers come in: "3 of 4 answered", who is still to answer, who is away, and the blockers with names. Anyone can see the state of the team in one glance without opening the thread. Answers that say "no", "none" or "няма" to the blocker question should not appear as blockers.

#Blockers need a person

A bot can collect blockers; only a person can remove them. Agree who reads the summary each day, usually the team lead, and that a blocker gets a reply the same day, even if the reply is "I will look at this tomorrow". A standup where blockers are reported and nothing happens stops being answered within weeks. Equally, do not turn the summary into a list of who failed to answer; a missing answer is information, not an offence.

#What a standup bot has to do correctly

A Slack standup bot is a small web service. Slack sends it a request when someone presses a button, submits a form or types a command, and the bot calls Slack's Web API to send messages and open forms. A few things are easy to get wrong:

  • Check that requests come from Slack. Anyone can send a request to the bot's public address. Slack signs each request with your app's signing secret; the bot must check the signature and the age of the request before doing anything.
  • Answer within three seconds. Slack expects an HTTP 200 for a button press or a command within 3 seconds and shows the user an error if it does not come. Acknowledge first and do slow work afterwards.
  • Do not process retries twice. If an event is not acknowledged in time, Slack retries it three times over a few minutes. Without a check, one answer can be posted three times.
  • Ask for few permissions. A standup bot needs to write messages, open direct messages, read user names and channel members, and receive a command. It does not need to read every message in the workspace.
  • Keep the answers where you want them. A self-hosted bot keeps the standup history on your own server, where you can export it.

The signature check is short. Slack builds a string from the version v0, the request timestamp and the raw request body, signs it with HMAC-SHA256 and the signing secret, and sends the result in the X-Slack-Signature header. This function repeats that and compares; the example at the bottom makes its own signed request, since a real one comes from Slack:

import hashlib
import hmac
import time


def from_slack(signing_secret: bytes, headers: dict, body: bytes, now: float | None = None) -> bool:
    """True when a request really comes from Slack: a fresh timestamp and a matching HMAC-SHA256."""
    try:
        timestamp = int(headers["X-Slack-Request-Timestamp"])
        signature = headers["X-Slack-Signature"]
    except (KeyError, ValueError):
        return False
    if abs((now or time.time()) - timestamp) > 5 * 60:      # an old request replayed later
        return False
    base = b"v0:" + str(timestamp).encode() + b":" + body   # the raw body, before any parsing
    expected = "v0=" + hmac.new(signing_secret, base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# A self-made request to show the check; a real one comes from Slack.
secret = b"not-a-real-signing-secret"
body = b"command=%2Fstandup&text=skip&user_id=U012ABCDEF"
ts = 1_765_263_600
good = {"X-Slack-Request-Timestamp": str(ts),
        "X-Slack-Signature": "v0=" + hmac.new(secret, f"v0:{ts}:".encode() + body, hashlib.sha256).hexdigest()}

print(from_slack(secret, good, body, now=ts + 2))                     # True
print(from_slack(secret, good, body + b"&x=1", now=ts + 2))           # False: body changed
print(from_slack(secret, good, body, now=ts + 6 * 60))                # False: too old

It prints True for the signed request, False when one byte of the body has changed, and False for a request six minutes old, which is how a recorded request replayed later looks. Two details matter: use the raw body exactly as it arrived, before any parsing, and compare with a constant-time function such as hmac.compare_digest.

#A bot that works this way

We made Planyorka, a self-hosted async standup bot for Slack: one small Python program with only the standard library, one settings file and one SQLite file. Every working day at the team's time it sends each person a private message with Answer and Skip buttons, collects the answers in a short form, posts them in one thread under a status message that shows who has answered, who is missing, who is away and every blocker, sends one reminder and closes the day at the summary time. It skips Bulgarian holidays, Orthodox Easter and moved days off automatically, speaks English or Bulgarian per team, supports any number of teams, and exports any period as CSV or Markdown. Every request is checked with the signing secret and retries are not processed twice. It was tested against a fake Slack Web API over HTTP and with signed requests to its real HTTP server, but it has not yet been run in a real Slack workspace, and its Docker image has not been built. It is listed as Coming soon.

#Frequently asked questions

What questions should an async standup ask?

Three: what did you finish, what is next, and is anything blocking you. Keep the blocker question separate so blockers can be collected and shown at the top.

How many reminders should a standup bot send?

One, about half an hour after the question, only to those who have not answered. More reminders produce answers written to make them stop.

Why does my Slack app show an error when a button is pressed?

Usually because the app did not answer the request with HTTP 200 within 3 seconds. Acknowledge at once and do the slow work afterwards.

How does a Slack app know a request really came from Slack?

By checking the X-Slack-Signature header: an HMAC-SHA256 of "v0", the timestamp and the raw body with the app's signing secret, and a timestamp no more than five minutes old.

#Sources

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile, API and game development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here