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?

  1. Developer uploads one or more named config documents (API keys, endpoints, feature flags) to the Kiskis vault via dashboard, CLI, or CI/CD.
  2. App launches and the SDK performs hardware attestation via Apple App Attest (Secure Enclave).
  3. Server verifies the attestation, confirming it's a genuine, unmodified app on a real device.
  4. Server returns the config document identified by (key, version).
  5. 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:

  1. User taps "Upgrade to Pro" in your app → Apple IAP / Stripe / RevenueCat processes the payment
  2. 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"}'
  1. 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:

  1. Someone with a jailbroken iPhone downloads your paid app, strips Apple's FairPlay DRM with a tool like Clutch or frida-ios-dump.
  2. They share the decrypted IPA on repositories like iOSGods, AppValley, or TrollStore feeds.
  3. 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)

AttackWhy Kiskis blocks it
Cracked IPA on jailbroken iPhoneApple 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 IPAThe keys aren't in your IPA. They come from Kiskis at runtime, only to attested devices.
Client-side isPro bypassPro content isn't client-side to unlock. It's delivered per-user, gated by attestation. Flipping a boolean unlocks nothing.
Replay of captured requestssignCount 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

LayerMechanismPurpose
TransportTLS 1.3Encrypt in transit
App IdentityApp Attest (Secure Enclave)Prove genuine device + app
Replay PreventionsignCount + nonceEach request is unique
AuthorizationJWT (15 min TTL)Time-limited access
Path ObfuscationEd25519-signed S3 pathsCan't guess storage locations
Rate LimitingAPI Gateway + WAFPrevent abuse
Encryption at RestS3 SSE-KMSProtect 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

  1. First run: SDK attests with Apple, fetches config, encrypts and stores in Keychain.
  2. Subsequent runs: Returns cached config instantly. Refreshes in background.
  3. Offline: Returns cached config. Sets isStale flag if past TTL.
  4. 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

PolicyBehaviorBest For
.warnAndUseReturn stale config with isStale = trueMost apps (default)
.failHardThrow error if staleFinancial apps
.useSilentlyReturn stale config, hide stalenessGames, 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

  1. CLI encrypts config locally using AES-256-GCM (key derived via HKDF)
  2. Encrypted blob is uploaded to Kiskis
  3. Server stores opaque ciphertext — cannot read or validate it
  4. 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

  1. Your app registers for push and sets kiskis.pushToken
  2. The SDK sends the token to Kiskis during attestation (and updates it if it changes)
  3. setUserId() associates the device's token with a user ID in our database
  4. When you call /push/send with a user ID, Kiskis finds all devices for that user
  5. Tokens are batched and sent to Apple's push servers via HTTP/2
  6. Apple delivers the notification to each device
  7. Your app wakes up and handles the notification

Silent vs Visible Push

TypeUser sees it?Best for
Silent ("silent": true)NoData sync, config refresh, background updates
Visible (title + body)YesAlerts, marketing, user-facing messages

Rate Limits

Plan/push/send (per hour)/push/broadcast (per day)Total/month
Hobby (free)10021,000
Indie ($29/mo)500510,000
Pro ($99/mo)2,00020100,000
Growth ($249/mo)10,000Unlimited500,000
ScaleCustomCustomCustom

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:

  1. Day 1: Upload config with "new_feature": false. Ship the code behind isEnabled("new_feature"). Feature exists in the app but is dark.
  2. Day 2: Enable for staff via per-user overrides. Test in production with real data.
  3. Day 3: Flip to 5% rollout. Monitor crash rates and analytics. If bad, flip back to 0% — no release needed.
  4. Day 4-7: Ramp 5% → 25% → 50% → 100%. Any issue, flip it back.

Kiskis vs Dedicated Flag Services

FeatureKiskisLaunchDarklyFirebase Remote Config
Price (starter)$29/mo Indie$200+/moFree (Firebase lock-in)
On/off flags
Variants / A/B
Progressive rolloutLimited
Kill switch
Rollback historyLimited
Hardware attestationPartial
Real-time updatesPush-triggeredSSE streamingPolling
Server-side rule engine✗ (evaluate client-side)
Evaluation metricsYour 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

CommandDescription
kiskis uploadUpload a config document for a (key, version) pattern
kiskis encryptEncrypt a file locally (ZK mode)
kiskis decryptDecrypt a file locally (testing)
kiskis keys:createCreate a provisioning credential
kiskis keys:revokeRevoke a provisioning credential
kiskis config:viewView the manifest for a config key
kiskis config:deleteDelete a version pattern or an entire key
kiskis user:setWrite per-user data (server-side, e.g. webhooks)
kiskis user:getRead per-user data (server-side)
kiskis user:deleteDelete per-user data (server-side)
kiskis push:setupUpload your APNs .p8 key
kiskis push:sendSend push to a user or device
kiskis push:broadcastSend push to all devices
kiskis push:statusCheck 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

PatternMatchesExample Use
*All versionsDefault payload for this key
2.*Any 2.x.xMajor version override
2.1.*Any 2.1.xMinor version override
2.1.3Exactly 2.1.3Hotfix for specific build

Matching Priority

When the app sends key=default&version=2.1.3, the server tries in order:

  1. Exact: 2.1.3
  2. Patch wildcard: 2.1.*
  3. Minor wildcard: 2.*
  4. Default: *

First match wins. If nothing matches for the requested key, the request fails with 404.

Security Architecture

Threat Model

ThreatMitigation
Decompile app for keysNo keys in binary; attestation prevents unauthorized use
Fake app clonesApp Attest verifies binary integrity + Secure Enclave
Bot/script calls APINo Secure Enclave = rejected
Replay attackssignCount must increment; nonces are one-time
Probe S3 for dataEd25519-signed hash paths; bucket denies ListBucket
MITM interceptTLS 1.3; optional certificate pinning
Kiskis employee accessS3 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

CategoryCollected?
Contact InfoNo
Health & FitnessNo
Financial InfoNo
LocationNo
IdentifiersNo (App Attest is anonymous)
Usage DataNo
DiagnosticsOptional (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

EnvironmentSecure EnclaveAttestationConfig Source
Xcode SimulatorNo (mocked)SandboxReal server (sandbox)
Physical device (debug)YesSandboxReal server (sandbox)
TestFlightYesProductionReal server (production)
App StoreYesProductionReal 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": "...", ... }