This documentation guides you through integrating CZL Connect into your app.
Have a question? Leave a message on the forumPush
Turns Web Push (RFC 8030) sent by servers, and messages from your app's backend, into iOS system notifications (APNs). It suits servers that can only send Web Push (such as a mail server's JMAP push subscriptions), as well as your own backend pushing notifications to specific users.
Content is end-to-end encrypted: the sender encrypts with the device's public key per RFC 8291 (aes128gcm). CZL Connect only handles the ciphertext, which it places unchanged in the notification's custom key c for the app's notification extension to decrypt and display. CZL Connect cannot see notification content.
Use cases
- A mail, calendar or other server that only supports Web Push needs to notify an iOS app about new items
- Your backend pushes notifications per user (order status, reminders) to every device that user has
- Several apps share one Apple push key without each running its own push service
Requirements
Push is available based on the app owner's plan. The quota is shared across all apps in the account and counts notifications successfully handed to the push platform each month (messages stashed while pending and failed sends are not counted):
| Plan | Pushes per month |
|---|---|
| Lite | Not supported |
| Plus | 50,000 |
| Pro | 500,000 |
| Enterprise | Unlimited |
Once the quota is used up, Web Push delivery returns 429 (1009) and backend sends return quota_exceeded per message. Push URLs stay valid and resume next month or after an upgrade.
Set up a push credential
Push credentials belong to your account, and one can be shared by several of your apps:
- Apple Developer → Certificates, Identifiers & Profiles → Keys: create a key with Apple Push Notifications service (APNs) enabled, download the
.p8file (it can only be downloaded once) and note the Key ID - Your Team ID is on the account's Membership page
- CZL Connect → Account settings → Integrations → Push credentials → Add credential: enter the Key ID and Team ID and paste the full
.p8contents
Before saving, the credential is tested against the push platform; a rejected credential can't be saved. The key is stored encrypted and never shown again. One .p8 can push to every app in the same developer team, in both sandbox and production. If the credential is later revoked, sends fail with reason credential_rejected, and both the credential card and the app's Push section show a warning.
Enable push for an app
Open the app → Push (visible to the app owner only):
| Setting | Description |
|---|---|
| Enable push | When off, device registration and pushes are rejected; registered devices are kept |
| Push credential | Pick one of your account's push credentials |
| App identifiers | iOS Bundle IDs, one per line. Unique site-wide, and verified on save to belong to the selected credential's team; removing an identifier also deletes its device subscriptions |
Saving requires passkey confirmation and is recorded in the activity log.
Device endpoints
Base URL: https://connect.czl.net/api/push (use the "Endpoint" shown in the app's Push section). The secret returned at registration is shown only once; afterwards call with Authorization: Bearer <secret>.
| Method | Path | Description |
|---|---|---|
| POST | /v1/subscriptions | Register a device → 201 {id, secret, url} |
| PUT | /v1/subscriptions/{id} | Update the device token / placeholder text and refresh the last-seen time → 204 |
| GET | /v1/subscriptions/{id}/pending | Fetch ciphertext stashed while pending → {messages: [base64url…]} |
| POST | /v1/subscriptions/{id}/activate | Activate and clear the stash → 204 |
| DELETE | /v1/subscriptions/{id} | Delete → 204 |
Register / update request body:
{
"topic": "com.example.app",
"token": "device token (hex)",
"sandbox": false,
"alert": { "title": "Example", "body": "New message" },
"keys": { "p256dh": "device public key (base64url)", "auth": "auth secret (base64url)" }
}
topicmust be an app identifier registered in an app with push enabled, otherwise1008 unknown app; it can't be changed on updatesandbox:truefor development builds (installed from Xcode),falsefor TestFlight and App Store buildsalert: placeholder text shown when the notification extension can't decrypt the content; the body defaults toNew notificationkeys: optional, only needed when your backend will push per user; the backend encrypts with it- Subscriptions not refreshed for 60 days are removed automatically; we recommend a PUT on every app launch. A
404on PUT means the subscription no longer exists; just register again
url is this device's Web Push URL; give it to the server as the push endpoint.
Pending and verification
A newly registered subscription is "pending": messages pushed meanwhile are only stashed (up to 5 per subscription, for 30 minutes) and no notification is shown. This is for server-side subscription verification, e.g. JMAP sends an encrypted PushVerification right after a PushSubscription is created:
- The app, in the foreground, registers the device and gives
urlto the server to create a push subscription - It polls
GET /pending, decrypts the verification code locally and sends it back to the server - It calls
POST /activate; only later pushes become system notifications
If no verification is needed, call activate right after registering.
Server-side push subscriptions usually expire; for example, Stalwart's JMAP PushSubscription expires after 7 days by default. The app should renew it with the server when it returns to the foreground and periodically (e.g. every 12 hours) by updating expires, and PUT this service's subscription at the same time to refresh its last-seen time. Renewing doesn't change the push URL, so there's no need to register or verify again; only rebuild when the device keys change (app reinstalled) or this service returns 404.
Web Push delivery
POST {url} (i.e. /api/push/p/{id}) with an RFC 8291 ciphertext body of at most 8 KB. Urgency: low / very-low use power-saving priority, so the system may batch them.
| Status | Meaning |
|---|---|
201 | Accepted (stashed while pending) |
404 | Subscription not found; the server should stop using this URL |
410 | The device token is no longer valid (app uninstalled, etc.) and the subscription was deleted; the server should stop using this URL |
413 | Message too large |
429 | Monthly quota used up (1009) or too many requests (1006); retry later |
403 | Push is disabled for the app (1010); the URL works again once re-enabled |
502 | Delivery failed (1007), e.g. no usable credential or the push platform is temporarily unavailable |
Push to users (App API)
Your backend calls these with an App API key that has the push:send scope:
1. Link a device to a user: the app passes its subscription id and secret to your backend through your own signed-in channel, and the backend calls
POST /api/v1/app/push/subscriptions/bind
{ "subscriptionId": "...", "secret": "...", "userId": "123" }
userId is the user's CZL Connect user ID, and the user must have authorized your app. When the user signs out in the app, call DELETE /api/v1/app/push/subscriptions/{id}/bind; the subscription stays as an anonymous one.
2. List a user's devices: GET /api/v1/app/push/users/{userId}/devices → {devices: [{id, platform, sandbox, keys: {p256dh, auth}, createdAt, lastPushAt}]}, active devices only.
3. Encrypt per device and send in a batch:
POST /api/v1/app/push/send
{ "messages": [ { "subscriptionId": "...", "payload": "ciphertext (base64url)", "urgency": "high" } ] }
Up to 100 messages per request, each returning {subscriptionId, status, reason}:
| status | Meaning |
|---|---|
sent | Handed to the push platform |
gone | The device token is no longer valid and the subscription was deleted; stop sending to it |
failed | Send failed; reason is credential_missing / credential_rejected / provider_error |
skipped | The user unsubscribed from this app's notifications, is banned, or is disabled in this app |
inactive | The subscription isn't activated yet |
not_found | The subscription doesn't exist or doesn't belong to this app |
quota_exceeded | Monthly quota used up |
invalid | payload isn't valid base64url or exceeds 8 KB |
Unsubscribing applies per app ("this app's notifications") and is shared with broadcast email; anonymous subscriptions (not linked to a user) are unaffected.
App requirements
- After getting notification permission, obtain the device token and set
sandboxfor development vs. release builds - Generate a P-256 key pair and a 16-byte auth secret; keep the private key in a keychain shared by the main app and the notification extension (App Group), and give the public key and auth to the server (Web Push subscription) or submit them at registration (
keys) - Add a Notification Service Extension: notifications carry
mutable-content: 1; the extension reads the custom keyc(base64url ciphertext), decrypts it per RFC 8291 and rewrites the title and body. If it can't decrypt, or the ciphertext was dropped for exceeding about 4 KB, the system shows the registered placeholder text
Rate limits
| Endpoint | Keyed by | Limit |
|---|---|---|
| Device registration | IP | 20 / hour |
| Other device endpoints | IP | 120 / minute |
| Web Push delivery | Subscription | 120 / minute |
| App API | App | Shared with other App API endpoints |
Exceeding a limit returns 429 with code 1006.
Error codes
Device endpoints and Web Push delivery return {"error": "<4-digit code> <English message>"}:
| Code | Meaning |
|---|---|
| 1001 | Invalid parameters (e.g. malformed device token) |
| 1002 | Missing credentials (no Authorization) |
| 1003 | Subscription not found / expired |
| 1004 | Message too large |
| 1005 | Storage error |
| 1006 | Too many requests |
| 1007 | Delivery failed |
| 1008 | Unknown app |
| 1009 | Monthly push quota used up |
| 1010 | Push is disabled for this app |
Data and privacy
- A subscription stores the device token, the SHA-256 of its
secretand, optionally, the device public key; notification content is never stored, and ciphertext stashed while pending is discarded after 30 minutes - Only daily send counts are recorded, not individual sends
- Subscriptions are hard-deleted when the token becomes invalid, the subscription or app is deleted, or it hasn't been refreshed for 60 days