Native apps can't use the website widget — they send no browser origin, so the domain allowlist would reject them. Instead you generate a mobile app key (ik_…), an ingest-only key made to ship inside an app binary. Submissions land in the same dashboard as your website feedback.
Save this as FeedbackClient.swift. It handles the anti-bot challenge for you: fetch a single-use token, wait a moment, then post the feedback.
import Foundation
/// Sends feedback to feedback.latentedge.io from a native app.
///
/// The ingest key (ik_...) is publishable by design: it can only submit
/// feedback to your site and can never read anything, so a plain string in
/// your app is fine. Keychain or obfuscation adds nothing here — the real
/// protections (scoping, rate limits, rotation) live on the server.
public struct FeedbackClient {
public enum FeedbackType: String { case feedback, bug, featureRequest = "feature_request" }
public enum FeedbackError: Error { case badResponse, rejected(status: Int) }
let ingestKey: String
let baseURL: URL
public init(ingestKey: String,
baseURL: URL = URL(string: "https://feedback.latentedge.io/api/v1")!) {
self.ingestKey = ingestKey
self.baseURL = baseURL
}
/// Submits one feedback item. Fetches a single-use challenge, waits the
/// required human-speed dwell, then posts the feedback.
public func submit(_ message: String,
type: FeedbackType = .feedback,
rating: Int? = nil,
source: String? = nil) async throws {
let challenge = try await fetchChallenge()
// The server rejects submissions that arrive under 1s after the
// challenge was issued; 1.2s leaves margin for clock skew.
try await Task.sleep(nanoseconds: 1_200_000_000)
var request = URLRequest(url: baseURL.appendingPathComponent("widget/feedback"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(ingestKey, forHTTPHeaderField: "X-API-Key")
var body: [String: Any] = [
"type": type.rawValue,
"message": message,
"challengeToken": challenge,
]
if let rating { body["rating"] = rating }
if let source { body["source"] = source }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw FeedbackError.badResponse }
guard http.statusCode == 201 else { throw FeedbackError.rejected(status: http.statusCode) }
}
private func fetchChallenge() async throws -> String {
var components = URLComponents(
url: baseURL.appendingPathComponent("widget/challenge"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [URLQueryItem(name: "key", value: ingestKey)]
let (data, _) = try await URLSession.shared.data(from: components.url!)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard let payload = json?["data"] as? [String: Any],
let challenge = payload["challenge"] as? String else {
throw FeedbackError.badResponse
}
return challenge
}
}let feedback = FeedbackClient(ingestKey: "ik_your_key_here")
// From any async context — a settings screen, a shake gesture, a form sheet:
try await feedback.submit(
"The export button crashes on iPad",
type: .bug,
rating: 4,
source: "ExportView" // shows up like a page URL in your dashboard
)Tip: pass a screen or feature name as source — it appears in the dashboard where a web submission shows its page URL. To build a form that mirrors your site settings (which feedback types are on, whether ratings show), fetch the config:
GET https://feedback.latentedge.io/api/v1/widget/config?key=ik_your_key_here
{ "success": true, "data": { "enabledTypes": ["feedback", "feature_request", "bug"],
"showRating": true } }The LatentEdge iOS package ships a prebuilt FeedbackSheet SwiftUI view — type chips, an optional star rating, and a message field — so you can drop in a finished feedback form instead of building your own.

Anything inside an app binary can be extracted, so the mobile app key is designed to be worthless to an attacker:
That's also why you don't need the Keychain or obfuscation for it: a plain string in code or a config file is the honest place for a publishable key.
| Status | Meaning |
|---|---|
201 | Feedback stored. |
401 | Unknown, rotated, or revoked key. Ship a new key or prompt an update. |
400 | Missing/expired/reused challenge token, or an empty message. Fetch a fresh challenge and retry once. |
429 | Rate limited. Back off; don't retry in a loop. |
The same endpoints work from any native platform — Android or desktop apps can use the identical flow with the same key.