Embedding in a native app

The editor that embeds into a web page also runs inside a native webview. Same frame, same methods, same events, same billing — the only thing that changes is who carries the messages: there is no parent window to postMessage to, so the frame talks to your shell instead.

This page is the integration guide. If you are writing a wrapper library rather than an app, the full wire contract is machine-readable: protocol.json, shipped inside the @snapnedit/embed package, lists every message, method, event and error code.

1. Add your app id to the key

A native app has no browser origin, so it identifies itself with a native app id: your bundle identifier, package name, or any short slug, written as native:<app-id>.

In the dashboard, open your publishable key and add it to Allowed origins, one per line alongside any web origins:

https://app.example.com
native:com.acme.photos

The id must start with a letter or digit and may contain letters, digits, ., _ and - (2–128 characters). It is matched case-insensitively.

The key is public either way. A publishable key is public in a native app exactly as it is on the web — it ships inside your .ipa/.apk and anyone can read it. The app id list is not a secret check; it stops your key being casually reused in someone else's app and lets you turn a surface off. Your real controls are the key's daily credit cap, revocation, and — if you want per-user limits — minting short-lived tokens server-side with POST /embed/tokens.

2. Load the frame

Point the webview at:

https://snapnedit.com/embed?transport=native&origin=native:com.acme.photos&key=pk_live_...

| Parameter | Required | Meaning | | --- | --- | --- | | transport | yes | Must be native. | | origin | yes | Your native:<app-id>, allowlisted on the key. | | key | one of | Your publishable key. | | token | one of | A token your backend minted with POST /embed/tokens — use this instead of key when you want per-end-user credit caps or an operation allowlist. It skips the app-id check. | | locale | no | UI locale, e.g. en. |

The credential rides the URL because there is no host page to hand it over from. That URL is loaded by your own webview and is never anyone else's document.referrer — but keep it out of any shared or persisted browsing history and out of your logs, and prefer a short-lived token over shipping the pk_ where your app has a backend to mint one.

Theme, features and initial content are not URL parameters — send them as calls once the editor is up (setTheme, setFeatures, loadImage, …). That keeps your launch URL short and lets you retheme at runtime.

3. Wire the two directions

In (your shell → the editor) is always the same function, evaluated as JavaScript in the webview:

window.SnapneditNativeBridge.receive('<one JSON message>')

Out (the editor → your shell) is whichever channel your platform provides. The frame detects it automatically; you just have to register it with the name below. If none of them fit, call window.SnapneditNativeBridge.setSink(fn) with your own function.

| Platform | Register | The frame calls | | --- | --- | --- | | WKWebView (iOS/macOS) | WKScriptMessageHandler named snapnedit | window.webkit.messageHandlers.snapnedit.postMessage(json) | | WebView2 (Windows) | nothing — built in | window.chrome.webview.postMessage(json) | | Android WebView | addJavascriptInterface(obj, "SnapneditAndroid") | window.SnapneditAndroid.postMessage(json) | | flutter_inappwebview | addJavaScriptHandler(handlerName: 'snapnedit') | window.flutter_inappwebview.callHandler('snapnedit', json) | | webview_flutter | JavaScriptChannel(name: 'Snapnedit') | window.Snapnedit.postMessage(json) |

Messages the editor produces before your channel exists are buffered and flushed as soon as it appears, so there is no handshake to race.

Swift — WKWebView

let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "snapnedit")          // out
let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: URL(string:
  "https://snapnedit.com/embed?transport=native&origin=native:com.acme.photos&key=pk_live_...")!))

func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) {
  handle(json: m.body as? String ?? "")                            // a snapnedit envelope
}
func send(_ json: String) {                                        // in
  let escaped = String(data: try! JSONEncoder().encode(json), encoding: .utf8)!
  webView.evaluateJavaScript("window.SnapneditNativeBridge.receive(\(escaped))")
}

C# — WebView2

await webView.EnsureCoreWebView2Async();
webView.CoreWebView2.WebMessageReceived += (_, e) =>                // out
    Handle(e.TryGetWebMessageAsString());                           // a snapnedit envelope
webView.CoreWebView2.Navigate(
    "https://snapnedit.com/embed?transport=native&origin=native:com.acme.photos&key=pk_live_...");

async Task Send(string json)                                        // in
{
    var literal = System.Text.Json.JsonSerializer.Serialize(json);   // quotes + escapes it
    await webView.CoreWebView2.ExecuteScriptAsync($"window.SnapneditNativeBridge.receive({literal})");
}

Kotlin — Android WebView

webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(object {                              // out
    @JavascriptInterface fun postMessage(json: String) = handle(json)
}, "SnapneditAndroid")
webView.loadUrl(
    "https://snapnedit.com/embed?transport=native&origin=native:com.acme.photos&key=pk_live_...")

fun send(json: String) {                                             // in
    val literal = JSONObject.quote(json)                             // quotes + escapes it
    webView.post { webView.evaluateJavascript("window.SnapneditNativeBridge.receive($literal)", null) }
}

Dart — webview_flutter

final controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..addJavaScriptChannel('Snapnedit',                                 // out
      onMessageReceived: (m) => handle(m.message))
  ..loadRequest(Uri.parse(
      'https://snapnedit.com/embed?transport=native&origin=native:com.acme.photos&key=pk_live_...'));

Future<void> send(String json) =>                                     // in
    controller.runJavaScript('window.SnapneditNativeBridge.receive(${jsonEncode(json)})');

In every example, json is one complete message and the platform's own JSON encoder is what turns it into a safe JavaScript string literal — never build that literal with string concatenation.

4. Talk to the editor

Messages are the same envelopes the web embed uses, serialized as JSON strings.

// ← the editor, as soon as it is live
{"snapnedit":1,"type":"ready-for-init"}
{"snapnedit":1,"type":"event","payload":{"name":"ready","data":{"version":"0.3.1"}}}

// → you: call a method. `id` is yours; it comes back on the result.
{"snapnedit":1,"type":"call","id":"1","payload":{"method":"loadImage","args":["data:image/png;base64,iVBORw0KGgo…"]}}

// ← the editor
{"snapnedit":1,"type":"result","id":"1","payload":{"ok":true,"value":null}}

// a failure carries a code you can switch on
{"snapnedit":1,"type":"result","id":"2","payload":{"ok":false,"error":{"code":"payment_required","message":"…"}}}

Wait for the ready event before issuing calls. If the key or app id is refused you get an error event with code origin_denied instead, and the webview shows the reason.

Every method and event from the web embed is available: loadImage, addImage, newDocument, run, openTool, undo, redo, select, getState, getDocument, setTheme, setFeatures, setLocale, export, exportTo, listDestinations — and the ready, change, selection, job, export, save, close, error and token-expiring events.

5. Getting files in and out

In — pass a data: URL or an https: URL to loadImage/addImage. There is no Blob to hand over from native code, and a blob: URL cannot cross the process boundary.

{"snapnedit":1,"type":"call","id":"3","payload":{"method":"loadImage","args":["data:image/jpeg;base64,/9j/4AAQ…"]}}

A data: URL is copied whole as a JavaScript string, so keep it to a few megabytes (4 MB is a sensible ceiling). Above that, upload the photo somewhere your app can reach over https: and pass the URL — the editor fetches it itself.

Outexport returns the bytes to you. Because JSON has no binary type, the blob value arrives as an object instead:

{"snapnedit":1,"type":"result","id":"4","payload":{"ok":true,"value":{
  "blob":{"dataUrl":"data:image/png;base64,iVBORw0…","mime":"image/png","bytes":184320},
  "width":640,"height":480}}}

Strip the data:…;base64, prefix and base64-decode the rest to get the file. Everything else about the result is unchanged.

Out, without the round trip — for large exports prefer exportTo: the editor uploads straight from the webview to your bucket and the bytes never cross into your app at all. Pass a presigned URL your backend minted, or the destinationId of a saved storage destination:

{"snapnedit":1,"type":"call","id":"5","payload":{"method":"exportTo","args":[{"destinationId":"sd_…","format":"png"}]}}

listDestinations gives you the account's destinations if you want to offer a picker.

6. Official wrappers

You do not have to hand-roll the message loop for long — official Swift, .NET, Kotlin and Dart wrappers are in progress under github.com/Snap-N-Edit, alongside the existing embed, sdk and mcp repositories. Each will speak exactly the protocol above, so anything you build against it now keeps working.