Forwarding App Store Connect webhooks to Slack: the definitive guide
Every realistic way to get App Store Connect and App Store Server Notifications V2 events into Slack — DIY incoming webhooks, the OAuth-app route, and the per-event filtering, private-channel, and signing-key gotchas nobody mentions until you hit them.
Slack App Store Connect App Store Server Notifications iOS
Almost every iOS team eventually decides they want App Store Connect events in Slack. App review verdicts, TestFlight feedback, build processing, subscription churn — all of it surfacing in a channel where the people who care about it already are, rather than in the App Store Connect UI nobody opens between releases.
The path from “we want this” to “this works reliably” is longer than it looks. There are two webhook systems on Apple’s side (we covered why in May), three different ways to receive them in Slack, half a dozen gotchas around channels and permissions, and a long tail of edge cases that only show up once a real tester submits real feedback at 2 a.m. on a Sunday.
This post walks the whole route. Skip ahead to whichever section matches where you are.
The two Apple sides
Quick recap so the rest of the post makes sense.
App Store Connect webhooks cover operational events: builds being uploaded, App Review state changes, TestFlight feedback, beta build state. Configured under Users and Access → Integrations → Webhooks. HMAC-SHA256 signing with a shared secret per webhook.
App Store Server Notifications V2 cover purchases: subscription renewals, refunds, billing failures, in-app purchases. Configured per-app, under that app’s App Information → App Store Server Notifications. JWS-signed with a chain back to Apple Root CA G3, with signedTransactionInfo and signedRenewalInfo nested two layers deep inside the outer payload.
Two systems. Two configuration screens. Two signing schemes. Most teams that want one eventually want both. Everything below applies to both, unless I call out otherwise.
The three ways to get this into Slack
There are realistically three options.
1. Slack incoming webhooks (DIY)
Slack’s classic incoming webhook URL takes a JSON POST and posts a message to a fixed channel. You point Apple at… no, you don’t, because Apple’s webhooks aren’t formatted as Slack messages — they’re either plain JSON (ASC) or JWS-wrapped JWTs (ASSN V2). You can’t point Apple’s webhook URLs straight at Slack.
So this path means standing up an intermediate service. Something on Cloudflare Workers, a tiny Rails or Node app, an AWS Lambda — that receives Apple’s webhook, verifies the signature, decodes the payload, formats a Slack blocks array, and POSTs it to the incoming webhook URL.
Doable. Each piece is also harder than it looks once you start handling real events. The ASSN V2 path needs ES256 signature verification against a rotating x5c chain rooted at Apple Root CA G3, then a recursive decode of the nested JWS layers. The ASC side needs HMAC verification using the per-webhook secret from the App Store Connect UI. Block Kit formatting that isn’t ugly takes a non-trivial amount of fiddling. And the moment you start receiving real subscription events, you’ll learn that Apple added new payload fields in April and again at WWDC that your decoders silently don’t know about.
There’s also a Slack-specific footgun on this path: modern app-bound incoming webhooks ignore the username and icon_url keys. If you were planning on overriding the sender name to your app’s name and the avatar to your app’s icon, you can’t. The sender is fixed to whatever you named your Slack app when you created it. (See the Slack incoming-webhooks docs for the current rules.)
If you only want one event type, in one channel, and you already have a server happy to host the receiver, this is a reasonable weekend project. If you want more than that, keep reading.
2. A proper Slack OAuth app
The “right” way to integrate with Slack in 2026 is an OAuth app installed via https://slack.com/oauth/v2/authorize. The flow:
- User clicks “Add to Slack”.
- Slack redirects to your authorize URL with a
code. - You exchange the
codefor a bot token viaoauth.v2.access. - You post messages via
chat.postMessage, passing the channel ID and ablockspayload.
This is what every modern Slack integration does. It’s also what you’d build if you went the DIY route and the requirements grew past one-channel-one-event. You need a registered Slack app (client ID + secret), a public OAuth callback URL, a place to store the bot token securely, and a channel picker UI that lists what the bot can see via conversations.list.
The scopes worth requesting, for a webhook forwarder:
chat:write— post messages as the bot.chat:write.public— post to public channels the bot hasn’t been/invite-d to.channels:read— list public channels for the picker.groups:read— list private channels the bot has been invited to.
You need both channels:read and groups:read if you want to support private channels — they’re separate scopes in Slack. And without chat:write.public, every send to a public channel the bot isn’t a member of comes back with not_in_channel, which is bad UX for an integration the user just installed.
3. Use Juice Machine
I run Juice Machine, so this is the obvious “or use the thing” pitch. But the boring reason to mention it is that it’s the same OAuth-app pattern as (2), pre-built and pre-signing-verified. The setup looks like this:
- Create an app inside Juice Machine. You get a secret webhook URL.
- Paste that URL into App Store Connect, in either or both of the configuration screens (Users and Access → Integrations → Webhooks, and/or the per-app App Store Server Notifications page). Both webhook formats are auto-detected on the same URL.
- Add a destination of type Slack. Click “Add to Slack”, complete the OAuth dance, pick a channel from the dropdown.
- Optionally filter the destination down to the event types you actually want in that channel.
The full step-by-step is in the App Store Connect setup docs and the destinations docs. What’s worth dwelling on are the bits the docs gloss over.
The OAuth bits that bite teams
Whether you build the OAuth app yourself or use Juice Machine’s, the same handful of issues hit everyone.
Private channels need an explicit invite. chat:write.public covers public channels only. For any private channel, you have to run /invite @juicemachine (or whatever your bot is named) inside that channel after the OAuth handshake. Forget this and the first delivery fails with not_in_channel. The Juice Machine destination form notes this; if you’re building your own, surface the same hint.
Reinstalling doesn’t break existing channels. A common worry: “if I reinstall on a new workspace, do my existing destinations break?” The answer depends on how you store the installation. If it’s keyed off the Slack workspace ID (not the bot user ID) and your channel records reference channel IDs (not names), a clean reinstall replaces the bot token but the channel selection survives. Channel names can change underneath you; channel IDs are stable. Always store the ID.
The sender is your app, not your customer. Same fix as the incoming-webhook case — there’s no way to make Slack think the message came from “Trailmix” instead of “Juice Machine” (or whatever you named your Slack app). What you can do is put the customer’s app icon inside the message body as an image accessory on the header block. That’s what we do, and it reads close enough to native that most people don’t notice the sender label.
What Slack message format actually works
A few opinions, hard-earned.
Use Block Kit, not text. chat.postMessage with just a text argument gives you a single line. Useless for anything with structured fields (version, build, status, product, price). The pattern that survives contact with real events is one attachment with a color (Slack’s old attachments API survives specifically because the color bar still works and Block Kit has no native equivalent), a section block with a mrkdwn header and an image accessory for the app icon, a second section block with fields for the labelled key/value pairs, and a context block for the one-line “what does this status mean” sentence.
Render timestamps as Slack date placeholders. Slack’s <!date^1750939200^{date_short} at {time}|24 Jun 2026> syntax renders the date and time in each viewer’s local timezone. UTC ISO-8601 in the message body looks lazy and confuses anyone whose timezone isn’t UTC. (Worth lifting straight into a DIY receiver — Apple’s payloads carry timestamps as ISO-8601 strings; convert and emit the <!date^…> form.)
Color-code by event family, not by individual event. Green for SUBSCRIBED / DID_RENEW / READY_FOR_SALE. Amber for DID_FAIL_TO_RENEW / IN_REVIEW. Red for REFUND / REJECTED. Blue for build uploads. Once you have more than five colors you might as well have none — your eye stops parsing them as signal.
Always set a text fallback. Block Kit messages without a text argument render as “This content can’t be displayed” in iOS push notifications and in screen-reader contexts. Set text to "<app name> — <event title>" and you get sensible previews everywhere.
Filtering: the bit everyone gets wrong
The default temptation, once you’ve wired this up, is to route every event for every app to one Slack channel. Don’t.
Three months in, here’s what teams actually want:
#app-reviewsforappStoreVersionStateUpdatedonly — and ideally only the exciting transitions (READY_FOR_SALE,REJECTED), not the half-dozen interim states.#testflight-feedbackforbetaFeedbackCreatedonly.#buildsforbuildStateCreated/buildBetaStateUpdated, or just turned off entirely once the team trusts CI.#subscription-eventsor a private#$$$channel forSUBSCRIBED,DID_RENEW,REFUND.#oncall(and PagerDuty) forDID_FAIL_TO_RENEW,GRACE_PERIOD_EXPIRED, anything billing-recovery-flavoured.
That’s five destinations, each filtered to a different subset of events. If you’re rolling your own, this means a filter table per destination (a list of event types + optional subtypes), plus an early next in the dispatch loop. If you’re using Juice Machine, it’s a checkbox grid on the destination edit page — and common type+subtype combinations (e.g. DID_CHANGE_RENEWAL_STATUS:AUTO_RENEW_DISABLED) are listed as their own checkboxes you can toggle independently of the base type.
Critically: by default, new event types Apple introduces should also flow. Apple ships new notificationType values and new subtypes on a roughly annual cadence (commitment plans landed in WWDC 2026; something else will land in 2027). A destination configured with “all events” should keep receiving new ones automatically. A destination configured with explicit filters should keep its filter set and you can opt in to new types later. Whichever filter model you pick, document the new-event-type behaviour, because someone will ask.
Troubleshooting checklist
In rough order of how often each one bites.
Nothing’s arriving at all. Send a test from your destination first. If the test works, the problem is upstream — either the webhook URL isn’t in App Store Connect, or you’ve configured the wrong app. The ASC webhook configuration is per-account (in Users and Access), not per-app — verify the app is included in the webhook’s “applies to” set. ASSN V2 is configured per-app under that app’s App Information.
not_in_channel errors after Slack reinstall. Bot was removed from a private channel during the workspace shuffle. /invite @yourbot in each affected channel.
Messages going to the wrong channel. Almost always a channel ID changed, or you stored the channel name instead of the ID. Channel names rename freely in Slack and don’t notify integrations.
Sender shows as “App” or with a different icon than you expected. Modern Slack OAuth apps inherit identity from the Slack app’s configuration in api.slack.com, not from the payload. Change the icon and display name in your Slack app’s settings if you want them different — payload-level overrides have been deprecated.
ASSN V2 events arrive late or out of order. That’s how Apple works. DID_RENEW for a transaction can land before SUBSCRIBED. Build for unordered, at-least-once arrival. The notificationUUID in each payload is the dedup key.
A signature verifies fine in dev and fails in production. Apple rotates signing keys. The x5c chain in each ASSN V2 payload contains the public certificate for the key that signed this specific event — your verifier should use that chain, not a cached “current” key. ASC webhooks use a shared HMAC secret instead, and Apple sometimes rotates it; if your verification suddenly fails for ASC events, regenerate the secret in App Store Connect and update your verifier.
TL;DR
- Apple’s two webhook systems both flow to Slack the same way — through an OAuth-installed Slack app posting via
chat.postMessage, not via raw incoming webhooks. - DIY-ing the receiver is fine for a single channel and one event type. Past that, the OAuth dance, the channel picker, the signature verification, and the Block Kit formatting all want to be one thing — either your platform or a hosted one.
- Use Block Kit, color-code by family, use Slack’s
<!date^…>placeholder, and always set atextfallback. - Filter aggressively. Five channels with subset filters beats one channel with everything.
- Private channels need
/invite; new event types should keep flowing by default; channel IDs not names. - Juice Machine does all of this in a Slack destination, with the same OAuth + filter model described above and signature verification handled in our JWS path. Both Apple webhook formats land on the same URL.