Notifications
Notifications are optional. Without them the dashboard still works; you just have to remember to open it. With them, a card arrives when a review is requested of you, another when the review is ready, one if a run is stopped or fails, and one when a QA guide finishes.
One notifier serves every backend: the scripts call notify_card <kind> <payload> (in bin/notify.sh) and each enabled backend renders the same small payload in its own idiom. Enable any combination.
Which backends fire
Section titled “Which backends fire”NOTIFY_BACKENDS in .env is a comma list of slack, discord, generic, none. Leave it empty and the set is derived from whichever URLs are configured — an existing Slack-only install changes nothing. The admin can also switch backends live on the dashboard’s Settings page (stored in settings.json, which wins over .env; see Configuration → Runtime settings).
A backend that fails — a dead URL, a 4xx from the provider — logs a WARN line in the poller or review log and nothing else. It never aborts the review that triggered it.
Events
Section titled “Events”| Kind | Fires when | Mentions |
|---|---|---|
review_requested | A new review request for a signed-in user — from a GitHub webhook the moment it happens, or from the poller on its next pass (once per PR per person; new commits never re-ping). Suppressed for PRs older than max PR age and, if enabled, for bot-authored PRs. | The requested reviewer |
review_ready | A review run you started finished; the card carries the verdict, the finding count and the agent’s summary. Nothing has been posted to GitHub yet. | Only the person who started the run |
review_stopped | A run was force-stopped from the dashboard (with confirmation that the agent is gone), or failed to produce a review. | The person who stopped / started it |
qa_ready | A QA guide finished building. | The person who asked for it |
The verdict on a review-ready card
Section titled “The verdict on a review-ready card”The colour bar and the header of a review_ready card come from the verdict, never from how the review would be posted (COMMENT / REQUEST_CHANGES is an implementation detail and is not shown). The thresholds are the ones the dashboard’s verdict banner uses:
| Verdict | When | Colour | Header |
|---|---|---|---|
blocked | at least one blocker finding, or the agent asked for changes | red | 🔴 Not LGTM — 1 blocker · 3 findings |
attention | no blockers, at least one should-fix | amber | 🟡 Needs attention — 2 to fix · 2 findings |
minor | only nits / questions | blue | 🔵 Minor notes — 1 · 1 finding |
lgtm | no findings at all | green | 🟢 LGTM — nothing to fix · 0 findings |
review_requested and qa_ready cards are neutral (brand colour); review_stopped is red when the run failed and grey when someone stopped it. Slack paints the colour as the attachment bar, Discord as the embed’s left border.
From GitHub webhooks
Section titled “From GitHub webhooks”With GITHUB_WEBHOOK_SECRET set and a hook on the repository (Configuration → GitHub webhooks), GitHub events map onto the queue like this. Only review_requested produces a card; everything else changes the dashboard silently.
| GitHub event | Effect | Card |
|---|---|---|
pull_request · review_requested | The PR joins the requested reviewer’s To review; a team request is expanded and only signed-in members are added. | review_requested, unless that person was already told (same seen key as the poller) |
pull_request · review_request_removed | That reviewer’s row disappears. | none |
pull_request · synchronize | The row’s head SHA is refreshed, so an existing review shows the stale banner. | none — a push never re-pings |
pull_request · closed (or merged) | The PR leaves the queue; a review that was never posted is archived. Posted / approved rows stay as history. | none |
pull_request_review · submitted | A review the signed-in person submitted on GitHub itself moves their row to Posted (or Approved). | none |
Deliveries for repositories outside REPOS / REPO_ALLOW_ORG are acknowledged and ignored, so one org-level hook is fine.
Two ways to connect. Set one in .env and restart the dashboard (the poller and the scripts pick it up on their next run).
Incoming webhook (simplest)
Section titled “Incoming webhook (simplest)”SLACK_WEBHOOK=https://hooks.slack.com/services/…Slack → your workspace apps → Incoming Webhooks → add to a channel → copy the URL. Send-only: the “review ready” message arrives as a new message naming the PR rather than a thread reply, because webhooks never return a message timestamp.
Bot token (threads replies)
Section titled “Bot token (threads replies)”SLACK_BOT_TOKEN=xoxb-…SLACK_CHANNEL=C0123456789A Slack app with chat:write in the channel. Posts use chat.postMessage, the request card’s timestamp is stored per reviewer, and the review-ready / stopped / QA replies are threaded under it. Takes precedence over the webhook when both are set. Needs a Slack app, which some workspaces gate.
Mentions
Section titled “Mentions”Cards @mention the reviewer using the Slack member ID they saved in Integrations (Slack → profile picture → Profile → ⋮ → Copy member ID). Without one, the request card shows a bare @login label that pings nobody, and the review-ready card carries no mention at all. One channel serves everyone; only the mentioned person is pinged.
Discord
Section titled “Discord”DISCORD_WEBHOOK=https://discord.com/api/webhooks/…Channel settings → Integrations → Webhooks → New Webhook → copy the URL. Each event is one embed titled owner/name #123 · PR title, a description saying what happened, and a links line (Open review · Dashboard · Open PR) standing in for buttons, which Discord webhooks do not have.
Mentions use the Discord user ID each reviewer saves in Integrations (Discord → User Settings → Advanced → Developer Mode on, then right-click your name → Copy User ID; it is all digits). The mention goes in the message body as <@id> with allowed_mentions restricted to that one user, so nobody else in the channel is pinged.
Generic webhook
Section titled “Generic webhook”WEBHOOK_URL=https://example.com/reviewstageWEBHOOK_SECRET=a-long-random-string # optional but recommendedFor Microsoft Teams (via a workflow), Zapier, n8n, Make, or your own service. Every event is one POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
X-ReviewStage-Event | the kind |
X-ReviewStage-Signature | sha256=<hex HMAC-SHA256 of the raw body, keyed with WEBHOOK_SECRET> — only when the secret is set |
User-Agent | ReviewStage-Webhook/1 |
Payload
Section titled “Payload”{ "kind": "review_ready", "ts": 1789265102, "repo": "acme/widgets", "pr": "38849", "title": "Add lead-time badge to product cards", "author": "teammate", "url": "https://github.com/acme/widgets/pull/38849", "login": "acme-dev", "slack_id": "U0TEST", "discord_id": "", "extra": { "verdict": "attention", "findings": 2, "blockers": 0, "should_fix": 1, "event": "COMMENT", "summary": "Adds a lead-time badge. Logic is sound; two small things.", "detail": "https://reviews.example.com/pr?pr=38849&exp=…&sig=…" }}pr is a string. login is the reviewer the card is for; slack_id / discord_id are whatever they saved, or "". extra depends on kind:
| Kind | extra |
|---|---|
review_requested | additions, deletions, files (numbers), detail (dashboard PR link), board (dashboard index link) |
review_ready | verdict (lgtm, minor, attention or blocked — see the verdict), findings, blockers, should_fix (numbers), event (COMMENT or REQUEST_CHANGES — how the review would be posted; kept for machines, not shown on cards), summary, detail |
review_stopped | status (stopped or failed), message (failed only), job (Review or QA guide), confirmed (the agent is verifiably gone), runner (whose Claude account it ran on), text (the Slack-formatted line) |
qa_ready | detail (dashboard QA link) |
The same schema is shown on the Integrations page under Generic webhook → Show payload schema.
Verifying the signature
Section titled “Verifying the signature”Compute HMAC-SHA256 over the raw request body with your secret and compare it, constant-time, to the header value after sha256=.
# shell — body saved to body.json, header value in $SIGprintf 'sha256=%s\n' "$(openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -r < body.json | cut -d' ' -f1)"[ "$SIG" = "sha256=…" ] # compare# pythonimport hmac, hashlibdef verify(raw_body: bytes, header: str, secret: str) -> bool: want = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(want, header or "")// nodeimport { createHmac, timingSafeEqual } from "node:crypto";export function verify(rawBody, header, secret) { const want = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"); return header?.length === want.length && timingSafeEqual(Buffer.from(want), Buffer.from(header));}Parse the JSON only after the signature checks out, and sign over the bytes you received — re-serialising first will change the digest.
None — the dashboard is the inbox
Section titled “None — the dashboard is the inbox”Set NOTIFY_BACKENDS=none (or tick None in Settings, or configure no URLs at all). The poller still keeps the queue fresh; you open the dashboard and work the To review tab. Everything else — per-reviewer runs, posting, approval — is identical.
Dedup, retries and suppression
Section titled “Dedup, retries and suppression”Each reviewer is notified once per PR and never again for that PR: pushing new commits does not re-ping anyone, on any backend. The webhook receiver and the poller share the same seen file and the same <repo>:<pr>:<login> key, so a request that arrives by webhook and is then found by the next poll (or the other way round) still produces exactly one card.
A seen line now means a card was sent. Both paths notify first and write seen only on a confirmed send; a failed send is counted in notify-fails.json and retried on the next cycle, and only after five consecutive failures is the pair marked seen so it stops retrying for ever. Previously the webhook wrote seen first and the poller wrote it last, so whether a failed card was ever retried depended on which path happened to win.
Draft, bot-authored and over-age pull requests are not marked seen at all. They go into suppressed with the reason and are re-evaluated on every cycle, so a PR that was a draft the first time anyone looked at it pings by itself the moment it is marked ready. It used to be marked seen, permanently, and could never ping again.
A blackholed endpoint cannot wedge discovery. Every card is sent under a 20-second watchdog and every curl in notify.sh carries --connect-timeout 5 --max-time 10. Without them a Slack endpoint that accepted a connection and never answered held the poller’s own lock indefinitely, with nothing in the log to say why.
The dashboard always reflects the live queue regardless of what was announced; a card is a nudge, not the source of truth. To re-announce, see Troubleshooting.
Limits the platforms impose
Section titled “Limits the platforms impose”- Discord rejects an embed title over 256 characters, and used to reject the whole card with a
400visible only in a log line. Titles and descriptions are truncated by character — never mid-character — with an ellipsis. - Slack rejects a section whose text is empty, so a review that produced no summary used to lose its entire card. That block is emitted only when there is something to put in it, and is clamped to Slack’s 3,000-character limit.
- The Slack bot token is not passed on the command line. It goes in on stdin, so it is not readable out of
psfor the lifetime of the request.
Privacy
Section titled “Privacy”Cards carry PR titles, authors and diff sizes; the review-ready card carries the agent’s summary, which can quote code. Use a private channel whose members can all already read the repository, and treat every webhook URL, bot token and WEBHOOK_SECRET as a secret. The generic payload includes each reviewer’s Slack / Discord IDs — send it only to an endpoint you control or trust.
Phone and browser notifications
Section titled “Phone and browser notifications”ReviewStage is an installable app (see Mobile), and it can tell you a review is waiting without Slack or Discord: web push, delivered to each browser or phone you turn it on from. A notification carries the title (Review requested: owner/name#123), the PR title and the review page to open. It never runs a review and never posts anything — tapping it opens the page, and everything from there is still your click.
Turning it on, per device
Section titled “Turning it on, per device”- Open the dashboard on the device — on a phone, from the home-screen app (below).
- Settings → Notifications on this device → turn the switch on, and allow notifications when the browser asks. The device is named from its browser (
iPhone · Safari,Android · Chrome); the list below the switch shows every device you have turned on, with a remove button each. - Send a test delivers “This is what a review request looks like.” to all of your devices. If nothing arrives within a few seconds, see the checks below.
Each person enables their own devices; nobody else’s devices are visible or reachable. A review request pushes only to the devices of the reviewer it names, and a repeat for the same PR replaces the earlier notification rather than stacking (tag).
iPhone and iPad: Safari delivers push only to installed web apps (iOS 16.4 or later). In a Safari tab the switch is replaced by the hint “Add to Home Screen first” — tap Share → Add to Home Screen, open ReviewStage from that icon, then turn notifications on there. Android Chrome and desktop browsers deliver to tabs and installed apps alike.
What the server stores
Section titled “What the server stores”~/.reviewstage/push_vapid.json(mode 600): the server’s VAPID key pair, generated the first time anyone turns notifications on. The public half identifies this server to the push services; the private half signs each send.~/.reviewstage/push_subs.json(mode 600): one row per device — your login, the push service endpoint the browser handed out, the browser’s public key and auth secret, the device name, when it was added, and the browser’s user agent. No GitHub or Claude token is ever involved, and the finding text never goes through a push: only the title, the PR title and a URL, encrypted end to end for that one browser (RFC 8291).- A device whose push service answers
404or410(the person revoked permission, cleared site data or uninstalled the app) is removed automatically on the next send.
All optional; see config.example.
| Variable | Meaning |
|---|---|
RS_PUSH | 1 (default) on; 0 switches push off everywhere without deleting anything. Restart after changing. |
VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY | Pin the pair instead of letting the server generate one (the base64url raw keys npx web-push generate-vapid-keys prints). Both or neither. |
VAPID_SUBJECT | The contact push services may use about the sender — a mailto: or https:// URL. Defaults to PUBLIC_URL when it is https. |
Push rides beside whatever NOTIFY_BACKENDS says: it fires for every reviewer who enabled it whenever a key pair exists, unless the list is none or RS_PUSH=0.
Rotating the keys
Section titled “Rotating the keys”Changing the pair invalidates every subscription — browsers bind their subscription to the public key they were given. To rotate: stop the server, delete push_vapid.json (or set a new VAPID_* pair), start it, and ask everyone to turn notifications off and on again on each device. There is no silent migration; that is a property of the protocol, not of ReviewStage.
When nothing arrives
Section titled “When nothing arrives”bin/doctor.sh(ordocker compose exec app doctor) printsPASS push: VAPID pair presentonce anyone has enabled a device,WARN push: no VAPID pair — notifications offbefore that, and warns when the server’s Python cannot importcryptography(the image installs it; a from-source install needspip install cryptography).- The poller’s log shows
WARN: push: <host>/…: answered 4xxfor a rejected send;… is gone (410) — subscription removedis a device that revoked permission. - On a phone, check the OS notification settings for the installed app, and that it was opened from the home screen at least once since installing.
Not planned soon. Anything that accepts a webhook (Zapier → email, ntfy, Pushover) covers it via the generic backend.
MIT licensed · Built on Claude Code