(1 month ago) · Ciprian Rarau · Technology · 11 min read
App Store Connect Webhooks: Automated Release Notes with Git Tags
How I connected Apple's App Store Connect webhooks to Slack, with automatic commit tracking between releases and forced auto-upgrades for internal TestFlight users. Build uploaded? Here's what changed, and your app will update itself.

The Problem
iOS build and release lifecycle is opaque:
- Push code, trigger build
- Wait… is it processing?
- Check TestFlight manually
- Build ready? Who knows?
- Submit for review
- Wait… is it in review?
- Approved? Rejected? Check App Store Connect
- Released? Hope someone noticed
And when a build goes out: “What’s in this release?” requires digging through git history.
The Solution
Apple sends webhooks. I listen.

What Apple Sends
App Store Connect fires webhooks for:
| Event Type | When It Fires |
|---|---|
BUILD_UPLOAD_STATE_UPDATED | Build processing (uploading, processing, valid, failed) |
BUILD_BETA_DETAIL_EXTERNAL_BUILD_STATE_UPDATED | TestFlight status changes |
APP_STORE_VERSION_APP_VERSION_STATE_UPDATED | Review submission, approval, rejection, release |
BETA_FEEDBACK_CRASH_SUBMISSION_CREATED | TestFlight crash report from tester |
BETA_FEEDBACK_SCREENSHOT_SUBMISSION_CREATED | TestFlight screenshot feedback |
The Cloud Function
Webhook Handler
@functions_framework.http
def handle_webhook(request: Request):
"""Entry point for App Store Connect webhooks."""
# 1. Verify Apple's signature
secret = get_secret("appstore-webhook-secret")
if not verify_apple_signature(request, secret):
return "Unauthorized", 401
# 2. Parse the webhook payload
data = request.get_json()
event_type = data.get("eventType")
app_id = extract_app_id(data)
# 3. Route to appropriate handler
if "BUILD" in event_type:
handle_build_event(data, app_id)
elif "APP_STORE_VERSION" in event_type:
handle_app_store_event(data, app_id)
elif "FEEDBACK" in event_type:
handle_feedback_event(data, app_id)
# 4. Always return 200 (prevent Apple retries)
return "OK", 200Signature Verification
Apple signs webhooks with HMAC-SHA256:
def verify_apple_signature(request: Request, secret: str) -> bool:
"""Verify the HMAC-SHA256 signature from Apple."""
signature_header = request.headers.get("X-Apple-Signature")
if not signature_header:
return False
# Apple sends: "hmacsha256=<hex>"
signature = signature_header.replace("hmacsha256=", "")
# Calculate expected signature
expected = hmac.new(
secret.encode("utf-8"),
request.get_data(), # Raw request body
hashlib.sha256
).hexdigest()
# Constant-time comparison (prevents timing attacks)
return hmac.compare_digest(signature.lower(), expected.lower())Fetching Build Details
The webhook payload contains IDs, not details. I fetch the actual version/build from Apple’s API:
def fetch_build_details(build_upload_id: str) -> dict:
"""Fetch build details from App Store Connect API."""
token = generate_jwt_token() # ES256 JWT, 20-minute expiry
# 1. Get buildUpload to find the build ID
response = requests.get(
f"{API_BASE_URL}/buildUploads/{build_upload_id}",
headers={"Authorization": f"Bearer {token}"},
params={"include": "build"}
)
build_id = response.json()["data"]["relationships"]["build"]["data"]["id"]
# 2. Get the build with version info
build_response = requests.get(
f"{API_BASE_URL}/builds/{build_id}",
headers={"Authorization": f"Bearer {token}"},
params={"include": "app,preReleaseVersion"}
)
data = build_response.json()
return {
"version": data["included"][1]["attributes"]["version"], # 1.2.3
"build_number": data["data"]["attributes"]["version"], # 235
"app_name": APP_NAMES.get(app_id, "Unknown App"),
}The Git Tag Strategy
Here’s where it gets interesting. Every build creates a git tag:
build/prod/235
build/prod/236
build/staging/124
build/staging/125The tag pattern: build/{environment}/{build_number}
GitHub Actions Workflow
# .github/workflows/ios-build.yml
- name: Create build tag and collect commits
run: |
# Find previous build tag for this environment
PREV_TAG=$(git tag -l "build/${{ inputs.lane }}/*" --sort=-v:refname | head -1)
# Get commits since last build
if [ -n "$PREV_TAG" ]; then
COMMITS=$(git log --pretty=format:"%h %s" ${PREV_TAG}..HEAD | head -20)
else
COMMITS=$(git log --pretty=format:"%h %s" -10)
fi
# Create JSON payload
cat > commits.json << EOF
{
"build_number": "$BUILD_NUMBER",
"environment": "${{ inputs.lane }}",
"app_id": "$APP_ID",
"commit_sha": "${{ github.sha }}",
"branch": "${{ github.ref_name }}",
"commits": $(echo "$COMMITS" | jq -R -s -c 'split("\n") | map(select(length > 0))'),
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
# Upload to GCS
gcloud storage cp commits.json \
gs://my-project-prod-appstore-webhook-source/commits/${APP_ID}/${BUILD_NUMBER}.json
# Create and push the tag
git tag "build/${{ inputs.lane }}/${BUILD_NUMBER}"
git push origin "build/${{ inputs.lane }}/${BUILD_NUMBER}"What Gets Stored
{
"build_number": "236",
"environment": "prod",
"app_id": "6471992170",
"commit_sha": "abc123def456",
"branch": "main",
"commits": [
"abc1234 Add dark mode toggle",
"def5678 Fix login validation",
"ghi9012 Update onboarding flow",
"jkl3456 Refactor auth service"
],
"timestamp": "2025-01-27T15:30:00Z"
}Fetching Commits in the Webhook Handler
def fetch_commits_from_gcs(app_id: str, build_number: str) -> list:
"""Fetch commit messages from GCS for a specific build."""
client = storage.Client()
bucket = client.bucket("my-project-prod-appstore-webhook-source")
blob = bucket.blob(f"commits/{app_id}/{build_number}.json")
if blob.exists():
content = blob.download_as_text()
data = json.loads(content)
return data.get("commits", [])
return []The Slack Messages
Build Completed
def format_build_message(build_info: dict, commits: list) -> dict:
"""Format Slack blocks for build notification."""
blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"✅ {build_info['app_name']} Build Ready",
"emoji": True
}
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Version:*\n{build_info['version']}"},
{"type": "mrkdwn", "text": f"*Build:*\n{build_info['build_number']}"}
]
}
]
# Add commits if available
if commits:
commit_text = "\n".join([f"• {c}" for c in commits[:10]])
if len(commits) > 10:
commit_text += f"\n_...and {len(commits) - 10} more_"
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Commits in this build:*\n{commit_text}"
}
})
# Add TestFlight button
blocks.append({
"type": "actions",
"elements": [{
"type": "button",
"text": {"type": "plain_text", "text": "Open in TestFlight"},
"url": f"itms-beta://beta.itunes.apple.com/v1/app/{build_info['app_id']}"
}]
})
return {"blocks": blocks}App Store Events
Different events get different formatting:
| Event | Emoji | Message |
|---|---|---|
| Submitted for Review | 📤 | “My App 1.2.3 submitted for review” |
| In Review | 👀 | “My App 1.2.3 is being reviewed” |
| Approved | ✅ | “My App 1.2.3 approved!” |
| Rejected | ❌ | “My App 1.2.3 rejected” |
| Released | 🚀 | “My App 1.2.3 is now live!” |
Crash Reports
def format_feedback_message(data: dict) -> dict:
"""Format crash or screenshot feedback."""
feedback_type = data.get("feedbackType", "feedback")
if feedback_type == "crash":
emoji = "⚠️"
title = "TestFlight Crash Report"
else:
emoji = "📸"
title = "TestFlight Screenshot Feedback"
return {
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": f"{emoji} {title}"}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"New {feedback_type} from TestFlight user"
}
},
{
"type": "actions",
"elements": [{
"type": "button",
"text": {"type": "plain_text", "text": "View in App Store Connect"},
"url": f"https://appstoreconnect.apple.com/apps/{app_id}/testflight"
}]
}
]
}Terraform Infrastructure
# Cloud Function
resource "google_cloudfunctions2_function" "appstore_webhook" {
name = "appstore-webhook-${var.environment}"
location = var.region
build_config {
runtime = "python312"
entry_point = "handle_webhook"
source {
storage_source {
bucket = google_storage_bucket.function_source.name
object = google_storage_bucket_object.function_zip.name
}
}
}
service_config {
max_instance_count = 10
min_instance_count = 0
available_memory = "256Mi"
timeout_seconds = 60
environment_variables = {
GCP_PROJECT = var.project_id
ENVIRONMENT = var.environment
SLACK_CHANNEL = "alerts-ios-builds"
}
secret_environment_variables {
key = "WEBHOOK_SECRET"
project_id = var.project_id
secret = google_secret_manager_secret.webhook_secret.secret_id
version = "latest"
}
}
}
# Storage for commits
resource "google_storage_bucket" "commits" {
name = "${var.project_id}-appstore-webhook-source"
location = var.region
lifecycle_rule {
condition {
age = 90 # Keep commits for 90 days
}
action {
type = "Delete"
}
}
}Multi-App Support
Single webhook handler, multiple apps:
APP_NAMES = {
"6471992170": "My App", # Production
"6747853426": "My App Stage", # Staging
"6747853441": "My App Dev", # Development
"6758223635": "My App Dev 2", # Dev variant
}
def get_app_name(app_id: str) -> str:
return APP_NAMES.get(app_id, f"Unknown App ({app_id})")Each app has its own:
- Build number sequence
- Git tag namespace (
build/prod/*,build/staging/*) - Commit history in GCS
Webhook Management Script
#!/usr/bin/env python3
"""Manage App Store Connect webhooks."""
import jwt
import time
import requests
EVENT_TYPES = [
"BUILD_UPLOAD_STATE_UPDATED",
"BUILD_BETA_DETAIL_EXTERNAL_BUILD_STATE_UPDATED",
"APP_STORE_VERSION_APP_VERSION_STATE_UPDATED",
"BETA_FEEDBACK_CRASH_SUBMISSION_CREATED",
"BETA_FEEDBACK_SCREENSHOT_SUBMISSION_CREATED",
]
def generate_token(key_id: str, issuer_id: str, private_key: str) -> str:
"""Generate JWT for App Store Connect API."""
now = int(time.time())
payload = {
"iss": issuer_id,
"iat": now,
"exp": now + 1200, # 20 minutes
"aud": "appstoreconnect-v1"
}
return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})
def create_webhook(app_id: str, url: str, secret: str):
"""Register webhook with Apple."""
token = generate_token(KEY_ID, ISSUER_ID, PRIVATE_KEY)
response = requests.post(
"https://api.appstoreconnect.apple.com/v1/appWebhooks",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
},
json={
"data": {
"type": "appWebhooks",
"attributes": {
"url": url,
"secret": secret,
"eventTypes": EVENT_TYPES
},
"relationships": {
"app": {
"data": {"type": "apps", "id": app_id}
}
}
}
}
)
return response.json()Testing
Simulate Webhooks Locally
#!/usr/bin/env python3
"""Simulate App Store Connect webhooks for testing."""
import hmac
import hashlib
import json
import requests
EVENTS = {
"build_processing": {
"eventType": "BUILD_UPLOAD_STATE_UPDATED",
"data": {"state": "PROCESSING"}
},
"build_complete": {
"eventType": "BUILD_UPLOAD_STATE_UPDATED",
"data": {"state": "VALID"}
},
"review_submitted": {
"eventType": "APP_STORE_VERSION_APP_VERSION_STATE_UPDATED",
"data": {"state": "WAITING_FOR_REVIEW"}
},
# ... more events
}
def simulate(event_name: str, url: str, secret: str):
"""Send signed webhook to function."""
payload = json.dumps(EVENTS[event_name])
signature = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
response = requests.post(
url,
headers={
"Content-Type": "application/json",
"X-Apple-Signature": f"hmacsha256={signature}"
},
data=payload
)
print(f"Simulated {event_name}: {response.status_code}")Usage
# Simulate build complete
python simulate_webhooks.py --event build_complete
# Simulate all events
python simulate_webhooks.py --allThe Complete Flow

The Auto-Upgrade: Closing the Loop
Notifications are nice. But there was still a gap. Someone from QA was testing on build 225 while the latest was 337. They had no idea they needed to update. Nobody told them. TestFlight doesn’t force updates.
So I connected the webhook to the backend.
The Idea
When a build finishes processing on TestFlight, the Cloud Function already knows the version and build number. What if it also told the backend? And what if the mobile app checked that on every launch?

How It Works
The Cloud Function gained a few lines of code:
APP_BACKEND_CONFIG = {
"6747853441": {"env": "dev", "url": "https://dev.eli-app.com"},
"6747853426": {"env": "stage", "url": "https://staging.eli-app.com"},
# Production is excluded - App Store handles that
}
def update_backend_app_version(app_id, version, build_number):
config = APP_BACKEND_CONFIG.get(app_id)
if not config:
return # Not a dev/staging app, skip
secret = get_secret("app-version-webhook-secret")
requests.post(
f"{config['url']}/app-version/webhook",
headers={"x-api-key": secret},
json={
"version": version,
"buildNumber": build_number,
"platform": "ios",
"env": config["env"]
}
)The backend stores the version as 1.4.17.338 (marketing version + build number) and marks it mandatory: true. The mobile app reads its actual build number at runtime using react-native-device-info (not from package.json, which turns out to be stale by the time the build is installed). On every app launch, it sends its full 4-segment version to the backend and gets back whether an update is required.
The Version Comparison Problem
Semantic versioning is usually 3 segments: 1.4.17. But in dev, the marketing version stays the same across dozens of builds. Build 225 and build 338 are both 1.4.17. The difference is only in the build number.
The fix: store 4-segment versions (1.4.17.338) and compare dynamically:
private compareVersions(latest: string, current: string): boolean {
const latestParts = latest.split('.').map(s => parseInt(s, 10));
const currentParts = current.split('.').map(s => parseInt(s, 10));
const maxLength = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < maxLength; i++) {
const l = latestParts[i] || 0;
const c = currentParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}Missing segments default to 0, so 1.4.17 < 1.4.17.1. This handles mixed formats gracefully.
The Stale Build Number Gotcha
Here’s a fun one I didn’t expect. The mobile app was reading the build number from package.json:
// OLD - wrong
const buildNumber = packageJson.build[Platform.OS]; // "225" foreverFastlane increments CURRENT_PROJECT_VERSION in the Xcode project during CI, but never touches package.json. So the app always thought it was build 225, no matter how many times it was updated.
The fix: read the native bundle version at runtime:
// NEW - correct
import DeviceInfo from 'react-native-device-info';
const buildNumber = DeviceInfo.getBuildNumber(); // "338" from CFBundleVersionThis reads CFBundleVersion directly from the installed binary. No stale values.
The Result
A full round trip, completely automated:
- Developer pushes code
- GitHub Actions builds and uploads to TestFlight
- Apple processes the build
- Apple fires webhook to Cloud Function
- Cloud Function notifies Slack AND updates the backend
- QA opens the app, sees “New version available”, taps update
- TestFlight installs the latest build
No manual database updates. No Slack messages saying “hey, new build is up, please update.” No one testing on a build from three weeks ago. The system enforces currency.
This is particularly powerful for internal dev and staging builds where you want everyone on the same page. Production still goes through the App Store review process, which has its own update mechanisms.
The Philosophy
Release transparency is a feature. The team should know:
- When builds are ready - Not “check TestFlight periodically”
- What’s in each build - Not “dig through git log”
- Where releases are in the pipeline - Not “check App Store Connect”
- When things go wrong - Crashes, rejections, failures
- That they’re on the latest build - Not “are you sure you updated?”
All of this arrives in Slack. The iOS build channel becomes the source of truth for release status. And the auto-upgrade mechanism ensures nobody is testing on stale builds.
The git tag strategy means I can always answer “what changed between build 235 and 236?” without leaving the terminal:
git log build/prod/235..build/prod/236 --onelineAnd when the Slack notification arrives with those commits already listed, the team doesn’t even need to ask.
The full pipeline, from push to forced update on the tester’s device, runs without any human intervention. That’s the dream: developers write code, and the system handles everything else.



