Why build an alert system?

I recently added an in-app purchase subscription to Retinelle. Once the subscription was live, I wanted real-time alerts whenever something happens — a new subscriber, a renewal, a cancellation, a refund. Apple’s App Store Server Notifications V2 push these events to a URL you control, but I didn’t want to constantly check a dashboard.

Here’s how I built a tiny server that receives Apple’s notifications and forwards them as text messages.

Tech stack

I wanted to keep things “simple”. A plain serverless function (like an AWS Lambda or Scaleway Function) would have been the simplest option. But I’m planning to build more small tools like this, so I set up a monorepo from the start. That led me to create a Bun monorepo, with its first app being a serverless container.

  • TypeScript — because I have been using TypeScript quite a lot lately
  • Bun — both as package manager and runtime. No need for a separate bundler or transpiler, and Bun.serve() replaces Express
  • Scaleway Serverless Containers — scales to zero, so I only pay when Apple sends a notification. The free tier covers my usage entirely. And since I worked there, I know the team is solid

The entire project has zero npm dependencies — just Bun’s runtime and TypeScript types as dev dependencies.

Architecture

Architecture diagram showing App Store Connect sending notifications to Bun.serve, which forwards an SMS through Free Mobile.
High-level flow from App Store Connect to the SMS alert.
  • Apple sends a signed JWS payload to our endpoint
  • Bun.serve() decodes it, formats a human-readable message, and calls the SMS API
  • Free Mobile delivers the SMS (free API for their subscribers in France). I might switch to another messaging app at some point, but here it was straightforward
  • Scaleway Serverless Containers hosts the whole thing, scaling to zero when idle

Decoding Apple’s JWS payloads

Apple wraps everything in JWS (JSON Web Signature) tokens. A JWS is three base64url-encoded segments separated by dots: header.payload.signature. We only need the payload (middle segment).

For a server-to-server notification endpoint, signature verification is optional — the connection already comes over HTTPS from Apple’s servers. This keeps our code minimal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/decode-jws.ts
export function decodeJwsPayload<T>({ jws }: { jws: string }): T {
  const parts = jws.split('.')
  if (parts.length !== 3) {
    throw new Error(`Invalid JWS: expected 3 segments, got ${parts.length}`)
  }

  const payload = parts[1] as string
  // Base64url → Base64, then pad to a multiple of 4
  const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
  const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')

  let json: string
  try {
    json = atob(padded)
  } catch {
    throw new Error('Invalid JWS: payload is not valid base64')
  }

  try {
    return JSON.parse(json) as T
  } catch {
    throw new Error('Invalid JWS: payload is not valid JSON')
  }
}

The key gotcha here is base64url vs base64. Apple uses base64url encoding (RFC 4648 §5), which replaces + with - and / with _, and omits padding =. The atob() function expects standard base64, so we need to convert back and add padding before decoding.

Typing the notifications

Apple’s V2 notification spec is pretty large, and Apple documents the full set of notification types. For an SMS alert service, we only need a small subset:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/types.ts
export interface SignedNotification {
  signedPayload: string
}

export interface NotificationPayload {
  notificationType: string
  subtype?: string
  data?: NotificationData
  version: string
  signedDate: number
  notificationUUID: string
}

export interface NotificationData {
  appAppleId?: number
  bundleId?: string
  bundleVersion?: string
  environment: 'Sandbox' | 'Production'
  signedTransactionInfo?: string
  signedRenewalInfo?: string
}

export interface TransactionInfo {
  productId: string
  expiresDate?: number
  originalTransactionId: string
  transactionId: string
  type: string
}

Full file: Download “types.ts”.

The nested JWS pattern is the main thing worth noting: the top-level payload contains signedTransactionInfo, which is itself another JWS that you decode separately to get the transaction details.

Formatting human-readable SMS

An SMS has 160 characters. Not that we can’t send multiple for free, but let’s take that into consideration (we need to be concise). The formatter maps Apple’s SCREAMING_SNAKE_CASE types to readable labels and extracts the key info:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// src/format-sms.ts
const labels: Record<NotificationType, string> = {
  CONSUMPTION_REQUEST: 'Consumption Request',
  DID_CHANGE_RENEWAL_PREF: 'Changed Renewal Pref',
  DID_CHANGE_RENEWAL_STATUS: 'Changed Renewal Status',
  DID_FAIL_TO_RENEW: 'Failed to Renew',
  DID_RENEW: 'Renewed',
  EXPIRED: 'Expired',
  EXTERNAL_PURCHASE_TOKEN: 'External Purchase',
  GRACE_PERIOD_EXPIRED: 'Grace Period Expired',
  OFFER_REDEEMED: 'Offer Redeemed',
  PRICE_INCREASE: 'Price Increase',
  REFUND: 'Refund',
  REFUND_DECLINED: 'Refund Declined',
  REFUND_REVERSED: 'Refund Reversed',
  RENEWAL_EXTENDED: 'Renewal Extended',
  RENEWAL_EXTENSION: 'Renewal Extension',
  REVOKE: 'Revoked',
  SUBSCRIBED: 'Subscribed',
  TEST: 'Test',
}

export function formatSms({ payload }: { payload: NotificationPayload }): string {
  const label = labels[payload.notificationType] ?? payload.notificationType
  const subtitlePart = payload.subtype
    ? ` (${payload.subtype
        .split('_')
        .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
        .join(' ')})`
    : ''

  const lines: string[] = [`[Retinelle] ${label}${subtitlePart}`]

  if (payload.data?.signedTransactionInfo) {
    const tx = decodeJwsPayload<TransactionInfo>({
      jws: payload.data.signedTransactionInfo,
    })
    lines.push(`Product: ${tx.productId}`)
    if (tx.expiresDate) {
      const date = new Date(tx.expiresDate).toISOString().split('T')[0]
      lines.push(`Expires: ${date}`)
    }
  }

  if (payload.data?.environment) {
    lines.push(`Env: ${payload.data.environment}`)
  }

  return lines.join('\n')
}

A typical SMS looks like:

1
2
3
4
[Retinelle] Subscribed (Initial Buy)
Product: plus.monthly
Expires: 2026-04-15
Env: Production

Sending SMS via Free Mobile

Free Mobile is a French carrier that offers a free SMS API to its subscribers. One GET request, no SDK needed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/send-sms.ts
export async function sendSms({ message }: { message: string }): Promise<void> {
  const user = Bun.env.FREE_MOBILE_USER
  const pass = Bun.env.FREE_MOBILE_PASS

  if (!user || !pass) {
    throw new Error('FREE_MOBILE_USER and FREE_MOBILE_PASS must be set')
  }

  const url = `https://smsapi.free-mobile.fr/sendmsg?user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}&msg=${encodeURIComponent(message)}`

  const res = await fetch(url)

  if (!res.ok) {
    const errors: Record<number, string> = {
      400: 'Missing or invalid parameter',
      402: 'Too many SMS sent, rate limited',
      403: 'Invalid credentials',
      500: 'Free Mobile server error',
    }
    const detail = errors[res.status] ?? `HTTP ${res.status}`
    throw new Error(`SMS API error: ${detail}`)
  }
}

You activate the API in your Free Mobile subscriber dashboard under “My Options” → “Notifications by SMS”. You get a user ID (warning: it might be different from the user ID you use to connect!) and an API key.

The server

The server itself is minimal. The critical design decision: always return HTTP 200, even if processing fails.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/index.ts
import { decodeJwsPayload } from './decode-jws.ts'
import { formatSms } from './format-sms.ts'
import { sendSms } from './send-sms.ts'
import type { NotificationPayload, SignedNotification } from './types.ts'

const requiredEnv = ['FREE_MOBILE_USER', 'FREE_MOBILE_PASS'] as const
for (const key of requiredEnv) {
  if (!Bun.env[key]) {
    console.error(`Missing required env var: ${key}`)
    process.exit(1)
  }
}

const port = Number(Bun.env.PORT) || 8080

Bun.serve({
  port,
  async fetch(req) {
    const url = new URL(req.url)

    if (req.method === 'POST' && url.pathname === '/notifications') {
      try {
        const body = (await req.json()) as SignedNotification
        const payload = decodeJwsPayload<NotificationPayload>({
          jws: body.signedPayload,
        })
        const message = formatSms({ payload })
        await sendSms({ message })
        console.log(`SMS sent: ${payload.notificationType}`)
      } catch (err) {
        console.error('Error processing notification:', err)
      }
      return new Response('ok', { status: 200 })
    }

    if (req.method === 'GET' && url.pathname === '/health') {
      return new Response('ok')
    }

    return new Response('Not Found', { status: 404 })
  },
})

console.log(`Server listening on port ${port}`)

Why always 200? Because if you return an error, Apple will retry the notification — potentially dozens of times. If your SMS API is temporarily down, you’d get a flood of duplicate messages once it recovers. Better to acknowledge receipt and log the error.

Here is the Dockerfile:

1
2
3
4
5
6
7
8
# Simple single-stage build -- no dependencies to install, no build step.
FROM oven/bun:1.3.10
WORKDIR /usr/src/app
COPY package.json ./
COPY src/ ./src/
USER bun
EXPOSE 8080/tcp
ENTRYPOINT ["bun", "src/index.ts"]

It stays intentionally minimal: no bun install, no multi-stage build, and no compilation step. It just copies the sources and runs Bun directly.

CI/CD with GitHub Actions

The GitHub Actions workflow is short enough to summarize:

  1. Check out the repo.
  2. Build and push the Docker image to Scaleway Container Registry.
  3. Update the Serverless Container to the new image, then deploy it.

Full file: Download “deploy-app-store-notifications.yml”.

The workflow triggers on pushes to main that touch files under apps/app-store-notifications/, or manually via workflow_dispatch. On the container side, I kept the resources tiny: 128 MB RAM, 100 mcpu, min-scale=0, max-scale=1.

Environment variables and secrets

There are two different buckets of configuration here, and mixing them up is the easiest way to lose time:

  1. Deploy-time credentials in GitHub.
  2. Runtime secrets in Scaleway.

In GitHub, I would store:

  • secrets.SCW_ACCESS_KEY and secrets.SCW_SECRET_KEY for the API keys.
  • vars.SCW_PROJECT_ID, vars.SCW_ORGANIZATION_ID, vars.SCW_CONTAINER_ID, and vars.SCW_REGISTRY_NAMESPACE for the non-secret identifiers.

In Scaleway Serverless Containers, I would store:

  • FREE_MOBILE_USER.
  • FREE_MOBILE_PASS.
  • PORT optionally, if you don’t want the default 8080.

The important rule is simple: deployment credentials live in GitHub, runtime secrets live with the running service. For local development, you can export the same values in your shell or load them from a local .env file. Just don’t commit that file.

Configuring App Store Connect

  1. Go to App Store Connect → your app → App Information
  2. Scroll to App Store Server Notifications
  3. Set the Production Server URL to your endpoint: https://your-container-url.scw.cloud/notifications
  4. Optionally configure a Sandbox URL for testing

You can test your setup by using Apple’s App Store Server API to request a test notification programmatically.

Production hardening

Before using this in production, there are still two important hardening steps:

  1. Verify Apple’s JWS signature before sending the SMS. Right now this article only shows how to decode the payload, which is fine for a prototype but not enough for a public endpoint. The proper way is to validate the JWS signature and certificate chain against Apple’s requirements before trusting signedPayload.
  2. Deduplicate notifications by notificationUUID. App Store Server Notifications are delivered at least once, so retries can happen. A simple approach would be to store each processed notificationUUID in a local Redis instance with a TTL, and skip the SMS if the key already exists. Bun works well here since talking to Redis is straightforward.