Self-hosted push notifications for iOS, powered by Cloudflare Workers.
Paje is a self-hosted push notification service. Deploy a Cloudflare Worker as your server, build the iOS app with your own Apple Developer account, and send notifications with a single HTTP request.
- Fully self-hosted — your server, your APNs credentials, your data
- Simple HTTP API —
curl -d '{"body":"Hello"}' https://your-server.workers.dev/my-topic - Dashboard — see subscriptions, messages, and delivery stats
- Rich notifications — images, action buttons, click URLs
| Component | Purpose |
|---|---|
| Worker | HTTP routing, auth, serves dashboard, publish API |
| TopicDO | Per-topic Durable Object — stores devices and messages in SQLite, sends APNs push, broadcasts updates over WebSocket |
| iOS App | Subscribe to topics, receive push notifications, view message history |
- Node.js 18+
- Cloudflare account (free plan works)
- Apple Developer account ($99/yr — required for APNs)
- Xcode 15+
git clone https://github.com/jonesphillip/paje.git
cd paje
npm install- Go to Apple Developer > Keys
- Create a new key with Apple Push Notifications service (APNs) enabled
- Download the
.p8file and note the Key ID - Note your Team ID from Membership Details
# Base64-encode your .p8 key (downloaded in step 2)
cat AuthKey_XXXXXXXXXX.p8 | base64 | pbcopy
# Set secrets on Cloudflare
npx wrangler secret put APNS_KEY_P8 # paste the base64 string
npx wrangler secret put APNS_KEY_ID # 10-char ID shown when you created the key (e.g. ABC123DEFG)
npx wrangler secret put APNS_TEAM_ID # found at developer.apple.com > Membership Details (e.g. 9A8B7C6D5E)
npx wrangler secret put APP_BUNDLE_ID # the bundle ID you'll use in Xcode (e.g. com.yourname.paje)
npx wrangler secret put APNS_SANDBOX # set to "true" for dev/TestFlight buildsnpm run deployYour server is live at https://paje.<your-subdomain>.workers.dev.
- Open
ios/Paje/Paje.xcodeprojin Xcode - Update the Bundle Identifier to match
APP_BUNDLE_ID - Select your development team
- Build and run on your device
On first launch, enter your server URL and subscribe to a topic.
Post to any topic your device is subscribed to — the topic is the URL path:
curl -H "Content-Type: application/json" \
-d '{"body":"Hello from Paje!"}' \
https://your-server.workers.dev/my-topicIf you subscribed to my-topic in the iOS app, your phone buzzes.
curl -H "Content-Type: application/json" \
-d '{"body":"Deploy complete"}' \
https://your-server.workers.dev/my-topiccurl -H "Content-Type: application/json" \
-d '{"title":"Build Failed","priority":"high","body":"main branch is broken"}' \
https://your-server.workers.dev/cicurl -H "Content-Type: application/json" \
-d '{
"title": "New Photo",
"url": "https://example.com/photo",
"image": "https://picsum.photos/800/400",
"body": "A new photo was uploaded"
}' \
https://your-server.workers.dev/photoscurl -H "Content-Type: application/json" \
-d '{
"title": "PR #42 Ready",
"body": "All checks passed",
"actions": [
{"label":"View PR","url":"https://github.com/org/repo/pull/42"},
{"label":"Merge","url":"https://github.com/org/repo/pull/42/merge"}
]
}' \
https://your-server.workers.dev/github| Field | Required | Description |
|---|---|---|
body |
Yes | Notification message text |
title |
No | Notification title |
priority |
No | high, default, or low |
url |
No | URL to open when notification is tapped |
image |
No | Image URL for rich notification |
actions |
No | Array of [{"label":"...","url":"..."}] (max 3) |
Include Authorization: Bearer <token> header when PUBLISH_TOKEN is set.
By default, all endpoints are open. To require a token for publishing and subscribing:
npx wrangler secret put PUBLISH_TOKENThen include the token in requests:
curl -H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"body":"Secured message"}' \
https://your-server.workers.dev/my-topicThe token is required for:
- Publishing messages (
POST /:topic) - Subscribing/unsubscribing devices
In the iOS app, set the token in Settings > Access Token.
The web dashboard is enabled by default at your server URL. It shows:
- Overview stats (subscriptions, subscribers, messages)
- Subscription list with message counts
- Message history per subscription
- Send test notifications
To password-protect the dashboard:
npx wrangler secret put DASHBOARD_PASSWORDWhen set, the dashboard shows a login page. API routes are unaffected.
To disable the dashboard entirely, add to wrangler.jsonc:
| Secret | Required | Description |
|---|---|---|
APNS_KEY_P8 |
Yes | Base64-encoded APNs .p8 key |
APNS_KEY_ID |
Yes | APNs key ID |
APNS_TEAM_ID |
Yes | Apple Developer Team ID |
APP_BUNDLE_ID |
Yes | iOS app bundle identifier |
PUBLISH_TOKEN |
No | When set, requires Bearer token for publish/subscribe |
DASHBOARD_PASSWORD |
No | When set, requires password to access the web dashboard |
APNS_SANDBOX |
No | Set to "true" for development/TestFlight builds |
| Variable | Default | Description |
|---|---|---|
DASHBOARD_ENABLED |
"true" |
Set to "false" to disable the web dashboard |
worker/
├── index.ts # HTTP routing, API endpoints
├── TopicDO.ts # Per-subscription Durable Object (devices, messages, WebSocket)
├── RegistryDO.ts # Global registry of subscriptions + WebSocket
├── apns.ts # APNs JWT signing and push delivery
├── auth.ts # Dashboard password authentication
└── env.d.ts # Environment type definitions
src/
├── App.tsx # React dashboard app
├── components/ # Overview, SubscriptionsList, SubscriptionDetail
├── hooks/ # Shared WebSocket hook
└── types.ts # TypeScript types
ios/Paje/Paje/
├── Views/ # SwiftUI views
├── Models/ # SwiftData models
├── Services/ # API client, WebSocket, subscription manager
├── Theme/ # Shared styles and components
└── Extensions/ # Notification service extension (rich notifications)
Apache 2.0




{ "vars": { "DASHBOARD_ENABLED": "false" } }