snaplyagentDeveloper docs
Developer SDK

SDK reference β€” Snaply Agent developer docs

Platform
One choice β€” every code sample follows it.

Install & initialize

One call at app start. The SDK registers the device, opens a lightweight socket, and waits. It never captures on its own.

import SnaplyAgent // AppDelegate β€” application(_:didFinishLaunchingWithOptions:) Snaply.configure(key: "snap_live_9f…")
keyProduct API key. Use snap_test_… while integrating β€” test captures are free and watermarked.userOptional at init. Whatever your app knows β€” an ID, a name, both. This is how agents find the caller.
The key only works from the identity set on the product β€” bundle ID on iOS, package name on Android, allowed domains on web. It's required, and requests from anywhere else are rejected with 403 origin_mismatch. On web, list every origin you run from, including localhost for local development β€” an empty list allows nothing.

Identify the user

Identity is optional. Call it after login β€” or never. We link whatever you pass to the device ID, so agents can search by name, your ID, phone, or the device. Anonymous devices get a short support code instead.

Snaply.identify(id: "usr_20481", name: "Maya Kowalski", phone: "+14155550142") // not signed in? surface the support code: Snaply.showSupportCode() // e.g. "74-315" β€” agents search it // on logout: Snaply.reset()
phone β€” optional, E.164. Send it and your call centre can screen-pop the caller by caller-ID (see Screen-pop API).

Vendor ID On by default

A per-install identifier for the handset, not the install. It's what lets a reinstall be recognised as the same device instead of arriving as a new one β€” so a revoked device stays revoked, and two accounts sharing one phone are visible. Collected by default on iOS and Android. No permission prompt is involved.

It is not a hardware serial. On iOS it is identifierForVendor?.uuidString β€” the same value across your apps, a different value for every other vendor, and it resets when the user deletes the last of your apps. Android's ANDROID_ID is scoped to your signing key and resets on factory reset. So it survives a reinstall, which is the point, but it does not identify a person across the app ecosystem and cannot be joined to anyone else's data. Neither platform's label category means "hardware serial", and they don't even share a name: Apple's is Identifiers β†’ Device ID, Google's is Device or other IDs β€” deliberately broad, covering exactly this kind of scoped, resettable value. Declare it under those; don't read them as a hardware claim.
// Collected by default β€” declare it, or turn it off. Snaply.configure( key: "snap_live_9f…", collectVendorId: false // default: true ) // On by default, Snaply sends // UIDevice.current.identifierForVendor?.uuidString // It is vendor-scoped, not hardware: same value across YOUR apps, // different for every other vendor, and it resets when the user // deletes the last of your apps. It can also be nil before the // first unlock after a restart β€” Snaply retries, and treats nil as // simply absent. // // Two separate Apple requirements, both on you: // 1. App Privacy label β€” declare // Identifiers β†’ Device ID // and pick a purpose. For support continuity that is // App Functionality, not Analytics. // 2. Privacy manifest β€” your PrivacyInfo.xcprivacy must list // NSPrivacyCollectedDataTypeDeviceID // Snaply ships its own manifest for the SDK; yours still has // to cover it, because the app is what Apple reviews.
Declare it before you ship, or turn it off. Apple and Google hold you responsible for what an SDK in your app collects, and a label that doesn't match can get a submission rejected. So either add the identifier entry to your store listing β€” Apple files it under Identifiers β†’ Device ID, Google under Device or other IDs β€” or pass collectVendorId: false. Both are fine; shipping with neither is not. It lives in your build, so support can't change it for you.
Where it shows: Devices & users β†’ a device β†’ Vendor ID. On the web that field reads "Not available on the web"; on iOS and Android, if you turn it off it reads "Not collected" and reinstalls register as new devices. Never read out to the customer β€” the support code is the spoken one.

SwiftUI remote control Automatic

Support can operate your SwiftUI controls during a remote-control session the customer has approved. From iOS 2.40.0 this works on its own β€” there is nothing to configure and no parameter to pass; UIKit and SwiftUI controls both respond. To do it, the SDK switches on the app’s accessibility tree for the duration of the session, through a private Apple API resolved at runtime β€” which Apple’s guideline 2.5.1 prohibits, so we say so here, before you ship. The SDK only does this in an app that has SwiftUI loaded; a UIKit-only app never calls it.

iOS only β€” Android and web do not use a private API for this.

Language

You choose the language the SDK speaks; it doesn’t guess from the phone. Snaply includes English, Armenian and Russian. For any other language, translate the SDK keys in your app β€” the files are in your dashboard under Settings β†’ SDK strings.

Snaply.configure(key: "snap_test_…", language: "hy")
1. your appKeys you define win: res/values-xx/strings.xml on Android, xx.lproj/Snaply.strings on iOS.2. SnaplyThe built-in translation for that language, if there is one.3. EnglishAlways there, so nothing ever shows blank.
Keys are identical on every platform and start with snaply_, so they never clash with your own strings. Placeholders like {agent} become %@ on iOS and %1$s on Android in the downloaded files. Dates and numbers follow the language you set.

Redaction

Mark any view sensitive and it's covered by a labelled REDACTED box in every capture β€” screenshots, live view, and recordings. Password fields are covered automatically on every platform. Nothing else is β€” mark whatever you want hidden.

// mark any view β€” painted over with a labelled REDACTED box view.snaplyRedact(label: "Card number") // check state: if view.isSnaplyRedacted { … } // global look (default: red box, white "REDACTED"): Snaply.setRedactionStyle(SnaplyRedaction( color: .systemRed, label: "REDACTED", labelColor: .white))
Redaction happens on-device β€” the REDACTED box is painted before the frame is ever encoded, so the real pixels never leave the phone. Redacted regions stay safe in live view, recordings, and server-composited saves.

Battery state

Reported with the rest of the device context on every capture and live session. Nothing to enable on Android or web β€” but iOS reports nothing until battery monitoring is on, so the SDK turns it on for the read itself and puts your setting back afterwards.

The five states Snaply reports
chargingPlugged in and the level is rising.fullPlugged in and at 100%.unpluggedRunning on the battery.not_chargingPlugged in but not taking charge β€” heat, a weak cable or accessory, or the phone's own charge limit. Android only; iOS cannot distinguish this from unplugged.unknownThe platform couldn't say.
Don't fold not_charging into unplugged. It is the one state worth surfacing to an agent: the caller believes the phone is charging and it isn't. Collapsing the two is a common bug, and it hides the fault it exists to reveal.
// Nothing to configure β€” but iOS reports .unknown unless // monitoring is on, so the SDK enables it for the read itself // and restores your previous setting afterwards β€” nothing is // left switched on: // UIDevice.current.isBatteryMonitoringEnabled = true // // UIDevice.BatteryState has FOUR cases: // .unknown β†’ "unknown" // .unplugged β†’ "unplugged" // .charging β†’ "charging" // .full β†’ "full" // // There is no iOS case for "plugged in but not charging". // A device held at a charge limit reports .charging or .full, // so Snaply never reports not_charging on iOS β€” rather than // guessing a state the platform does not expose.

Health & VPN

Device health is re-reported when the OS signals a network change, not sampled once at registration. Snaply records when each reading was taken, so the dashboard can tell a live reading from the launch snapshot.

batteryLevel and state β€” see Battery state.networkWi-Fi, cellular generation, or offline.vpnAndroid only. Read from NetworkCapabilities.TRANSPORT_VPN β€” no extra permission. Not sent on iOS or web: iOS has no public API, and the usual proxy-settings heuristic misses per-app VPNs and some WireGuard setups β€” exactly the corporate cases most likely to break a live view. The dashboard says the platform can't report it rather than implying there is none.integrityiOS and Android only. Jailbreak or root indicators: "signs" or "clean". A heuristic, and the dashboard labels it as one β€” a determined install defeats every check, so a clean reading is not proof, and a hit is never grounds to refuse on its own. Nothing in the SDK acts on it: no refusal, no block, no prompt. Absent from the web SDK and from any build older than this field, which the dashboard shows as "not checked" rather than as a clean reading.storage, memoryFree space and available memory.
The integrity signal is a heuristic, and nothing acts on it. The tooling that defeats jailbreak and root checks is standard, so the reading is shown to an agent as context and never as a verdict: a clean result is not proof, a hit is not grounds to refuse, and neither changes what the SDK does. Device trust β€” the kind that can gate registration or live view by workspace policy β€” will come from Play Integrity and App Attest, which are un-forgeable where this is not. This does not pre-empt them.
// Nothing to configure. Health is re-reported when the OS // signals a network change; the backend stamps each reading. // // vpn: NOT sent on iOS. // There is no public API. The common heuristic inspects // CFNetworkCopySystemProxySettings() // for tun/tap/ppp/ipsec keys β€” which misses per-app VPNs and // some WireGuard configs. Those are the corporate setups most // likely to break a live view, so the heuristic fails hardest // exactly where an agent would rely on it. A false "no VPN" // sends the agent down the wrong path; "VPN likely" changes // nothing they would do. Either way the action is: ask. // // integrity: "signs" | "clean". // Jailbreak indicators β€” package-manager artefacts, the // rootless /var/jb root, an injected tweak runtime in the // loaded-image list, and whether a write outside the sandbox // succeeds. All public API: fork()/system() probes are left // out because static analysis flags them on submission, and // URL-scheme probes need a LSApplicationQueriesSchemes entry // in YOUR Info.plist, without which they silently always pass. // A heuristic. Nothing in the SDK acts on it.

Screen-pop API (incoming calls)

When a call reaches your call centre, POST the caller's number to Snaply the moment your agent picks up. We resolve it to a user β€” searching the whole workspace β€” and pop them straight into that agent's console, no typing. Authenticate with your workspace server key (Settings β†’ Server key). The key identifies the workspace, so you never pass a workspace or product id.

POST https://snaplyagent.com/api/v1/calls/incoming Authorization: Bearer wsk_live_… { "phone": "+14155550142", // caller-ID, E.164 (required) "agent_caller_id": "2", // which agent line rang (required) "call_id": "cti_88213" // your reference (optional) }
phoneCaller's number in E.164. We normalise it and match against the phone your app passed to identify().agent_caller_idWhich agent line rang β€” a small number (1, 2, 3…) the workspace owner assigns each agent under Team β†’ member β†’ Caller ID. We map it to that agent and pop their open console in real time.call_idYour telephony reference. Echoed back and stored on the consent log for the session.

Responds { "matched": true, "callId": "…" } when the number is known, or { "matched": false } when it isn't β€” the agent then falls back to search or the support code. Numbers are never dialled from Snaply; you keep your telephony.

The HTTP response never contains the caller's identity. It returns only matched + callId. The resolved person is delivered privately to the matched agent's console over its live WebSocket β€” so a workspace server key can never be used as a phone-number β†’ identity lookup.
Errors
404agent_not_foundNo agent maps to that agent_caller_id in this workspace β€” check the Caller ID on Team.
422phone_requiredNo caller number on the request β€” send it as phone, in E.164.
422agent_caller_id_requiredNo agent_caller_id β€” include the line number that rang.

Request lifecycle

When an agent requests a capture, the SDK shows the consent popup and emits events you can hook:

onRequestShown()Popup is on screen. Pause video or hide sensitive views if you need to.
onAllowed(capture)User tapped Allow. capture.id matches the console and webhook record.
onDenied()User declined. Nothing left the device.
onExpired()No response within 60s β€” the popup dismissed itself.
onLiveEnded(session)Live view stopped β€” by the user, the agent, or call end.
onRemoteControl(active)Support requested in-app control and the user allowed it (active=true), or control ended (active=false).
onError(error)Something failed β€” registration, a capture upload, or a response. The error carries the code from the table below, so this is where you find out a key was revoked or an environment hit its device limit.
Menus are answered on-device. During remote control the agent's tap can open a menu, but iOS renders the open menu's items outside your app's process, so the agent can't tap them β€” your customer picks the item themselves. The agent sees the choice land the moment they do.
Privacy contract: mark a view sensitive and a REDACTED box covers it in every capture β€” even live view. Password fields are covered by default; everything else you mark yourself (see Redaction).

Webhook payload

POSTed to your product's webhook URL on every approved capture as multipart/form-data: a payload part (the JSON below) and an image part β€” the PNG bytes uploaded directly. We don't host the file or hand you a URL. Signed with X-Snaply-Signature (HMAC-SHA256 over the raw body). We retry on 5xx for 24h.

{ "event": "capture.created", "capture_id": "cap_9f31d8", "product": "prd_acme_ios", "user": { "id": "usr_20481", "name": "Maya Kowalski" }, "device": { "id": "dvc_8f3a91c2", "os": "iOS 18.4", "app": "4.2.1" }, "mode": "ask_every_time", "test": true, // present & true only for test-key captures β€” never billed "requested_by": "dana@acme.dev", "image": "<multipart file part Β· field "image" Β· image/png>", "captured_at": "2026-10-08T14:31:12Z" }
No hosted URL: the screenshot arrives as an uploaded file part in the same multipart request β€” persist it to your own storage on receipt.
Retention deletion

When a capture's retention window lapses we erase it and POST a deletion notice to the same webhook URL β€” JSON only, no image part, signed with the same X-Snaply-Signature HMAC. Delete your stored copy on receipt.

{ "event": "capture.deleted", "capture_id": "cap_9f31d8", "reason": "retention", "user": { "id": "usr_20481", "name": "Maya Kowalski" }, "deleted_at": "2027-01-06T00:00:00Z" }

Errors

Something failed β€” registration, a capture upload, or a response. The error carries the code from the table below, so this is where you find out a key was revoked or an environment hit its device limit.

401invalid_keyKey revoked or rotated β€” check product settings.
403mode_not_allowedLive view / session approval not enabled on this product or plan.
403origin_mismatchThe key was used from an app or domain that isn't this product's verified identity.
403account_suspendedThe account is suspended (billing or compliance hold). Every call is rejected until it's resolved.
403workspace_suspendedThis workspace is suspended by an admin β€” other workspaces on the account keep working.
403product_pausedThis product is paused β€” its key is inactive. Other products in the workspace are unaffected.
403seat_suspendedThe requesting agent's seat is suspended β€” they're signed out and can't capture until restored.
403identity_requiredThe product has no app identity set β€” every call is rejected until one is set in product settings.
403device_revokedThis device's token was revoked by the workspace β€” it can't call the API or register again.
409device_offlineThe device isn't connected β€” nothing was sent. Retry once it's back online.
409device_backgroundedThe app is connected but in the background, so it can't show the prompt. Ask the caller to reopen it.
410request_expiredThe 60-second consent window passed.
429quota_exceededMonthly screenshot quota reached β€” upgrade or wait for the cycle.
429device_limitThis environment hit its device registration limit, so new devices can't register. Existing ones keep working.