snaplyagentDeveloper docsDeveloper 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_…")
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.

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).

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.

Vendor IDOn 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_…", 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.

Battery state

Reported automatically with the rest of Device health — there is nothing to enable. Snaply normalises to fivestates, not iOS's four, because the extra one is the only battery state a support agent can act on.

SnaplyiOSAndroid
charging.chargingBATTERY_STATUS_CHARGING
full.fullBATTERY_STATUS_FULL
unplugged.unpluggedBATTERY_STATUS_DISCHARGING
not_chargingno equivalentBATTERY_STATUS_NOT_CHARGING
unknown.unknownBATTERY_STATUS_UNKNOWN
Never collapse not_charging into unplugged.It means plugged in and not taking charge — heat, a weak cable, or a vendor charge cap. Folding the two together is a common bug in cross-platform battery code, and it destroys the one signal this field is worth reporting for: the caller believes the phone is charging and it isn't.
// Reported automatically — no configuration. // Snaply enables battery monitoring around the read and restores // your app's previous setting, so nothing is left switched on. // iOS reports four of the five states: // charging · full · unplugged · unknown // // iOS has NO equivalent of not_charging. A device held at a charge // limit reports .charging or .full, so Snaply never synthesises it // here — a guessed state is worse than an absent one. // // "unknown" means monitoring was switched off after init, not that // the device is faulty.

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.storage, memoryFree space and available memory.
No device-integrity signal is collected. Snaply does not run jailbreak or root heuristics: the tooling that defeats them is standard, so a check would catch only the careless while reading to an agent as authoritative. Device trust will come from Play Integrity and App Attest, and it will gate registration and live view by workspace policy — an agent is never shown a verdict about a customer.
// 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.

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). The SDK shows its own consent prompt and controlling banner — this is just a hook if you want to react.
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.
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

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.
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.