OneTwoApps App Ratings & ReviewsNotifications

Optional · Notifications · Step by step

The report waits until you open it. The message comes to you.

At the end of the daily run the script checks whether anything happened since the last message: a new review with one or two stars, an app whose status flips, a jump in the crash rate. Only then does a short message go out, via e-mail, Slack, Teams or webhook, with the AI’s one-line summary and a link to the report. Nothing new, no message.

3channels: e-mail, Slack, Teams, webhook
3triggers, each can be switched off
1message per incident, never twice
0new requests to the AI
Example

How it arrives

App reviews: 2 new 1–2 ★ · status red: Scanbox

STATUS
- Scanbox (Apple): yellow → red – 3 × 1–2 ★ in 7 days

NEW 1–2-★ REVIEWS
- Scanbox (Apple), 1 ★, 2026-10-24, DEU, Version 2.4.1
  “Login no longer works”
  Since the update I cannot get in any more.
  AI: login fails since the update (urgency high)
- Scanbox (Apple), 2 ★, 2026-10-23, AUT
  Constant crashes when opening the inbox.

Report: https://reviews.company.internal/
Lines marked “AI:” come from the stored AI analysis
(anthropic (claude-sonnet-5-5)); numbers never come from the AI.

When it runs

Always last. And only when there is something to say.

./ratings.sh daily does three things in a row: fetch (with the AI analysis if enabled), report, notification. It deliberately comes last so that the status, the AI lines and the linked report reflect the day.

1Once per incident

The script remembers in data/notify-state.json when it last reported, which status each app had and whether a crash reason was present, and keeps an alert log there (the last 500 incidents). If daily runs again the same day, it finds nothing new and stays quiet. ./ratings.sh notify --history and the report under “Data status” show the log; a red or yellow traffic light says “for 3 days”.

2The first run is silent

After enabling, the first run only remembers the state. Otherwise a message would list every 1–2-★ review ever fetched. If you really want that, set quietOnFirstRun to false.

3Not delivered does not mean forgotten

If no channel answers (mail server down, webhook returns an error), the remembered state stays and the next run reports the same incidents again. If one channel delivered, the incident counts as reported; the run still ends with exit code 1 so the error shows up in the log.

The notification needs no access to Apple, Google or the AI. It reads only the files under data/, the same ones as the report. In the GitHub workflow it is the same step, on Windows the third call in daily.cmd. With notify.enabled: false (default) nothing happens at all, daily runs as before.

What triggers a message

Three triggers. The same rules as the traffic light in the report.

The notification invents no thresholds of its own. Whatever turns yellow or red in the report is reported; the rules live in one place (src/metrics.py) and are explained in the report under “Status”.

New 1–2-★ reviewstriggers.negativeReviews

Every review with one or two stars fetched since the last run: app, stars, date, country, version, title and text (shortened). If the AI analysis runs, its one-line summary is added, such as “AI: login fails since the update”. More than ten are counted rather than listed.

Status changetriggers.ampelChange

An app turns yellow or red, or it is green again. With the reasons as the report phrases them: “3 × 1–2 ★ in 7 days”, “crashes 0.8 → 1.9”, “store below 4 ★”; “2 without reply” only if that rule is switched on in the setup (off by default). An improvement from red to yellow is not reported, only the return to green.

Crash jumptriggers.crashJump

The crash rate per 1000 sessions has risen by at least half or is above 2, and that is new since the last run. Even without a status change. Apple only, from 500 sessions in the window, as in the report.

What does not triggerby design

Reviews with three to five stars, new downloads, support replies, a new update. All of that is in the report; the message is meant to be rare and to count when it arrives.

What the message looks like

Subject, three blocks, one link. Readable on a phone.

Plain text, no images, no HTML. The same message goes to e-mail, Slack and Teams (there as a card, one line per text block); the webhook additionally receives it as structured JSON.

1

Subject

Counts what is inside so the inbox list is enough: App reviews: 2 new 1–2 ★ · status red: Scanbox. In Slack this is the bold first line.

2

Blocks

STATUS with level before → after and reasons. CRASHES with the rate before → after. NEW 1–2-★ REVIEWS with one header line, title, text and AI line each. Empty blocks are omitted.

3

Footer

Link to the report from settings.reportUrl (the file path if not set). If the message contains AI lines, it names provider and model. Numbers never come from the AI. Language de or en.

// webhook: the same as JSON (shortened), signature in the header X-Ratings-Signature { "schemaVersion": 1, "reportUrl": "https://…", "subject": "App reviews: …", "text": "…", "events": [ { "kind": "negativeReview", "appName": "Scanbox", "platform": "apple", "review": { "rating": 1, "createdAt": "2026-10-24T10:00:00Z", "country": "DEU", "title": "Login no longer works", "text": "…" }, "ai": { "issue": "login fails since the update", "urgency": "high" } }, { "kind": "ampelChange", "appName": "Scanbox", "ampel": { "before": 1, "after": 2, "reasons": [ { "code": "neg", "text": "3 × 1–2 ★ in 7 days" } ] } } ] }

Set up · Steps 1 to 3

One account, one environment variable, one block in config.json.

The block notify is ready in config/config.example.json. All you need is access to the channel the message should use. All three at once work too.

1

Get access

E-mail: SMTP server, port, user name and password of a mailbox allowed to send; with many providers an “app password”. Sending is encrypted, STARTTLS on 587 or SSL on 465.
Slack: create an app with “Incoming Webhooks” in Slack and generate a webhook for the channel. The address https://hooks.slack.com/services/… is the secret.
Teams: in the channel open the three dots, “Workflows”, create the template “Post to a channel when a webhook request is received” and change nothing. The address https://…logic.azure.com/workflows/… is the secret. Not yet tried against a real Teams.
Webhook: an HTTPS address that accepts JSON via POST (n8n, Zapier, Power Automate with your own flow, your own service). Optionally a shared secret for the signature.

2

Secret as environment variable

Password, webhook address and signing secret never go into config.json, only their variable name. For the daily run the variable belongs to the scheduler’s environment: systemd unit, launchd plist, Task Scheduler or the CI system’s secrets (see BETRIEB.md).

# ~/.zshrc or the service’s ratings.env
export RATINGS_SMTP_PASSWORD="…"
export RATINGS_SLACK_WEBHOOK="https://hooks.slack.com/services/…"
export RATINGS_TEAMS_WEBHOOK="https://…logic.azure.com/workflows/…"

# check without showing the value
test -n "$RATINGS_SMTP_PASSWORD" && echo set

If a password or webhook address ends up in the file by mistake, the script rejects the configuration. In logs and error messages the secret part is removed.

3

Enter the block

Easiest in the setup in the browser, step notification. By hand: enabled to true, the same on the channel you want. Plus settings.reportUrl with the address where the company reaches the report.

"settings": { "reportUrl":
  "https://reviews.company.internal/" },
"notify": {
  "enabled": true,
  "language": "en",
  "channels": [
    { "type": "email", "enabled": true,
      "host": "smtp.company.com", "port": 587,
      "security": "starttls",
      "username": "reports@company.com",
      "passwordEnvironmentVariable":
        "RATINGS_SMTP_PASSWORD",
      "from": "reports@company.com",
      "to": ["boss@company.com"] },
    { "type": "slack", "enabled": true,
      "webhookUrlEnvironmentVariable":
        "RATINGS_SLACK_WEBHOOK" },
    { "type": "teams", "enabled": false,
      "urlEnvironmentVariable":
        "RATINGS_TEAMS_WEBHOOK" },
    { "type": "webhook", "enabled": false,
      "urlEnvironmentVariable": "RATINGS_WEBHOOK_URL",
      "secretEnvironmentVariable":
        "RATINGS_WEBHOOK_SECRET" }
  ]
}

Step 4 · Test

First see what would be reported. Then a test message. Then it runs by itself.

Both commands are also in the menu under “Status and overview”. Neither changes the remembered state.

1

Dry run

Shows the message the daily run would send right now, with all incidents. Nothing is sent and nothing is remembered. Before enabling this is usually empty or “first run, state remembered”.

./ratings.sh notify --dry-run
2

Test message

Sends a short test message to all enabled channels and says which one delivered. This checks password, port and webhook address without waiting for a real 1-★ review.

./ratings.sh notify --test
Testnachricht: 2 von 2 Kanälen zugestellt.
3

The next daily run

Nothing more to do afterwards. The first real run remembers the state, every further run reports what arrived since. If the scheduler is already running, it is enough to add the environment variables there.

./ratings.sh daily
… report built …
Benachrichtigung: 3 Vorfälle, 2 von 2 Kanälen zugestellt.

Settings

Few switches. All in the block notify, all optional.

The defaults suit everyday use. The table is also in BENACHRICHTIGUNG.md and README.md.

FieldMeaningDefault
enabledswitch for the whole stepfalse
languagelanguage of the message, de or ende
triggersnegativeReviews, ampelChange, crashJump, each on or offall true
quietOnFirstRunfirst run only remembers the statetrue
maxReviewsPerMessagethis many new 1–2-★ reviews are listed individually, the rest is counted10
channels[].typeemail, slack, teams or webhook; each channel has its own enabledempty
emailhost, port, security (starttls, ssl, none only to 127.0.0.1), username, passwordEnvironmentVariable, from, to (list)port 587, STARTTLS
slackwebhookUrlEnvironmentVariable
teamsurlEnvironmentVariable (address of the Teams workflow)
webhookurlEnvironmentVariable, optional secretEnvironmentVariable (HMAC-SHA256 over the body, header X-Ratings-Signature)
settings.reportUrladdress of the published report for the link in the messagenull = file path

What data leaves

Review excerpts to your addresses. Nothing to us, nothing to the AI.

Recipients are only the addresses you enter: your mail server, your Slack workspace, your Teams tenant, your webhook service. Reviews are public texts, but they may contain details the author wrote in themselves. Recipients therefore belong inside the company, not on open lists.

What the message contains
  • per reported review stars, date, country, version, title and text (at most 300 characters)
  • name of the app and store
  • status before and after with reasons, crash rate before and after
  • the stored AI one-liner for the review if the analysis runs, with provider and model in the footer
  • the link to the report
Never
  • user names, addresses, phone numbers, e-mail addresses of reviewers. They are not stored in the first place.
  • review IDs in e-mail and Slack; the webhook JSON carries the ID so your own service can find the review in the report
  • credentials, keys, client names, store figures
  • a new request to an AI provider: the AI lines are the stored results of the analysis
  • data to the maker of this script: there is no server

Encrypted. E-mail only with STARTTLS or SSL and certificate check, webhooks only via HTTPS; unencrypted works solely for a relay on the same machine. Password, webhook address and signing secret come only from environment variables; in logs even the secret part of a Slack address is removed. The published report mentions the notification in its privacy policy (section 4b).

If something does not work

The message says what is missing. Here is what to do then.

The log is in data/logs/notify.log. A channel error does not stop the other channels. Messages are in German; the gist is below.

“Benachrichtigung ist ausgeschaltet” (notification is off)with --test

notify.enabled is false.

“Kein Kanal freigeschaltet” (no channel enabled)with --test

The channel lacks "enabled": true.

“Umgebungsvariable … ist nicht gesetzt oder leer” (variable not set)scheduler

The variable is in your shell but not in the service’s environment. Add it to the systemd unit, plist, Task Scheduler or CI secrets.

“SMTP-Verbindung … fehlgeschlagen” (SMTP connection failed)e-mail

Host, port, firewall. Port 587 needs starttls, port 465 ssl.

“E-Mail konnte nicht gesendet werden” with 535e-mail

User name or password wrong; many providers require an app password instead of the account password.

“Teams-Workflow abgelehnt” (Teams workflow rejected)Teams

Workflow deleted, switched off or address copied wrongly (it is long and contains sig=…). In Teams open “Workflows” and look at the run history; the reason is shown there.

“Slack-Webhook abgelehnt” (Slack webhook rejected)Slack

Webhook deleted in Slack or address copied wrongly. Create a new one in Slack and replace the variable.

A message arrives although nothing is newstate

data/notify-state.json is missing or was deleted; then it is a first run again, which only sends with quietOnFirstRun: false.

No message despite new 1–2 ★check

./ratings.sh notify --dry-run shows what is pending. If the review arrived before enabling, it counts as known.