indie-push-server/SKILL.md
Implement push notifications for indie iOS apps. Covers APNs integration, device token management, notification types, scheduling, and best practices.
npx skillsauth add abanoub-ashraf/manus-skills-import indie-push-serverInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
4 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Implement push notifications for indie iOS apps. Covers APNs integration, device token management, notification types, scheduling, and best practices.
| Method | Pros | Cons | |--------|------|------| | Token-based (p8) | No expiration, one key for all apps | Slightly more complex setup | | Certificate-based (p12) | Simpler initial setup | Expires yearly, per-app certificates |
Recommendation: Use token-based (p8) authentication.
| Environment | Host | Use | |-------------|------|-----| | Development | api.sandbox.push.apple.com | TestFlight, Xcode builds | | Production | api.push.apple.com | App Store builds |
.p8 file (only downloadable once!)APNS_KEY_ID="XXXXXXXXXX" # 10-character key ID
APNS_TEAM_ID="XXXXXXXXXX" # 10-character team ID
APNS_KEY_PATH="./AuthKey_XXX.p8" # Path to your .p8 file
APNS_BUNDLE_ID="com.yourcompany.app"
APNS_ENVIRONMENT="development" # or "production"
npm install apns2
# or for more control:
npm install jsonwebtoken node-fetch
// src/lib/apns.ts
import { ApnsClient, Notification, Priority, PushType } from 'apns2'
import fs from 'fs'
import path from 'path'
const keyPath = path.resolve(process.env.APNS_KEY_PATH!)
const key = fs.readFileSync(keyPath, 'utf8')
const client = new ApnsClient({
team: process.env.APNS_TEAM_ID!,
keyId: process.env.APNS_KEY_ID!,
signingKey: key,
defaultTopic: process.env.APNS_BUNDLE_ID!,
host: process.env.APNS_ENVIRONMENT === 'production'
? 'api.push.apple.com'
: 'api.sandbox.push.apple.com',
})
export interface PushPayload {
title: string
body: string
badge?: number
sound?: string
data?: Record<string, any>
category?: string
threadId?: string
}
export async function sendPush(
deviceToken: string,
payload: PushPayload
): Promise<void> {
const notification = new Notification(deviceToken, {
aps: {
alert: {
title: payload.title,
body: payload.body,
},
badge: payload.badge,
sound: payload.sound || 'default',
category: payload.category,
'thread-id': payload.threadId,
},
...payload.data,
})
notification.pushType = PushType.alert
notification.priority = Priority.immediate
await client.send(notification)
}
export async function sendSilentPush(
deviceToken: string,
data: Record<string, any>
): Promise<void> {
const notification = new Notification(deviceToken, {
aps: {
'content-available': 1,
},
...data,
})
notification.pushType = PushType.background
notification.priority = Priority.throttled
await client.send(notification)
}
export async function sendBatchPush(
deviceTokens: string[],
payload: PushPayload
): Promise<{ success: string[]; failed: string[] }> {
const results = { success: [] as string[], failed: [] as string[] }
const notifications = deviceTokens.map(token => {
const notification = new Notification(token, {
aps: {
alert: {
title: payload.title,
body: payload.body,
},
badge: payload.badge,
sound: payload.sound || 'default',
},
...payload.data,
})
notification.pushType = PushType.alert
notification.priority = Priority.immediate
return notification
})
const responses = await client.sendMany(notifications)
responses.forEach((response, index) => {
if (response.sent) {
results.success.push(deviceTokens[index])
} else {
results.failed.push(deviceTokens[index])
}
})
return results
}
// src/lib/apns-manual.ts
import jwt from 'jsonwebtoken'
import fs from 'fs'
import http2 from 'http2'
const key = fs.readFileSync(process.env.APNS_KEY_PATH!, 'utf8')
let cachedToken: { token: string; expires: number } | null = null
function getAuthToken(): string {
const now = Math.floor(Date.now() / 1000)
// Reuse token if not expired (tokens valid for 1 hour)
if (cachedToken && cachedToken.expires > now) {
return cachedToken.token
}
const token = jwt.sign(
{
iss: process.env.APNS_TEAM_ID,
iat: now,
},
key,
{
algorithm: 'ES256',
header: {
alg: 'ES256',
kid: process.env.APNS_KEY_ID,
},
}
)
cachedToken = {
token,
expires: now + 3500, // Refresh slightly before 1 hour
}
return token
}
export async function sendNotification(
deviceToken: string,
payload: object
): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve, reject) => {
const host = process.env.APNS_ENVIRONMENT === 'production'
? 'api.push.apple.com'
: 'api.sandbox.push.apple.com'
const client = http2.connect(`https://${host}`)
const req = client.request({
':method': 'POST',
':path': `/3/device/${deviceToken}`,
'authorization': `bearer ${getAuthToken()}`,
'apns-topic': process.env.APNS_BUNDLE_ID,
'apns-push-type': 'alert',
'apns-priority': '10',
'content-type': 'application/json',
})
let responseData = ''
req.on('response', (headers) => {
const status = headers[':status']
req.on('data', (chunk) => {
responseData += chunk
})
req.on('end', () => {
client.close()
if (status === 200) {
resolve({ success: true })
} else {
const error = responseData ? JSON.parse(responseData) : {}
resolve({ success: false, error: error.reason })
}
})
})
req.on('error', (error) => {
client.close()
reject(error)
})
req.write(JSON.stringify(payload))
req.end()
})
}
// prisma/schema.prisma
model Device {
id String @id @default(cuid())
userId String
deviceToken String
platform String // ios
environment String // sandbox, production
appVersion String?
osVersion String?
isActive Boolean @default(true)
lastActiveAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, deviceToken])
@@index([userId])
@@index([deviceToken])
}
// src/routes/devices.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { prisma } from '../lib/db'
import { authMiddleware } from '../middleware/auth'
export const deviceRoutes = new Hono()
deviceRoutes.use('*', authMiddleware)
const registerSchema = z.object({
deviceToken: z.string().min(64).max(200),
platform: z.literal('ios'),
environment: z.enum(['sandbox', 'production']),
appVersion: z.string().optional(),
osVersion: z.string().optional(),
})
// Register device token
deviceRoutes.post('/register', zValidator('json', registerSchema), async (c) => {
const userId = c.get('userId')
const data = c.req.valid('json')
// Upsert device
const device = await prisma.device.upsert({
where: {
userId_deviceToken: {
userId,
deviceToken: data.deviceToken,
},
},
create: {
userId,
...data,
},
update: {
...data,
isActive: true,
lastActiveAt: new Date(),
},
})
return c.json({ id: device.id }, 201)
})
// Unregister device token
deviceRoutes.delete('/unregister', zValidator('json', z.object({
deviceToken: z.string(),
})), async (c) => {
const userId = c.get('userId')
const { deviceToken } = c.req.valid('json')
await prisma.device.updateMany({
where: {
userId,
deviceToken,
},
data: {
isActive: false,
},
})
return c.json({ success: true })
})
// Update last active
deviceRoutes.post('/heartbeat', zValidator('json', z.object({
deviceToken: z.string(),
})), async (c) => {
const userId = c.get('userId')
const { deviceToken } = c.req.valid('json')
await prisma.device.updateMany({
where: {
userId,
deviceToken,
},
data: {
lastActiveAt: new Date(),
},
})
return c.json({ success: true })
})
// src/services/push.ts
import { prisma } from '../lib/db'
import { sendPush, sendBatchPush, sendSilentPush, PushPayload } from '../lib/apns'
export interface SendOptions {
userId?: string
userIds?: string[]
allUsers?: boolean
data?: Record<string, any>
}
export async function sendPushToUser(
userId: string,
payload: PushPayload
): Promise<{ sent: number; failed: number }> {
const devices = await prisma.device.findMany({
where: {
userId,
isActive: true,
},
select: {
deviceToken: true,
},
})
if (devices.length === 0) {
return { sent: 0, failed: 0 }
}
const tokens = devices.map(d => d.deviceToken)
const result = await sendBatchPush(tokens, payload)
// Mark failed tokens as inactive
if (result.failed.length > 0) {
await prisma.device.updateMany({
where: {
deviceToken: { in: result.failed },
},
data: {
isActive: false,
},
})
}
return {
sent: result.success.length,
failed: result.failed.length,
}
}
export async function sendPushToUsers(
userIds: string[],
payload: PushPayload
): Promise<{ sent: number; failed: number }> {
const devices = await prisma.device.findMany({
where: {
userId: { in: userIds },
isActive: true,
},
select: {
deviceToken: true,
},
})
if (devices.length === 0) {
return { sent: 0, failed: 0 }
}
const tokens = devices.map(d => d.deviceToken)
const result = await sendBatchPush(tokens, payload)
if (result.failed.length > 0) {
await prisma.device.updateMany({
where: {
deviceToken: { in: result.failed },
},
data: {
isActive: false,
},
})
}
return {
sent: result.success.length,
failed: result.failed.length,
}
}
export async function sendBroadcast(
payload: PushPayload,
batchSize = 500
): Promise<{ sent: number; failed: number }> {
let sent = 0
let failed = 0
let cursor: string | undefined
while (true) {
const devices = await prisma.device.findMany({
where: {
isActive: true,
},
select: {
id: true,
deviceToken: true,
},
take: batchSize,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { id: 'asc' },
})
if (devices.length === 0) break
const tokens = devices.map(d => d.deviceToken)
const result = await sendBatchPush(tokens, payload)
sent += result.success.length
failed += result.failed.length
if (result.failed.length > 0) {
await prisma.device.updateMany({
where: {
deviceToken: { in: result.failed },
},
data: {
isActive: false,
},
})
}
cursor = devices[devices.length - 1].id
if (devices.length < batchSize) break
}
return { sent, failed }
}
export async function sendSilentPushToUser(
userId: string,
data: Record<string, any>
): Promise<void> {
const devices = await prisma.device.findMany({
where: {
userId,
isActive: true,
},
select: {
deviceToken: true,
},
})
await Promise.all(
devices.map(d => sendSilentPush(d.deviceToken, data))
)
}
// Standard alert with title and body
await sendPush(deviceToken, {
title: 'New Message',
body: 'John sent you a message',
badge: 1,
sound: 'default',
data: {
type: 'message',
messageId: '123',
},
})
{
"aps": {
"alert": {
"title": "New Message",
"subtitle": "From John",
"body": "Hey, how are you?"
},
"badge": 1,
"sound": "default",
"category": "MESSAGE_CATEGORY",
"thread-id": "conversation-123",
"mutable-content": 1
},
"messageId": "123",
"senderId": "456"
}
// Silent push for background refresh
await sendSilentPush(deviceToken, {
type: 'sync',
timestamp: Date.now(),
})
Payload:
{
"aps": {
"content-available": 1
},
"type": "sync",
"timestamp": 1234567890
}
// Notification with media attachment
await sendPush(deviceToken, {
title: 'Photo shared',
body: 'Sarah shared a photo with you',
data: {
'mutable-content': 1,
mediaUrl: 'https://example.com/image.jpg',
mediaType: 'image',
},
})
Note: Rich notifications require a Notification Service Extension in your iOS app.
// In iOS app - AppDelegate or notification setup
let likeAction = UNNotificationAction(
identifier: "LIKE_ACTION",
title: "Like",
options: []
)
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY_ACTION",
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Type your reply..."
)
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE_CATEGORY",
actions: [likeAction, replyAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
await sendPush(deviceToken, {
title: 'New Message',
body: 'Hey, how are you?',
category: 'MESSAGE_CATEGORY',
data: {
messageId: '123',
},
})
model ScheduledNotification {
id String @id @default(cuid())
userId String?
userIds String[] // For batch notifications
broadcast Boolean @default(false)
title String
body String
data Json?
category String?
scheduledAt DateTime
sentAt DateTime?
status String @default("pending") // pending, sent, failed, cancelled
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scheduledAt, status])
}
// src/services/scheduler.ts
import { prisma } from '../lib/db'
import { sendPushToUser, sendPushToUsers, sendBroadcast } from './push'
export async function scheduleNotification(options: {
userId?: string
userIds?: string[]
broadcast?: boolean
title: string
body: string
data?: Record<string, any>
category?: string
scheduledAt: Date
}): Promise<string> {
const notification = await prisma.scheduledNotification.create({
data: {
userId: options.userId,
userIds: options.userIds || [],
broadcast: options.broadcast || false,
title: options.title,
body: options.body,
data: options.data,
category: options.category,
scheduledAt: options.scheduledAt,
},
})
return notification.id
}
export async function processScheduledNotifications(): Promise<void> {
const notifications = await prisma.scheduledNotification.findMany({
where: {
status: 'pending',
scheduledAt: { lte: new Date() },
},
take: 100,
})
for (const notification of notifications) {
try {
const payload = {
title: notification.title,
body: notification.body,
data: notification.data as Record<string, any> | undefined,
category: notification.category || undefined,
}
if (notification.broadcast) {
await sendBroadcast(payload)
} else if (notification.userIds.length > 0) {
await sendPushToUsers(notification.userIds, payload)
} else if (notification.userId) {
await sendPushToUser(notification.userId, payload)
}
await prisma.scheduledNotification.update({
where: { id: notification.id },
data: {
status: 'sent',
sentAt: new Date(),
},
})
} catch (error) {
console.error(`Failed to send notification ${notification.id}:`, error)
await prisma.scheduledNotification.update({
where: { id: notification.id },
data: { status: 'failed' },
})
}
}
}
export async function cancelScheduledNotification(id: string): Promise<void> {
await prisma.scheduledNotification.update({
where: { id },
data: { status: 'cancelled' },
})
}
// src/jobs/notifications.ts
import cron from 'node-cron'
import { processScheduledNotifications } from '../services/scheduler'
// Run every minute
cron.schedule('* * * * *', async () => {
try {
await processScheduledNotifications()
} catch (error) {
console.error('Notification job failed:', error)
}
})
// src/routes/admin/notifications.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { sendPushToUser, sendPushToUsers, sendBroadcast } from '../../services/push'
import { scheduleNotification } from '../../services/scheduler'
import { adminMiddleware } from '../../middleware/admin'
export const notificationRoutes = new Hono()
notificationRoutes.use('*', adminMiddleware)
const sendSchema = z.object({
userId: z.string().optional(),
userIds: z.array(z.string()).optional(),
broadcast: z.boolean().optional(),
title: z.string(),
body: z.string(),
data: z.record(z.any()).optional(),
category: z.string().optional(),
scheduledAt: z.string().datetime().optional(),
})
// Send notification
notificationRoutes.post('/send', zValidator('json', sendSchema), async (c) => {
const data = c.req.valid('json')
// Scheduled notification
if (data.scheduledAt) {
const id = await scheduleNotification({
...data,
scheduledAt: new Date(data.scheduledAt),
})
return c.json({ scheduled: true, id })
}
// Immediate notification
const payload = {
title: data.title,
body: data.body,
data: data.data,
category: data.category,
}
let result: { sent: number; failed: number }
if (data.broadcast) {
result = await sendBroadcast(payload)
} else if (data.userIds && data.userIds.length > 0) {
result = await sendPushToUsers(data.userIds, payload)
} else if (data.userId) {
result = await sendPushToUser(data.userId, payload)
} else {
return c.json({ error: 'Must specify userId, userIds, or broadcast' }, 400)
}
return c.json(result)
})
// Get notification stats
notificationRoutes.get('/stats', async (c) => {
const [totalDevices, activeDevices, scheduledCount] = await Promise.all([
prisma.device.count(),
prisma.device.count({ where: { isActive: true } }),
prisma.scheduledNotification.count({ where: { status: 'pending' } }),
])
return c.json({
totalDevices,
activeDevices,
scheduledNotifications: scheduledCount,
})
})
| Code | Meaning | Action |
|------|---------|--------|
| BadDeviceToken | Invalid token format | Remove token |
| Unregistered | App uninstalled | Mark inactive |
| DeviceTokenNotForTopic | Wrong bundle ID | Check configuration |
| PayloadTooLarge | > 4KB payload | Reduce payload size |
| TooManyRequests | Rate limited | Implement backoff |
| InternalServerError | APNs issue | Retry later |
| ServiceUnavailable | APNs down | Retry later |
export async function sendPushWithRetry(
deviceToken: string,
payload: PushPayload,
maxRetries = 3
): Promise<{ success: boolean; error?: string }> {
let lastError: string | undefined
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await sendPush(deviceToken, payload)
return { success: true }
} catch (error: any) {
lastError = error.reason || error.message
// Don't retry for permanent failures
const permanentErrors = [
'BadDeviceToken',
'Unregistered',
'DeviceTokenNotForTopic',
'PayloadTooLarge',
]
if (permanentErrors.includes(lastError)) {
// Mark device as inactive
await prisma.device.updateMany({
where: { deviceToken },
data: { isActive: false },
})
break
}
// Exponential backoff for transient errors
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
)
}
}
return { success: false, error: lastError }
}
api.sandbox.push.apple.com// src/routes/test.ts (remove in production)
import { Hono } from 'hono'
import { sendPush, sendSilentPush } from '../lib/apns'
export const testRoutes = new Hono()
testRoutes.post('/push', async (c) => {
const { deviceToken, title, body } = await c.req.json()
try {
await sendPush(deviceToken, { title, body })
return c.json({ success: true })
} catch (error: any) {
return c.json({ error: error.message }, 500)
}
})
testRoutes.post('/silent', async (c) => {
const { deviceToken, data } = await c.req.json()
try {
await sendSilentPush(deviceToken, data)
return c.json({ success: true })
} catch (error: any) {
return c.json({ error: error.message }, 500)
}
})
✅ Store device tokens per device, not per user
✅ Handle token refresh (tokens can change)
✅ Use silent pushes for data sync
✅ Batch notifications (500-1000 at a time)
✅ Implement exponential backoff for retries
✅ Mark failed tokens as inactive
✅ Use thread-id for grouped notifications
✅ Test with both sandbox and production
❌ Don't send too many notifications (user will disable)
❌ Don't store .p8 key in git
❌ Don't ignore APNs error responses
❌ Don't send large payloads (max 4KB)
❌ Don't assume token is permanent
❌ Don't send at bad times (late night)
# APNs Configuration
APNS_KEY_ID="XXXXXXXXXX"
APNS_TEAM_ID="XXXXXXXXXX"
APNS_KEY_PATH="./keys/AuthKey_XXX.p8"
APNS_BUNDLE_ID="com.yourcompany.app"
APNS_ENVIRONMENT="development" # or "production"
□ .p8 key file securely stored (not in git)
□ Environment variables set for production
□ Using production APNs endpoint for App Store builds
□ Batch size appropriate for your scale
□ Error handling and retry logic implemented
□ Inactive tokens being cleaned up
□ Monitoring/alerting for notification failures
□ Rate limiting on send endpoints
development
Design principles for building polished, native-feeling SwiftUI apps and widgets. Use this skill when creating or modifying SwiftUI views, iOS widgets (WidgetKit), or any native Apple UI. Ensures proper spacing, typography, colors, and widget implementations that look and feel like quality apps rather than AI-generated slop.
data-ai
Design and implement SwiftUI views, components, and app architecture. Use when creating new SwiftUI views, implementing MVVM/TCA patterns, managing state with @Observable, @State, @Binding, or @Environment, designing navigation flows, or structuring iOS app architecture. Triggers on SwiftUI, view model, state management, navigation, coordinator pattern.
development
Implement, review, or improve SwiftUI animations and transitions. Use when adding implicit or explicit animations with withAnimation, configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (bounce, pulse, variableColor, breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion.
testing
Audit SwiftUI views for accessibility (iOS + macOS) with patch-ready fixes