App Store Connect contains a surprising amount of useful information. It knows whether a build finished processing, where a release is in review, what people are writing in reviews, how downloads are moving, and which parts of a product page are being seen.
The problem is that I rarely want to open App Store Connect and navigate through all of it.
What I wanted was much simpler: go into an app's Slack channel and ask a question.
@Ops how are the App Store metrics looking today?
The answer should appear in the same thread, already scoped to the right app. A release channel should understand questions about builds and review state. A ratings channel should understand questions about reviews. I should also be able to use explicit commands when I want predictable behavior:
/store brief
/store metrics
/store reviews
/store release
I did not want a bot continuously polling Apple, dumping raw JSON into Slack, or keeping an expensive server alive. The final system uses App Store Connect webhooks for events Apple can push, and runs ASC CLI only when someone requests a report. ASC CLI was built by my friend Rudrank Riyam, and the project is open source on GitHub.
This post explains the general architecture. Credentials, signing material, route IDs, and production configuration have been deliberately removed.
Here is the actual interaction in Slack. I ask for the latest weekly metrics, the bot acknowledges the exact seven-day period it is about to calculate, and the completed report returns to the same thread:

The production flow from request to completed report. Private metric values are redacted; the rest of the interface is untouched.
The Important Distinction: Push Events and Pull Reports
The design became much clearer once I stopped treating all App Store Connect data as if it arrived the same way.
Apple now supports App Store Connect webhooks for lifecycle events such as:
- build upload state changes
- beta build state changes
- app version state changes
- new TestFlight crash feedback
- new TestFlight screenshot feedback
Those are genuine events. Apple already knows when they happen and can push them to an HTTPS endpoint. Polling for them would add delay and unnecessary API calls.
Metrics and customer reviews are different. They are resources I can retrieve through the App Store Connect API, but they are not part of the webhook event set I needed. Trying to imitate a webhook by checking them every few minutes would be wasteful, and daily polling would still generate reports I might never read.
The resulting rule is simple:
Apple pushes lifecycle changes. A human pulls reports when they want them.
This gives Slack useful real-time notifications without turning it into a firehose, while keeping metrics and review collection entirely on demand.
The Architecture
The system has four parts:
Apple webhooks
│
▼
Slack ── command/mention ──> Cloudflare Worker ──> D1
│ │
│ workflow_dispatch │ job + route state
▼ │
GitHub Actions │
│ │
asc CLI │
│ │
App Store Connect │
│ │
└── signed callback ─┘
│
▼
Slack thread
The Cloudflare Worker is the coordinator. It receives authenticated Slack requests and Apple webhooks, resolves the correct app and channel, creates jobs, dispatches GitHub Actions, and posts results back to Slack.
Cloudflare D1 stores the small amount of durable state the coordinator needs: channel routes, event identities, delivery receipts, and report jobs.
GitHub Actions is the execution environment for asc. A Worker is excellent at validating requests and coordinating HTTP services, but it cannot launch an arbitrary native CLI process. I could have reimplemented every App Store Connect request in TypeScript, but asc already handles authentication, pagination, resource relationships, deterministic JSON output, and a large command surface.
An ephemeral CI runner is a much better process boundary. It starts only when asked, receives only the credentials it needs, runs the report, returns the result, and disappears.
Modeling Slack as Routes, Not One Giant Channel
My first instinct was to create one operations channel per app. That quickly became noisy. Releases, reviews, product ideas, user feedback, and revenue events have different audiences and different rhythms.
Instead, I model Slack destinations as data:
CREATE TABLE slack_routes (
app_slug TEXT NOT NULL,
topic TEXT NOT NULL,
channel_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
UNIQUE (app_slug, topic, channel_id)
);
The topic can be anything: general, metrics, ratings-reviews, releases, bug-reports, or a future category I have not invented yet.
This produces a useful property: adding a new channel does not require a Worker deployment. I create the channel, invite the bot, and insert a route.
The reverse lookup is equally valuable. Given a Slack channel ID, the Worker can infer both the app and the topic:
const route = await findRouteByChannel(channelId)
const app = route?.appSlug
const defaultReport = reportForTopic(route?.topic)
That is what makes a message in a metrics channel feel natural. The user does not need to restate context that Slack already contains.
Receiving Commands and Mentions
I support both a slash command and Slack's app_mention event.
Slash commands are deterministic and easy to document. Mentions are more conversational:
@Ops what is happening with the latest build?
@Ops show me the newest written reviews
@Ops give me the complete brief
The intent parser is deliberately boring. It does not need a language model to distinguish four reports:
function inferReport(text: string, topic?: string) {
const value = text.toLowerCase()
if (/\b(review|reviews|rating|ratings)\b/.test(value)) return 'reviews'
if (/\b(release|build|testflight|submission)\b/.test(value)) return 'release'
if (/\b(metric|download|impression|session|sales)\b/.test(value)) return 'metrics'
if (topic === 'ratings-reviews') return 'reviews'
if (topic === 'releases') return 'release'
if (topic === 'metrics') return 'metrics'
return 'brief'
}
The Worker verifies Slack's signature over the exact raw request body before parsing anything. It also stores Slack's event ID or command trigger ID as an idempotency key. Slack can retry delivery, and a retry must not launch a second CI job.
Another constraint is response time. A Slack slash command expects a quick acknowledgement. Waiting for a CI runner to start, authenticate with Apple, download reports, and aggregate them would time out.
The synchronous path therefore does almost nothing:
- Verify the Slack signature.
- Resolve the app and report type.
- Create an idempotent job.
- Post “Pulling the latest Apple data…” in a thread.
- Dispatch the background workflow.
- Return HTTP 200.
The longer work runs through waitUntil() and GitHub Actions.
Dispatching the ASC Runner
GitHub exposes an API for creating a workflow_dispatch event. The Worker sends four non-secret inputs:
{
"ref": "main",
"inputs": {
"action": "metrics",
"app_slug": "example-app",
"job_id": "4f2c...",
"window_days": "14"
}
}
The app registry that maps a slug to an App Store Connect app ID lives in the private repository. The Slack message never contains an Apple credential, and the GitHub dispatch never contains the Slack token.
The workflow itself is intentionally small:
name: On-demand App Store brief
on:
workflow_dispatch:
inputs:
action:
required: true
type: choice
options: [brief, metrics, reviews, release]
app_slug:
required: true
type: string
job_id:
required: true
type: string
window_days:
required: true
default: "7"
type: choice
options: ["7", "14", "30"]
permissions:
contents: read
jobs:
report:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- run: brew install asc
- name: Configure App Store Connect auth
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
run: |
printf '%s' "$ASC_PRIVATE_KEY" > "$RUNNER_TEMP/AuthKey.p8"
asc auth login \
--bypass-keychain \
--local \
--name ci \
--key-id "$ASC_KEY_ID" \
--issuer-id "$ASC_ISSUER_ID" \
--private-key "$RUNNER_TEMP/AuthKey.p8"
- name: Build report
run: ruby scripts/app_store_brief.rb \
--app "${{ inputs.app_slug }}" \
--action "${{ inputs.action }}" \
--window-days "${{ inputs.window_days }}" \
--output report.txt
The actual workflow has one more important step: if: always() sends either the report or a controlled failure message back to the Worker. Without that, a failed authentication or malformed report would leave a permanent “working…” thread in Slack.
Building Useful Reports from ASC
asc is JSON-first, which makes it a good source for a deterministic reporting script.
Release status is one command:
asc status --app "$APP_ID" --platform IOS --output json
Ratings and reviews are similarly direct:
asc reviews ratings --app "$APP_ID" --country us --output json
asc reviews \
--app "$APP_ID" \
--sort=-createdDate \
--limit 10 \
--include-response \
--output json
Analytics needs more care. App Store Connect Analytics Reports are asynchronous resources. I first create or reuse one ongoing report request per app:
asc analytics request \
--app "$APP_ID" \
--access-type ONGOING \
--reuse-existing
The reporting job then walks the available reports, instances, and segments for the dates it needs. Depending on the app and Apple's privacy thresholds, some datasets may be empty or delayed.
This led to an important reporting decision: I do not label the result “today” just because the user asked today.
The default is the latest seven complete reported days compared with the seven days before them. But the comparison window is part of the request, not a hidden reporting constant. I can ask for 7d, 14d, or 30d, or use phrases such as “weekly metrics,” “the last two weeks,” and “monthly metrics.” The Worker normalizes that choice and sends it to the reporting job as window_days.
This fixed a surprisingly important usability problem. A line such as “impressions down 51.8%” is almost meaningless if I cannot tell whether it means week over week, month over month, or something else. The acknowledgement now states the requested window before any work begins, and the completed report repeats the baseline:
Comparison: latest 14 complete reported days vs the previous 14 days.
Percentages are period-over-period.
Unsupported requests such as a ninety-day or yearly comparison do not silently fall back to seven days. The bot explains the supported windows instead.
The response also includes a data-health line with the actual dates used:
Downloads: 142 ↑ 12.7%
Product-page views: 391 ↓ 3.2%
Sessions: 1,804 ↑ 8.1%
Data health: downloads through 8 Aug; sessions through 7 Aug;
crash rows unavailable because privacy thresholds may apply.
Different reports can legitimately have different latest dates. Hiding that detail would make a polished Slack message less trustworthy than the raw data. The visible date ranges are as important as the percentages themselves.
Returning to the Original Slack Thread
The workflow does not have a Slack token. It returns a compact result to a dedicated Worker endpoint:
{
"job_id": "4f2c...",
"status": "success",
"report": "*Example App · App Store metrics*\n..."
}
The callback is authenticated with a separate random bearer secret shared only by GitHub Actions and the Worker.
The Worker loads the job from D1, finds the original channel and thread timestamp, posts the result using its Slack bot token, and marks the job complete. A repeated callback sees the terminal job state and becomes a no-op.
This boundary keeps the credentials narrow:
Slack signing secret → Cloudflare only
Slack bot token → Cloudflare only
GitHub dispatch token → Cloudflare only
ASC private key → GitHub Actions only
Callback token → Cloudflare + GitHub Actions
Apple webhook secret → Cloudflare + App Store Connect
No system gets every secret.
App Store Connect Webhooks
The other half of the system does not involve GitHub Actions at all.
I register a webhook for each app with a URL shaped like:
POST /webhooks/apple/:app
The Worker verifies Apple's HMAC signature over the unmodified request body, validates the app against its registry, and stores the Apple event ID before routing it. Build and app-version state changes go to the app's release topic. TestFlight crash and screenshot feedback go to its bug-report topic.
The event ID is the deduplication key. Delivery state is recorded per destination channel so a retry can resume a failed Slack post without duplicating channels that already succeeded.
Apple provides webhook pings and delivery records, which are worth using. A configuration is not complete because the API accepted it. I sent a ping for every registered app and checked that the most recent delivery showed SUCCEEDED with HTTP 200.
What Did Not Work
Several plausible designs were worse than the final one.
Polling everything on a schedule
Scheduled collection sounds simple until it runs across many apps. Most reports would be generated when nobody wanted them, review polling would still not be real time, and an aggressive interval would waste App Store Connect API capacity.
Webhooks plus on-demand reports aligned the work with an actual event or question.
Putting every event in one channel
A single operations channel became a mixed stream of releases, reviews, revenue events, errors, and ideas. Per-app topics made routing predictable and let the Slack channel itself carry context.
Running ASC inside the Worker
Cloudflare Workers are not general-purpose process hosts. Treating the Worker as a coordinator and using an ephemeral CI runner for the native CLI kept both sides simple.
Giving the CI runner a Slack token
It would have been convenient for GitHub Actions to post directly to Slack. It would also have spread a powerful workspace credential into another system and duplicated routing logic. The callback design lets the runner return data without knowing anything about Slack.
Assuming all analytics share one “latest” date
Downloads, sessions, purchases, crashes, and discovery reports can arrive on different schedules. A single global date silently mixes incomplete and complete windows. Each report needs its own latest complete date and an honest availability note.
Stopping after individual component tests
The Worker passed its tests. The ASC script returned real data locally. The GitHub workflow looked correct. Slack accepted the manifest.
The first end-to-end mention still found a workflow validation error: a GitHub context was used at a scope where it was not available. actionlint caught the exact line, but only after the Slack-triggered test forced me to inspect the run.
The useful definition of “done” was one message traveling through Slack, Cloudflare, D1, GitHub Actions, asc, App Store Connect, the authenticated callback, and back into the original Slack thread.
Security Rules I Would Keep
-
Verify raw request bodies. Slack and Apple signatures cover the exact bytes received. Do not parse and reserialize before verification.
-
Use a separate secret for every trust boundary. The callback token is not the ingest token, Slack signing secret, or Apple webhook secret.
-
Keep the reporting command set read-only. A conversational request should never accidentally become a release submission or review response.
-
Do not put credentials in workflow inputs. Inputs are identifiers and requested actions. Secrets come from the platform's encrypted secret store.
-
Make every external delivery idempotent. Slack retries events, Apple can resend deliveries, GitHub callbacks can retry, and networks fail after the receiver commits work.
-
Return bounded output. Slack messages have limits, and raw analytics payloads can be enormous. Aggregate first, truncate defensively, and retain only the evidence needed for debugging.
-
Test webhook delivery, not just registration. A saved URL and secret prove configuration, not reachability or signature compatibility.
-
Name every comparison baseline. “Down 12%” is not a useful operational statement until the report says which period and dates it is comparing.
The Result
I now have a small App Store command center that behaves like part of Slack rather than a dashboard bolted onto it.
Lifecycle events arrive automatically in the correct app channel. Metrics, ratings, reviews, and release briefings run only when requested. The app is inferred from the channel. The result returns to the original thread. There is no traditional server, no daily polling job, and no Slack credential inside CI.
The surprising part is how little of the system is specific to App Store Connect. The same pattern works whenever a useful CLI cannot run in an edge function:
chat command → authenticated coordinator → ephemeral runner → signed callback
Cloudflare Workers provide the low-latency public control plane. D1 provides just enough durable coordination. GitHub Actions provides a temporary process host. asc turns Apple's API into a scriptable interface. Slack provides the context and the place where the answer is actually useful.
None of those pieces is especially complicated on its own. The value comes from giving each one a narrow responsibility—and refusing to solve an event-driven problem with another polling loop.