Quick Start
Get Kiskis running in your iOS app in under 60 seconds.
1. Create an Account
Sign up at the Kiskis Dashboard and note your provisioning credential (kk_prod_...).
2. Upload Your Config
curl -X POST https://api.kiskis.dev/v1/admin/config/upload \
-H "Authorization: Bearer kk_prod_YOUR_AUTH" \
-H "Content-Type: application/json" \
-d '{"key":"default","version":"*","config":{"api_keys":{"stripe":"sk_live_..."}}}'
3. Add the Swift Package
In Xcode: File → Add Package Dependencies → enter:
https://github.com/kiskisdev/client
4. Fetch Config
import Kiskis
let kiskis = KiskisClient(
teamId: "YOUR_TEAM_ID",
key: "default"
)
let config = try await kiskis.fetchConfig()
let stripeKey = config.string("api_keys.stripe")
That's it. The SDK handles App Attest, JWT tokens, Keychain caching, and background refresh automatically.
Core Concepts
How Kiskis Works
Kiskis solves the "bootstrap problem" — how does an app get API keys without embedding them in the binary?
- Developer uploads one or more named config documents (API keys, endpoints, feature flags) to the Kiskis vault via dashboard, CLI, or CI/CD.
- App launches and the SDK performs hardware attestation via Apple App Attest (Secure Enclave).
- Server verifies the attestation, confirming it's a genuine, unmodified app on a real device.
- Server returns the config document identified by (
key, version).
- SDK caches the config in the iOS Keychain, encrypted and hardware-bound.
Config Keys (Named Documents)
Each app can hold many named config documents, each identified by a developer-chosen key. A KiskisClient instance is bound to one key. Common patterns:
default — base runtime config (API keys, endpoints). This is the default if you don't pass a key.
flags — feature flags (read via isEnabled(...)).
promos — promotional copy, pricing experiments.
region_us, region_eu — geo-specific documents chosen at startup.
Each key has its own version history, kill switch, and canary deployments. Requesting a key that doesn't exist returns 404 — there is no cross-key fallback.
Within a single key, version still does semver-pattern matching (see Version Targeting).
Use Case: Mobile Game Live Ops
Indie iOS game devs typically cobble together Firebase Remote Config plus a homegrown server for live ops. Kiskis replaces the whole stack with one product: tune economy, schedule events, nerf broken weapons, and push silent refreshes — all without a TestFlight build.
The pattern: one document per operational lane, each edited independently by the right role.
// key="default" — stable settings, rarely edited
{ "endpoints": { "leaderboard": "..." }, "max_level": 100 }
// key="economy" — tuned daily by a game designer
{ "coin_drop_rate": 1.2, "gem_shop": { "small": 0.99, "big": 9.99 } }
// key="events" — scheduled by the marketing team
{ "halloween_active": true, "double_xp_weekend": false,
"event_end_date": "2026-11-01T00:00:00Z" }
// key="difficulty" — patched based on player telemetry
{ "enemy_hp_multiplier": 0.9, "boss_dps": 100 }
In Swift, each lane is its own client:
let economy = KiskisClient(teamId: TEAM, key: "economy")
let events = KiskisClient(teamId: TEAM, key: "events")
let difficulty = KiskisClient(teamId: TEAM, key: "difficulty")
try await economy.fetchConfig()
let dropRate = economy.currentConfig()?.double("coin_drop_rate") ?? 1.0
if events.isEnabled("halloween_active") { applyHalloweenSkin() }
When a balance change goes wrong, kill the difficulty key's bad version without touching economy or events. When Halloween launches, a silent push (push docs) wakes every player's app and refetches only the events doc — not the whole config.
Use Case: Pro Tier / License Delivery
Every indie iOS app wants to sell a Pro tier. Tools like RevenueCat handle the purchase validation, but you still need to deliver the premium content to the paying customer — and only them. Kiskis is the last mile: your webhook tells us who paid, we deliver Pro content to that user's attested devices.
The flow:
- User taps "Upgrade to Pro" in your app → Apple IAP / Stripe / RevenueCat processes the payment
- Your webhook handler receives the purchase event and calls Kiskis:
// In your webhook handler (Node.js, Lambda, Cloudflare Worker, whatever)
await fetch('https://api.kiskis.dev/admin/users/' + userId + '/data', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + process.env.KISKIS_AUTH },
body: JSON.stringify({
entitlements: ['pro', 'cloud_sync', 'advanced_export'],
tier: 'pro'
})
});
Or from the CLI (useful for testing, comping early customers, or one-off grants):
kiskis user:set --user-id "alice_icloud_id" \
--data '{"entitlements":["pro","cloud_sync"],"tier":"pro"}'
- Your iOS app reads the data at launch:
let userData = try await kiskis.loadUserData(userId: currentUserId)
let entitlements = userData?["entitlements"] as? [String] ?? []
if entitlements.contains("pro") {
unlockProFeatures()
}
Why the attestation matters. An iOS app that hardcodes a boolean isPro check can be defeated by any reverse engineer — flip the bit and "become" Pro for free. With Kiskis, the Pro content only exists on devices that (a) the webhook has granted access to AND (b) pass App Attest (genuine, non-jailbroken iPhone). The content is physically unreachable otherwise.
This is especially valuable when Pro unlocks something that costs you money per user — a premium AI API key, an unlocked LLM model, a paid backend endpoint. Put that in the user's Kiskis payload, not in your binary. Free users trying to extract it find nothing; Pro users get it only on their real devices.
Need to revoke (refund, chargeback, subscription cancellation)? Your webhook calls DELETE on the same endpoint; a silent push (push docs) forces every device to refetch within seconds.
Use Case: Device Tenure & Trial Tracking
Kiskis already stamps the moment each device first attests and updates that device's "last seen" timestamp on every request. The GET /device/info endpoint surfaces both so you can build trial logic, tenure rewards, or welcome-back flows without running your own backend to track install timestamps.
Kiskis intentionally stays out of trial policy — you get the timestamps, you decide what they mean. Different plans can have different trial lengths, promo codes can grant extensions, whatever logic fits your app.
let info = try await kiskis.deviceInfo()
// info.firstSeen — when this device first attested (Date)
// info.lastSeen — most recent assertion (Date)
// Plan-dependent trial:
let trialDays = user.plan == .pro ? 30 : 7
let trialEndsAt = info.firstSeen.addingTimeInterval(Double(trialDays) * 86400)
if Date() > trialEndsAt && !user.hasPaid {
showPaywall()
}
// Or a welcome-back banner:
let daysSinceLastSeen = Calendar.current.dateComponents(
[.day], from: info.lastSeen, to: Date()
).day ?? 0
if daysSinceLastSeen > 7 {
showWelcomeBackBanner()
}
Honest Caveat
firstSeen is anchored to this App Attest install. If the user uninstalls and reinstalls, Apple issues a new key and firstSeen resets — this is Apple's privacy design and we can't work around it. For stronger anti-abuse (e.g., one trial per Apple ID forever), pair with StoreKit's Introductory Offer limits. For most apps, the device-scoped timestamp is enough friction to curb casual trial resets.
The API call itself is cheap — it's one lookup in our device registry. Cache the result for the session; no need to call it on every screen.
Use Case: Anti-Piracy
This is the defensive side of License Delivery. You're not losing sales to pirates you can see — you're losing sales to pirates you can't. On iOS, the attack chain looks like this:
- Someone with a jailbroken iPhone downloads your paid app, strips Apple's FairPlay DRM with a tool like
Clutch or frida-ios-dump.
- They share the decrypted IPA on repositories like iOSGods, AppValley, or TrollStore feeds.
- Others install the cracked copy on non-jailbroken devices via AltStore / Scarlet-style signing services. Or modify it first — flip
isPro = true, remove ads, unlock all characters.
Most indies never see this happening because they have no telemetry for it. Mobile game studios estimate piracy costs 5–15% of revenue; apps with IAP unlocks can be significantly higher.
What Kiskis Blocks (Because of How It's Built)
| Attack | Why Kiskis blocks it |
| Cracked IPA on jailbroken iPhone | Apple refuses to issue attestation on jailbroken hardware. No keyId → /config returns 401 → Pro content never delivered. |
| Modified IPA (flipped flags, removed paywalls) | App Attest includes a hash of your binary's code signature. Modified binary = different hash = Apple won't attest. |
| API keys extracted from decompiled IPA | The keys aren't in your IPA. They come from Kiskis at runtime, only to attested devices. |
Client-side isPro bypass | Pro content isn't client-side to unlock. It's delivered per-user, gated by attestation. Flipping a boolean unlocks nothing. |
| Replay of captured requests | signCount must increment monotonically. Old requests are rejected. |
| Account sharing (one license, many devices) | Each device attests independently. Anomaly signals flag one userId on many geos. |
What Kiskis Does NOT Do (Being Honest)
- Not binary obfuscation. A reverse engineer can still read your Swift. They just can't find usable secrets in it.
- Not RASP. No runtime self-protection, no anti-debugger, no SSL pinning. Apps needing those (banking, financial) should pair Kiskis with Guardsquare or similar.
- Not jailbreak detection at runtime. We let Apple's attestation do it. Kernel-level jailbreaks sometimes fool App Attest — it's not bulletproof.
- Not IAP receipt validation. That's RevenueCat's job. We deliver content after you've confirmed payment.
Honest framing: Kiskis makes casual piracy unprofitable. It raises the cost from "teenager with a Python script" to "dedicated reverse engineer with hardware." That's enough for most indie apps to plug the leak. If you're shipping banking software, pair us with a real RASP tool.
Key Principles
- Nothing is embedded. Your app ships with only your Team ID, Bundle ID, and the config key name (all non-secret).
- Hardware proves identity. Apple's Secure Enclave generates a key pair that can never be extracted.
- Offline-first. Cached config loads instantly. Network fetches happen in the background.
- Key + Version targeting. Each named document has its own independent version-targeted payloads.
Security Layers
| Layer | Mechanism | Purpose |
| Transport | TLS 1.3 | Encrypt in transit |
| App Identity | App Attest (Secure Enclave) | Prove genuine device + app |
| Replay Prevention | signCount + nonce | Each request is unique |
| Authorization | JWT (15 min TTL) | Time-limited access |
| Path Obfuscation | Ed25519-signed S3 paths | Can't guess storage locations |
| Rate Limiting | API Gateway + WAF | Prevent abuse |
| Encryption at Rest | S3 SSE-KMS | Protect stored configs |
SDK Installation
Swift Package Manager (Recommended)
In Xcode, go to File → Add Package Dependencies and enter:
https://github.com/kiskisdev/client
Select the Kiskis library (core). Optionally add:
KiskisAnalytics — telemetry reporting (~1MB)
KiskisBinaryBlobs — large file downloads (~0.5MB)
Requirements
- iOS 14.0+ (App Attest requires Secure Enclave)
- Xcode 15+
- Swift 5.9+
App Attest Capability
In Xcode, go to your target → Signing & Capabilities → + Capability → App Attest.
Simulator note: App Attest uses sandbox mode in the simulator. The SDK auto-detects this and uses sandbox attestation endpoints. Full hardware attestation requires a physical device.
Usage Guide
Basic Usage
import Kiskis
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
key: "default"
)
// Fetch the "default" config document
let config = try await kiskis.fetchConfig()
let stripeKey = config.string("api_keys.stripe")
let maxUpload = config.int("limits.max_upload_mb")
Non-Blocking Initialization
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions opts: ...) -> Bool {
Task {
do {
let config = try await KiskisClient.shared?.fetchConfig()
NotificationCenter.default.post(
name: .kiskisReady, object: config)
} catch {
handleOfflineState(error)
}
}
return true // Don't block main thread
}
}
Fallback Config (First Install Offline)
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
key: "default",
fallbackConfig: Bundle.main.url(
forResource: "fallback_config",
withExtension: "json")
)
Security warning: Fallback configs are embedded in the binary and are NOT protected by attestation. Never put API keys in the fallback — only non-sensitive defaults (endpoints, feature flags).
Per-User Data
Store user-specific data keyed by any user identifier you choose — your own user ID, Firebase UID, or any unique string.
// Pass any user identifier from your system
// Data is stored at a hashed path — anonymous, cross-device
Offline & Caching
How Caching Works
- First run: SDK attests with Apple, fetches config, encrypts and stores in Keychain.
- Subsequent runs: Returns cached config instantly. Refreshes in background.
- Offline: Returns cached config. Sets
isStale flag if past TTL.
- Long offline (>7 days default): Cache expires. SDK returns error or fallback.
Cache Policy Configuration
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
key: "default",
cachePolicy: .init(
maxStaleness: .days(7), // Trust cache for 7 days offline
backgroundRefresh: true, // Silent refresh when online
onStaleConfig: .warnAndUse // .warnAndUse | .failHard | .useSilently
)
)
Staleness Policies
| Policy | Behavior | Best For |
.warnAndUse | Return stale config with isStale = true | Most apps (default) |
.failHard | Throw error if stale | Financial apps |
.useSilently | Return stale config, hide staleness | Games, media apps |
Apple Outage Grace Mode
If Apple's attestation servers go down, the SDK extends the JWT TTL by 1 hour and serves cached config. Retries in background. Your app keeps working.
Zero-Knowledge Mode
In Zero-Knowledge mode, your config is encrypted on your machine before it ever touches our servers. We store and deliver opaque ciphertext. We literally cannot read your secrets.
How It Works
- CLI encrypts config locally using AES-256-GCM (key derived via HKDF)
- Encrypted blob is uploaded to Kiskis
- Server stores opaque ciphertext — cannot read or validate it
- SDK fetches ciphertext, decrypts locally with the vault key
CLI Usage
# Encrypt and upload in one step
kiskis upload --file secrets.json --auth $AUTH --ver "*" \
--encrypt --vault-pass "MyVaultKey"
# Or encrypt separately
kiskis encrypt --file secrets.json --vault-pass "MyVaultKey"
kiskis upload --file secrets.json.enc --auth $AUTH --ver "*"
SDK Usage
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
key: "default",
zeroKnowledge: .enabled(vaultPass: "MyVaultKey")
)
// fetchConfig() automatically decrypts locally
let config = try await kiskis.fetchConfig()
Tradeoff: In ZK mode, the server can't preview, validate, or filter your config. Selective fetching (scope parameter) is not available — the entire blob must be decrypted client-side.
Push Notifications
Kiskis can send push notifications to your users' devices. Send to a specific user (all their devices), a single device, or broadcast to everyone. No Firebase required — Kiskis talks directly to Apple's push servers using your APNs key.
Use case: SwiftData and CloudKit can take minutes to replicate. Send a silent push through Kiskis when data changes on one device, and all the user's other devices sync immediately.
1. Enable Push in Xcode
In your app target, add these capabilities:
- Push Notifications (Signing & Capabilities)
- Background Modes → check "Remote notifications"
2. Register for Push and Set the Token
// AppDelegate.swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Convert token to hex string and hand it to Kiskis
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
KiskisClient.shared?.pushToken = token
}
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
// Kiskis sends silent pushes with {"kiskis": {"action": "refresh"}}
if let kiskis = userInfo["kiskis"] as? [String: Any], kiskis["action"] as? String == "refresh" {
Task {
_ = try? await KiskisClient.shared?.fetchConfig()
completionHandler(.newData)
}
} else {
// Handle your own push notifications here
completionHandler(.noData)
}
}
3. Associate Users with Devices
Call setUserId() after your user logs in. This lets you send a push to a user and have it reach all their devices — iPhone, iPad, second phone, etc.
// After user logs in (e.g., iCloud, your own auth, etc.)
let recordID = try await CKContainer.default().userRecordID()
try await kiskis.setUserId(recordID.recordName)
// Or use any stable user identifier from your system
try await kiskis.setUserId("user_12345")
4. Send Push from Your Backend or Dashboard
Use the dashboard Push tab, the CLI, or call the API directly:
Send to a specific user (cross-device sync)
# All of Alice's devices get this push
kiskis push:send --auth $AUTH --to "alice_icloud_id" \
--silent --data '{"action": "sync"}'
Send to a specific device
kiskis push:send --auth $AUTH --device "keyId_abc123" \
--title "Update available" --body "Tap to refresh"
Broadcast to all users
kiskis push:broadcast --auth $AUTH \
--title "New feature!" --body "Check out dark mode"
Check delivery status
kiskis push:status --auth $AUTH --id push_a7f3b9c2e1d4f6a8
5. Upload Your APNs Key
Kiskis needs your APNs .p8 signing key to send pushes to your app. Generate one in the Apple Developer Portal under Keys → Apple Push Notifications service.
kiskis push:setup --auth $AUTH \
--apns-key-file AuthKey_XXXX.p8 \
--apns-key-id XXXX \
--apns-team-id YOUR_TEAM_ID
One key per account: An APNs key works for all apps in your Apple Developer account. You only need to upload it once.
How It Works Internally
- Your app registers for push and sets
kiskis.pushToken
- The SDK sends the token to Kiskis during attestation (and updates it if it changes)
setUserId() associates the device's token with a user ID in our database
- When you call
/push/send with a user ID, Kiskis finds all devices for that user
- Tokens are batched and sent to Apple's push servers via HTTP/2
- Apple delivers the notification to each device
- Your app wakes up and handles the notification
Silent vs Visible Push
| Type | User sees it? | Best for |
Silent ("silent": true) | No | Data sync, config refresh, background updates |
| Visible (title + body) | Yes | Alerts, marketing, user-facing messages |
Rate Limits
| Plan | /push/send (per hour) | /push/broadcast (per day) | Total/month |
| Hobby (free) | 100 | 2 | 1,000 |
| Indie ($29/mo) | 500 | 5 | 10,000 |
| Pro ($99/mo) | 2,000 | 20 | 100,000 |
| Growth ($249/mo) | 10,000 | Unlimited | 500,000 |
| Scale | Custom | Custom | Custom |
Feature Flags
Kiskis is a feature flag system. Ship features dark, toggle them on/off instantly without an app update, roll them out gradually, and A/B test variants — all from the same SDK you already use for API keys.
No extra service to buy. LaunchDarkly starts at $200/mo. Firebase Remote Config locks you into Firebase. Kiskis includes flags on every tier — free for 500 MAU, $29/mo for 5,000 MAU.
The Simplest Flag
Feature flags live in their own named config document, identified by a key such as "flags". A KiskisClient instance is bound to that key and sees flag names at the top level of the document — there is no features. prefix.
// flags.json (uploaded with --key flags)
{
"dark_mode": true,
"new_checkout": false,
"beta_search": true,
"checkout_flow": "express"
}
Upload it:
kiskis upload --file flags.json --auth $AUTH --key flags --ver "*"
In Swift, create a dedicated client for the flags document:
let flags = KiskisClient(teamId: "A1B2C3D4E5", key: "flags")
_ = try await flags.fetchConfig()
if flags.isEnabled("dark_mode") {
enableDarkMode()
}
if flags.isEnabled("new_checkout", default: false) {
showNewCheckout()
} else {
showClassicCheckout()
}
flags.isEnabled("dark_mode") reads the top-level dark_mode field. The default value is returned if the config hasn't loaded yet (first launch offline). Kiskis caches the last-known document, so after the first successful fetch flags work offline too.
Why a separate client? Each KiskisClient is bound to one config key. Your runtime settings ("default") and your flags ("flags") are independent documents with independent version histories and kill switches. Use two clients — one per key.
Variants (A/B Tests)
Flags don't have to be booleans. Use strings for multi-way splits:
// flags.json (top level)
{ "checkout_flow": "express" } // or "classic" or "onepage"
switch flags.variant("checkout_flow", default: "classic") {
case "express": showExpressCheckout()
case "onepage": showOnePageCheckout()
default: showClassicCheckout()
}
Progressive Rollouts
Roll a feature out to a percentage of devices — deterministic, so the same device always gets the same answer for the same flag.
// Roll new search to 25% of devices
if flags.isInRollout("new_search", percentage: 25) {
useNewSearchAPI()
} else {
useOldSearchAPI()
}
The rollout percentage is computed from a hash of the flag name + the device's identifierForVendor. Increase the percentage in code (requires a release), or put it in the flags document and ramp from the dashboard.
Two ways to ramp: change the percentage in your Swift code (requires release), OR put the percentage in the flags document and call flags.isInRollout("search", percentage: flagsDoc.int("search_rollout_pct") ?? 0) — then you can ramp from the dashboard without shipping an update.
Kill Switch (Instant Off)
A feature is broken in production and you need it off right now. Three ways to do it:
1. Flip the flag in the dashboard
Set the top-level broken_thing field in the flags document to false, save. Online devices fetch the new document within the TTL (default 1 hour). No app release needed.
2. Push a config refresh
Pair with push notifications for seconds-level propagation:
kiskis push:broadcast --auth $AUTH \
--silent --data '{"action": "refresh"}'
Every device wakes up, refetches its config documents, and picks up the new flag values. Offline devices pick up the change when they come online.
3. Version-wide kill
For a truly unsafe feature, use the kill-switch endpoint to disable delivery entirely for a specific (key, version) pair — apps fall back to cached config or emit an error your handler can catch.
Targeting by App Version
Different flag values for different app versions (within the flags key):
kiskis upload --file v1-flags.json --auth $AUTH --key flags --ver "1.*"
kiskis upload --file v2-flags.json --auth $AUTH --key flags --ver "2.*"
v1 users get v1-flags.json. v2 users get v2-flags.json. You can ship a flag enabled in v2 while keeping it off for v1 users — useful if the feature depends on code that only exists in v2.
Per-User Overrides (Staff / Beta)
Want to enable a flag for specific users (internal staff, beta testers) before rolling it out to everyone? Use per-user data:
// Global default (flags document): "beta_search": false
// Override for specific users (set via dashboard, CLI, or API)
try await flags.saveUserData(userId: "staff_alice", data: [
"feature_overrides": ["beta_search": true]
])
// In your Swift code
let userFlags = try await flags.loadUserData(userId: currentUserId)
let globalValue = flags.isEnabled("beta_search")
let override = (userFlags?["feature_overrides"] as? [String: Bool])?["beta_search"]
let isOn = override ?? globalValue
From a server (e.g. a webhook handler), you can set this via the CLI:
kiskis user:set --auth $AUTH --user-id "staff_alice" \
--data '{"feature_overrides":{"beta_search":true}}'
Or wrap it in a helper once and reuse everywhere.
Real-World Pattern: Gradual Feature Rollout
Launch a new feature safely in four steps:
- Day 1: Upload config with
"new_feature": false. Ship the code behind isEnabled("new_feature"). Feature exists in the app but is dark.
- Day 2: Enable for staff via per-user overrides. Test in production with real data.
- Day 3: Flip to 5% rollout. Monitor crash rates and analytics. If bad, flip back to 0% — no release needed.
- Day 4-7: Ramp 5% → 25% → 50% → 100%. Any issue, flip it back.
Kiskis vs Dedicated Flag Services
| Feature | Kiskis | LaunchDarkly | Firebase Remote Config |
| Price (starter) | $29/mo Indie | $200+/mo | Free (Firebase lock-in) |
| On/off flags | ✓ | ✓ | ✓ |
| Variants / A/B | ✓ | ✓ | ✓ |
| Progressive rollout | ✓ | ✓ | Limited |
| Kill switch | ✓ | ✓ | ✓ |
| Rollback history | ✓ | ✓ | Limited |
| Hardware attestation | ✓ | ✗ | Partial |
| Real-time updates | Push-triggered | SSE streaming | Polling |
| Server-side rule engine | ✗ (evaluate client-side) | ✓ | ✓ |
| Evaluation metrics | Your analytics | ✓ | ✓ |
| Also delivers API keys | ✓ | ✗ | ✗ |
When to pick LaunchDarkly instead: You need a server-side rule engine (target by country + subscription + user attribute combinations), formal A/B testing with outcome measurement, or flag evaluation metrics in a dashboard. For on/off flags, variants, and gradual rollouts, Kiskis is simpler and 1/10th the price.
Dashboard
The Kiskis dashboard at dashboard.kiskis.dev provides:
- Setup: Enter your Team ID and Bundle ID, create a provisioning credential
- Config Editor: Upload and manage config documents per key, with version targeting
- Provisioning Credentials: Create and revoke credentials (
kk_prod_...) for CLI/CI access
Authentication is via Cognito (email + password, optional MFA).
CLI Tool
npx kiskis-cli --help
Commands
| Command | Description |
kiskis upload | Upload a config document for a (key, version) pattern |
kiskis encrypt | Encrypt a file locally (ZK mode) |
kiskis decrypt | Decrypt a file locally (testing) |
kiskis keys:create | Create a provisioning credential |
kiskis keys:revoke | Revoke a provisioning credential |
kiskis config:view | View the manifest for a config key |
kiskis config:delete | Delete a version pattern or an entire key |
kiskis user:set | Write per-user data (server-side, e.g. webhooks) |
kiskis user:get | Read per-user data (server-side) |
kiskis user:delete | Delete per-user data (server-side) |
kiskis push:setup | Upload your APNs .p8 key |
kiskis push:send | Send push to a user or device |
kiskis push:broadcast | Send push to all devices |
kiskis push:status | Check push delivery status |
Flag naming: --auth is the provisioning credential (or $KISKIS_AUTH). --key is the name of the config document (default: "default"; common values: "flags", "promos").
Config Example
# Create a provisioning credential
kiskis keys:create --team-id A1B2C3 --bundle-id com.my.app
# Upload the default config (--key defaults to "default" and can be omitted)
kiskis upload --file config.json --auth $AUTH --ver "*"
# Upload v2-specific default config
kiskis upload --file v2-config.json --auth $AUTH --ver "2.*"
# Upload a separate feature-flags document
kiskis upload --file flags.json --auth $AUTH --key flags --ver "*"
# View what's deployed under each key
kiskis config:view --auth $AUTH # default
kiskis config:view --auth $AUTH --key flags # flags
Per-User Data Example (Server-Side)
# From a Stripe webhook: grant pro entitlement to a user
kiskis user:set --auth $AUTH --user-id "usr_48291" \
--data '{"entitlements":["pro"],"plan":"pro_annual"}'
# Read it back
kiskis user:get --auth $AUTH --user-id "usr_48291"
# Remove it (e.g. on subscription cancellation)
kiskis user:delete --auth $AUTH --user-id "usr_48291"
Push Example
# Upload your APNs key (one time)
kiskis push:setup --auth $AUTH \
--apns-key-file AuthKey_XXXX.p8 \
--apns-key-id XXXX --apns-team-id TEAMID
# Silent push to trigger sync on all of a user's devices
kiskis push:send --auth $AUTH --to "user_123" \
--silent --data '{"action": "sync"}'
# Broadcast to everyone
kiskis push:broadcast --auth $AUTH \
--title "Update available" --body "Version 2.0 is out!"
# Check delivery
kiskis push:status --auth $AUTH --id push_a7f3b9c2
CI/CD Integration
GitHub Actions
- name: Deploy Config to Kiskis
uses: kiskis/deploy-action@v1
with:
auth: ${{ secrets.KISKIS_AUTH }}
config-file: ./config/production.json
key: 'default'
version: '*'
Zero-Knowledge in CI/CD
- name: Deploy Encrypted Config
uses: kiskis/deploy-action@v1
with:
auth: ${{ secrets.KISKIS_AUTH }}
config-file: ./config/secrets.json
key: 'default'
version: '*'
zero-knowledge: 'true'
vault-pass: ${{ secrets.KISKIS_VAULT_PASS }}
Other CI Systems
Use the CLI directly:
# $KISKIS_AUTH is picked up automatically if set in the environment
npx kiskis-cli upload --file config.json --auth $KISKIS_AUTH --ver "*"
# Deploy feature flags as a separate document
npx kiskis-cli upload --file flags.json --auth $KISKIS_AUTH --key flags --ver "*"
Version Targeting
Every config lookup is a (key, version) pair. Each named config document (identified by key) has its own independent set of version-targeted payloads. This page covers how version matching works within a single key.
Key + Version
Upload different documents under different keys, each with its own version history:
kiskis upload --file config.json --auth $AUTH --ver "*" # key = "default"
kiskis upload --file v2-config.json --auth $AUTH --ver "2.*" # key = "default"
kiskis upload --file flags.json --auth $AUTH --key flags --ver "*"
kiskis upload --file flags-v2.json --auth $AUTH --key flags --ver "2.*"
The server matches version patterns within the requested key only. There is no fallback from one key to another — requesting a key that has no matching version returns a 404.
Version Patterns
| Pattern | Matches | Example Use |
* | All versions | Default payload for this key |
2.* | Any 2.x.x | Major version override |
2.1.* | Any 2.1.x | Minor version override |
2.1.3 | Exactly 2.1.3 | Hotfix for specific build |
Matching Priority
When the app sends key=default&version=2.1.3, the server tries in order:
- Exact:
2.1.3
- Patch wildcard:
2.1.*
- Minor wildcard:
2.*
- Default:
*
First match wins. If nothing matches for the requested key, the request fails with 404.
Security Architecture
Threat Model
| Threat | Mitigation |
| Decompile app for keys | No keys in binary; attestation prevents unauthorized use |
| Fake app clones | App Attest verifies binary integrity + Secure Enclave |
| Bot/script calls API | No Secure Enclave = rejected |
| Replay attacks | signCount must increment; nonces are one-time |
| Probe S3 for data | Ed25519-signed hash paths; bucket denies ListBucket |
| MITM intercept | TLS 1.3; optional certificate pinning |
| Kiskis employee access | S3 Deny-all-except-Lambda; KMS encryption; CloudTrail audit |
S3 Path Obfuscation
Config is stored at cryptographically signed paths. Even if the bucket were exposed, an attacker sees only random hex strings with no way to map them to developers.
Input: "A1B2C3.com.myapp" (TeamID.BundleID)
Sign: Ed25519.sign(masterKey, input)
Path: SHA256(signature) → "a7f3b9c2e1d4..."
App Store Review Guide
What to Disclose
- In your App Review notes, mention that the app downloads configuration at runtime via Kiskis.
- The SDK includes a
PrivacyInfo.xcprivacy manifest. No user data is collected.
- The SDK uses only public Apple APIs (DeviceCheck framework).
Privacy Nutrition Labels
| Category | Collected? |
| Contact Info | No |
| Health & Fitness | No |
| Financial Info | No |
| Location | No |
| Identifiers | No (App Attest is anonymous) |
| Usage Data | No |
| Diagnostics | Optional (KiskisAnalytics module) |
Important: Do not use Kiskis to enable features that weren't reviewed by Apple (e.g., hidden in-app purchases). This will get your app rejected.
Testing Matrix
| Environment | Secure Enclave | Attestation | Config Source |
| Xcode Simulator | No (mocked) | Sandbox | Real server (sandbox) |
| Physical device (debug) | Yes | Sandbox | Real server (sandbox) |
| TestFlight | Yes | Production | Real server (production) |
| App Store | Yes | Production | Real server (production) |
Sandbox vs Production
The SDK auto-detects the environment:
#if DEBUG
KeySAS.environment = .sandbox
#else
KeySAS.environment = .production
#endif
The server accepts both sandbox and production attestation tokens, validating against the appropriate Apple certificate chain.
Delivery API
Base URL: https://api.kiskis.dev
POST /auth/challenge
Get a one-time nonce for the attestation ceremony.
Response: { "nonce": "base64string" }
POST /auth/attest
Exchange App Attest attestation for JWT tokens.
Body: {
"attestationObject": "base64",
"keyId": "string",
"nonce": "string",
"teamId": "A1B2C3D4E5",
"bundleId": "com.my.app"
}
Response: {
"accessToken": "jwt",
"refreshToken": "jwt",
"expiresIn": 900
}
POST /auth/refresh
Refresh an expired access token.
Body: { "refreshToken": "jwt" }
Response: { "accessToken": "jwt", "refreshToken": "jwt", "expiresIn": 900 }
GET /config?key=default&version=2.1.3
Fetch a named config document matched to the app version. Both query params are required: key (the document name) and version (the semver for matching). Requires a hardware assertion or Authorization: Bearer {accessToken}.
Optional: &scope=api_keys.stripe,limits for selective fetching within the returned document.
Response: {
"key": "default",
"config": { ... },
"matchedPattern": "2.*",
"requestedVersion": "2.1.3"
}
If the requested key does not exist, the server returns 404. There is no fallback across keys.
GET /blob/{blobKey}
Get a presigned S3 URL for a binary blob. Expires in 5 minutes. blobKey here is the blob identifier within a config document, not a config key.
GET/PUT/DELETE /user/data?user_id=xxx
Device-facing per-user data endpoints. Get, save, or delete per-user data from the app, authenticated via hardware assertion. Pass any user identifier as user_id.
For server-side access (e.g., webhook handlers), use the admin endpoints under /admin/users/{user_id}/data — see the Management API.
Management API
All management endpoints require Authorization: Bearer {provisioning_credential} (a kk_prod_... token).
POST /admin/config/upload
Upload a config document for a given (key, version). key defaults to "default" if omitted.
Body: {
"key": "default", // optional, defaults to "default"
"version": "2.*",
"config": { ... }
}
Response: { "message": "...", "key": "default", "totalPatterns": 3, "allPatterns": [...] }
Example uploading a feature-flags document:
Body: {
"key": "flags",
"version": "*",
"config": { "dark_mode": true, "new_checkout": false }
}
GET /admin/config?key=default
View the full manifest (all version patterns) for a given key. Defaults to key=default.
DELETE /admin/config?key=default&version=2.*
Delete a specific version pattern within a key. Omit version to delete the entire key. key defaults to "default".
POST /admin/keys/create
Create a new provisioning credential.
Body: { "teamId": "...", "bundleId": "...", "name": "CI Credential" }
Response: { "auth": "kk_prod_...", "message": "Save this value..." }
POST /admin/keys/revoke
Body: { "auth": "kk_prod_..." }
POST /admin/kill-switch
Disable config delivery for a specific (key, version) pair.
Body: { "key": "default", "versions": ["2.1.3"], "enabled": false, "reason": "..." }
POST /admin/emergency-revoke
Body: { "key": "default", "version": "*", "force_refresh_ttl": 300 }
POST /admin/blob/upload
Body: { "blob_key": "model.bin", "data_base64": "...", "content_type": "..." }
PUT /admin/users/{user_id}/data
Write per-user data from a server-side context (e.g., a Stripe webhook handler). Unlike the device-facing /user/data, this uses the provisioning credential instead of a hardware assertion.
Headers: Authorization: Bearer {provisioning_credential}
Body: {
"data": {
"entitlements": ["pro"],
"plan": "pro_annual"
}
}
Response: { "user_id": "usr_48291", "updated_at": "2026-04-17T..." }
GET /admin/users/{user_id}/data
Read the per-user data document server-side.
Headers: Authorization: Bearer {provisioning_credential}
Response: { "user_id": "usr_48291", "data": { ... }, "updated_at": "..." }
DELETE /admin/users/{user_id}/data
Delete the per-user data document server-side (e.g., on subscription cancellation).
Headers: Authorization: Bearer {provisioning_credential}
Response: { "user_id": "usr_48291", "deleted": true }
Push API
POST /push/register
Associate a user ID with the current device. Called by the SDK (setUserId()). Requires assertion auth (Secure Enclave signature).
Headers: X-Key-Id, X-Team-Id, X-Bundle-Id, X-Assertion
Body: { "user_id": "icloud_record_id_or_your_user_id" }
Response: { "registered": true, "user_id": "..." }
POST /push/send
Send a push to a specific user (all their devices) or a single device. Requires provisioning credential.
Headers: Authorization: Bearer {provisioning_credential}
Body: {
"to": "user_id", // send to all devices for this user
// OR
"device": "keyId_abc123", // send to one specific device
"title": "Your order shipped!", // omit for silent push
"body": "Arriving Thursday.",
"badge": 1,
"sound": "default",
"data": { "orderId": "123" }, // custom payload (optional)
"silent": false, // true = background push, no alert
"env": "production" // or "sandbox"
}
Response: {
"id": "push_a7f3b9c2",
"status": "sent", // or "queued" for larger batches
"sent": 2,
"failed": 0
}
POST /push/broadcast
Send a push to all registered devices for your app. Always queued (processed via SQS fan-out for scale). Requires provisioning credential.
Headers: Authorization: Bearer {provisioning_credential}
Body: {
"title": "New feature!",
"body": "Check out dark mode.",
"badge": 1,
"sound": "default",
"data": { "screen": "settings" },
"silent": false,
"version": "2.*", // optional: filter by app version
"env": "production",
"collapse_key": "promo", // optional: device shows only the latest
"ttl": 7200 // optional: seconds before push expires
}
Response: {
"id": "push_b8d4c1e3",
"status": "queued",
"total_devices": 48000,
"estimated_completion": "10s"
}
GET /push/status/{id}
Check delivery status of a push. Requires provisioning credential.
Headers: Authorization: Bearer {provisioning_credential}
Response: {
"id": "push_b8d4c1e3",
"status": "complete", // "queued" | "sending" | "complete"
"type": "broadcast", // or "send"
"title": "New feature!",
"target": null, // user_id or device_id if targeted
"total_devices": 48000,
"sent": 47988,
"failed": 12,
"created_at": "2026-04-13T...",
"completed_at": "2026-04-13T..."
}
POST /push/apns-key
Upload your APNs .p8 signing key. Required before sending pushes. Requires provisioning credential.
Headers: Authorization: Bearer {provisioning_credential}
Body: {
"apns_key_b64": "LS0tLS1CRUdJ...", // base64-encoded .p8 file
"apns_key_id": "ABC1234567", // 10-char key ID from Apple
"apns_team_id": "YOUR_TEAM_ID" // Apple Developer Team ID
}
Response: { "message": "APNs key stored", "apns_key_id": "...", ... }