diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a618f61..32f4879 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "iterable-sdk", - "version": "26.10.2-beta", + "version": "26.10.4-beta", "description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a real device; iOS proof runs on the iOS Simulator.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 5e86840..5390c5c 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -3,7 +3,7 @@ "owner": { "name": "Iterable" }, "metadata": { "description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.", - "version": "26.10.2-beta" + "version": "26.10.4-beta" }, "plugins": [ { diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index e40f57e..405a847 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "iterable-sdk", - "version": "26.10.2-beta", + "version": "26.10.4-beta", "description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a real device; iOS proof runs on the iOS Simulator.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", diff --git a/INTENDED-FLOW.md b/INTENDED-FLOW.md index f662a7d..afb6138 100644 --- a/INTENDED-FLOW.md +++ b/INTENDED-FLOW.md @@ -151,6 +151,30 @@ Test Push remains optional troubleshooting and never blocks completion. Only missing verification credentials return to their narrow capture command. Missing proof metadata is not evidence that the SDK integration is missing. +## React Native path + +A React Native app runs the Android path and then the iOS path, one after the +other, under the `iterable-react-native` skill: + +1. Record the scope once — features, identity mode, region, JWT policy. Both + platforms use that one record. +2. **Android first.** The Android ladder provisions Firebase and Iterable, the + skill writes the integration, and `iterable-android-verify` proves a push. + No iOS step starts before that verdict is green. +3. **iOS second.** The iOS provider path offers to reuse both Iterable keys, + the region, No JWT, and the test email from the Android setup, carries over + the feature scope from step 1 (passed with `--features`, shown again for + confirmation), and adds the Apple/APNs prerequisites. The skill writes the + native push code (bare) or relies on the Expo plugin, and + `iterable-ios-verify` proves a push on a Release Simulator build. +4. The app is integrated when the verifier has passed for every platform in + scope. One green platform is not done. + +The Mobile key reaches the app as a gitignored file the tool writes — bare +React Native `.iterable/rn/iterable.local.ts`, Expo `EXPO_PUBLIC_ITERABLE_API_KEY` +in `.env.local` — and the app loads it so that a checkout without the file +still builds. + ## What divergence looks like The three classes, because they need different fixes and get confused constantly: diff --git a/README.md b/README.md index c9c11b7..4e23193 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ before reading or changing scoped cloud resources. | `iterable-android` | Integrates and troubleshoots Iterable's native Android SDK using version-pinned documentation. | | `iterable-android-verify` | Independently verifies native Android and React Native Android integrations and push delivery. | | `iterable-ios` | Integrates and troubleshoots Iterable's native iOS SDK using Iterable documentation and iOS lifecycle guidance. | -| `iterable-ios-verify` | Independently verifies an existing native iOS integration, using no-app-edits behavioral proof by default and optional instrumented diagnostics. | +| `iterable-ios-verify` | Independently verifies a native iOS, React Native iOS, or Expo iOS integration, using no-app-edits behavioral proof by default and optional instrumented diagnostics. | | `iterable-react-native` | Integrates and troubleshoots `@iterable/react-native-sdk`, including Expo projects. | The skills can work independently. A new push integration normally follows: diff --git a/REVIEW.md b/REVIEW.md index e966293..29e9c5d 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -88,10 +88,10 @@ commit: important question in the review. The pitfalls encode silent-failure traps; if Iterable's docs now recommend something a pitfall warns against, one of the two is wrong. Fix it promptly — a corpus that argues with itself is live. -- [ ] **Did headings move?** `SKILL.md`'s routing table points at slugs, and the - upgrade row points at `## Upgrading the SDK` inside `android-sdk`. If a - referenced section was renamed, the routing table now points at nothing — - fix it in a follow-up. +- [ ] **Did headings move?** `tests/decision-docs.sh` fails when a slug or + section named in a Step 0 decisions table stops resolving. The routing tables + further down `SKILL.md` aren't checked: if a section they name was renamed, + they now point at nothing — fix it in a follow-up. - [ ] **The transform only reshaped, never changed meaning.** Boilerplate removed, callouts converted, blank lines collapsed — but no claim added, weakened, or reversed. A *content* change not explained by an upstream edit diff --git a/bin/README.md b/bin/README.md index 38e902d..4500ccb 100644 --- a/bin/README.md +++ b/bin/README.md @@ -83,7 +83,9 @@ front end. Shared helpers and presentation logic live in `bin/config.sh`. Device-specific checks live in `bin/gates-device.sh`; Iterable checks live in -`bin/gates-iterable.sh`. +`bin/gates-iterable.sh`. `bin/existing-push.js` finds an app's own push path in +its source for both platforms; a done verdict only offers an optional check +for it, and no gate reads it. ### Native iOS entry points @@ -109,7 +111,8 @@ evidence differ from Firebase and Android device evidence. Verification starts with `ios-verify status`, even in a clean conversation; with no proof run it returns the one-answer proof screen. `ios-verify inspect` reads the project without interpreting a missing `.iterable/ios/` workspace as a -missing SDK integration. Behavioral proof needs no app edits: existing +missing SDK integration; for React Native and Expo it reads `package.json`, +the JavaScript sources, and the native hooks in `ios/`, never `node_modules`. Behavioral proof needs no app edits: existing identity, selected device, installed app, one targeted marker, and, on a Simulator, APNs delivery read from the Simulator's own records by `ios-prove` and re-derived by `ios-verify`. A standalone run diff --git a/bin/config.sh b/bin/config.sh index ab42fec..4cce49f 100755 --- a/bin/config.sh +++ b/bin/config.sh @@ -161,6 +161,30 @@ load_env_file "$WS/resolved.env" : "${PID:=}" : "${PACKAGE:=}" + +# Prints "\t", or nothing when no single applicationId is readable. +project_package() { + local root="${PROJECT_ROOT:-$(dirname "$WS")}" f value="" source="" + if [[ -f "$root/app.json" ]]; then + value="$(node -e ' + try { + const app = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(app?.expo?.android?.package || "")); + } catch {} + ' "$root/app.json" 2>/dev/null)" + [[ -z "$value" ]] || source="app.json" + fi + if [[ -z "$value" ]]; then + for f in android/app/build.gradle android/app/build.gradle.kts app/build.gradle app/build.gradle.kts; do + [[ -f "$root/$f" ]] || continue + value="$(sed -nE "s/^[[:space:]]*applicationId[[:space:]]*(=[[:space:]]*)?[\"']([A-Za-z][A-Za-z0-9_.]*)[\"'].*/\\2/p" "$root/$f" | sort -u)" + source="$f" + break + done + fi + [[ -n "$value" && "$value" != *$'\n'* && "$value" == *.* ]] || return 0 + printf '%s\t%s' "$value" "$source" +} : "${SA_ID:=itbl-onboard-fcm}" : "${APP_DISPLAY_NAME:=Iterable Onboard Test}" @@ -1305,6 +1329,11 @@ next_action() { elif [[ -f "$WS/state.tsv" ]]; then a_owner=none; a_kind=done a_summary="a push reached the device — nothing left to do" + # Offered, never a gate: whether the app's own push still shows is the developer's call. + local follow + follow="$(node "$BIN/existing-push.js" scan android "${PROJECT_ROOT:-$(dirname "$WS")}" 2>/dev/null | + node "$BIN/existing-push.js" follow-up 2>/dev/null)" + [[ -z "$follow" ]] || a_summary="$a_summary. $follow" else a_owner=agent; a_kind=run_ladder; a_cmd="$BIN/agent" a_summary="nothing has been checked yet" diff --git a/bin/existing-push.js b/bin/existing-push.js new file mode 100755 index 0000000..4a831f3 --- /dev/null +++ b/bin/existing-push.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +// Finds an app's own (non-Iterable) push handling in its source. Heuristic, like +// every source signal; Iterable-only wiring, including an `itbl` branch, is not one. +// +// existing-push.js scan ios (NUL-separated file list on stdin) +// existing-push.js scan android (walks the app root itself) +// existing-push.js follow-up (a scan's JSON on stdin) +// +// `scan` prints a JSON array of {signal, file, detail}. `follow-up` prints the +// optional after-integration line for a done verdict, or nothing. +// +// iOS takes its list from ios_project_find_native so every iOS scan prunes alike. + +"use strict"; +const fs = require("fs"); +const path = require("path"); + +// Comments go; string bodies stay (for "itbl") with brackets blanked so braces still pair. +function strip(src) { + let out = "", i = 0; + while (i < src.length) { + if (src.startsWith("//", i)) { while (i < src.length && src[i] !== "\n") i++; continue; } + if (src.startsWith("/*", i)) { + const end = src.indexOf("*/", i + 2), stop = end < 0 ? src.length : end + 2; + out += src.slice(i, stop).replace(/[^\n]/g, " "); i = stop; continue; + } + if (src[i] === "\"") { + const q = src.startsWith("\"\"\"", i) ? "\"\"\"" : "\""; + let j = i + q.length; + while (j < src.length && !src.startsWith(q, j) && (q.length === 3 || src[j] !== "\n")) j += src[j] === "\\" ? 2 : 1; + const closed = src.startsWith(q, j); + out += q + src.slice(i + q.length, j).replace(/[(){}\[\]]/g, " ") + (closed ? q : ""); + i = closed ? j + q.length : j; continue; + } + out += src[i++]; + } + return out; +} + +function statements(body) { + const out = []; let cur = "", depth = 0; + for (const ch of body) { + if (ch === "(" || ch === "[") depth++; + else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1); + if (depth === 0 && "\n;{}".includes(ch)) { if (cur.trim()) out.push(cur.trim()); cur = ""; continue; } + cur += ch; + } + if (cur.trim()) out.push(cur.trim()); + return out; +} + +const plumbing = [ + /Iterable|\bITB|itbl/, + /^(return\s+)?(try\s+)?(await\s+)?(self\.)?(completionHandler|completion|handler|contentHandler|reply)\s*\??\s*\(/, + /\bcontinuation\s*\.\s*resume\b/, + /^(return\s+)?(super\s*\.|\[\s*super\b)/, + /^(print|debugPrint|NSLog|os_log)\s*\(/, + /^(self\.)?(log(ger)?|timber)\b/i, + /^(return\s+)?(try\s+)?(await\s+)?(Task|DispatchQueue\.[\w.]+\.async|MainActor\.run|with(Checked|Unsafe)(Throwing)?Continuation)\b/ +]; +const keywords = new Set(["if", "while", "for", "switch", "when", "guard", "return", "catch", "case", + "available", "sizeof", "await", "try", "synchronized"]); +const acts = s => [...s.matchAll(/([A-Za-z_]\w*)\s*\??\s*\(/g)].some(m => !keywords.has(m[1])) || + /\[\s*[A-Za-z_][\w.]*\s+[A-Za-z_]/.test(s); +const doesMore = body => statements(body).some(s => !plumbing.some(p => p.test(s)) && acts(s)); + +function bodyAt(code, open) { + let depth = 0; + for (let i = open; i < code.length; i++) { + if (code[i] === "{") depth++; + else if (code[i] === "}" && --depth === 0) return code.slice(open + 1, i); + } + return code.slice(open + 1); +} + +const providers = { + ios: [ + ["Firebase Messaging", /\bFirebaseMessaging\b|\bFirebase\/Messaging\b|\bMessaging\.messaging\s*\(/], + ["OneSignal", /\bOneSignal/], + ["Airship", /\bAirship(Kit|Core)\b|\bUAirship\b|\bAirship\.takeOff\b/], + ["Braze", /\bBrazeKit\b|\bAppboy/], + ["CleverTap", /\bCleverTap/], + ["MoEngage", /\bMoEngage/], + ["Pushwoosh", /\bPushwoosh/] + ], + // Not firebase-messaging: Iterable's own Android SDK depends on it. + android: [ + ["OneSignal", /\bcom\.onesignal\b/i], + ["Braze", /\bcom\.(braze|appboy)\b/], + ["Airship", /\bcom\.urbanairship\b/], + ["CleverTap", /\bcom\.clevertap\b/], + ["MoEngage", /\bcom\.moengage\b/], + ["Pushwoosh", /\bcom\.pushwoosh\b/] + ] +}; + +const jsProviders = [ + ["Firebase Messaging", "@react-native-firebase/messaging"], ["OneSignal", "react-native-onesignal"], + ["OneSignal", "onesignal-expo-plugin"], ["Airship", "@ua/react-native-airship"], ["Braze", "@braze/react-native-sdk"], + ["CleverTap", "clevertap-react-native"], ["MoEngage", "react-native-moengage"], ["Pushwoosh", "pushwoosh-react-native-plugin"], + ["push-notification-ios", "@react-native-community/push-notification-ios"], + ["react-native-push-notification", "react-native-push-notification"], + ["react-native-notifications", "react-native-notifications"], ["expo-notifications", "expo-notifications"] +]; + +function collector(root) { + const found = [], seen = new Set(); + const add = (signal, file, detail) => { + if (seen.has(signal + detail)) return; + seen.add(signal + detail); + found.push({ signal, file: path.relative(root, file), detail }); + }; + return { found, add }; +} + +function read(file) { try { return fs.readFileSync(file, "utf8") } catch { return null } } + +function packageJson(root, add) { + let pkg = {}; try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) } catch {} + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + for (const [name, dep] of jsProviders) if (dep in deps) + add("push_sdk", path.join(root, "package.json"), `${name} (${dep}) is a dependency; it may own or swizzle the notification callbacks`); +} + +function scanIos(root, files) { + const { found, add } = collector(root); + const callbacks = [ + [/^application$/, /\bdidRegisterForRemoteNotificationsWithDeviceToken\b/, "didRegisterForRemoteNotificationsWithDeviceToken"], + [/^application$/, /\bdidReceiveRemoteNotification\b/, "didReceiveRemoteNotification"], + [/^userNotificationCenter$/, /\bwillPresent(Notification)?\b/, "userNotificationCenter willPresent"], + [/^userNotificationCenter$/, /\bdidReceive(NotificationResponse)?\b/, "userNotificationCenter didReceive"] + ]; + for (const file of files) { + const src = read(file); if (src == null) continue; + const base = path.basename(file); + const code = /^(Podfile|project\.yml)$/.test(base) ? src.replace(/^\s*#.*$/gm, "") : strip(src); + for (const [name, re] of providers.ios) if (re.test(code)) add("push_sdk", file, `${name} is linked; it may own or swizzle the notification callbacks`); + if (!/\.(swift|mm?)$/.test(base)) continue; + const starts = /\bfunc\s+(application|userNotificationCenter)\s*\(|^[ \t]*-\s*\([^)]*\)\s*(application|userNotificationCenter)\s*:/gm; + for (const m of code.matchAll(starts)) { + const open = code.indexOf("{", m.index); + if (open < 0) continue; + const signature = code.slice(m.index, open), owner = m[1] || m[2]; + const callback = callbacks.find(([o, label]) => o.test(owner) && label.test(signature)); + if (callback && doesMore(bodyAt(code, open))) add("notification_callback", file, `${callback[2]} does more than forward to Iterable`); + } + if (/\boverride\s+func\s+didReceive\s*\(\s*_\s+\w+\s*:\s*UNNotificationRequest|-\s*\(\s*void\s*\)\s*didReceiveNotificationRequest\s*:/.test(code) && + /\bcontentHandler\s*\??\s*\(/.test(code)) + add("notification_service_extension", file, "a Notification Service Extension that hands back content itself, so it also handles non-Iterable payloads"); + } + packageJson(root, add); + return found; +} + +const androidPrune = new Set(["build", "node_modules", ".git", ".gradle", ".iterable", ".idea", "ios"]); +function walkAndroid(dir, out) { + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (androidPrune.has(e.name) || (path.basename(dir) === "src" && /^(test|androidTest)/.test(e.name))) continue; + walkAndroid(full, out); + } else if (/\.(kt|java|gradle|gradle\.kts|toml)$/.test(e.name) || e.name === "AndroidManifest.xml") { + out.push(full); + } + } + return out; +} + +// A body's opening brace after the parameter list, or an expression body (`= …`) to the end of the line. +function functionBody(code, at) { + let i = code.indexOf("(", at), depth = 0; + for (; i < code.length; i++) { + if (code[i] === "(") depth++; + else if (code[i] === ")" && --depth === 0) break; + } + const rest = code.slice(i + 1), lead = rest.match(/^[^{=;]*/)[0], next = rest[lead.length]; + if (next === "{") return bodyAt(code, i + 1 + lead.length); + if (next === "=") return rest.slice(lead.length + 1).split("\n")[0]; + return ""; +} + +function scanAndroid(root) { + const { found, add } = collector(root); + const files = walkAndroid(root, []); + const forwardOnly = new Set(), manifests = []; + for (const file of files) { + const src = read(file); if (src == null) continue; + if (path.basename(file) === "AndroidManifest.xml") { manifests.push([file, src]); continue; } + const code = file.endsWith(".toml") ? src.replace(/^\s*#.*$/gm, "") : strip(src); + for (const [name, re] of providers.android) if (re.test(code)) add("push_sdk", file, `${name} is linked; it may own or show pushes`); + if (!/\.(kt|java)$/.test(file)) continue; + for (const m of code.matchAll(/\bclass\s+(\w+)[^{]*?(:|\bextends)\s*(com\.google\.firebase\.messaging\.)?FirebaseMessagingService\b/g)) { + const own = [...code.matchAll(/\b(fun|void)\s+(onMessageReceived|onNewToken)\s*\(/g)] + .filter(f => doesMore(functionBody(code, f.index))).map(f => f[2]); + if (own.length) add("messaging_service", file, `${m[1]} handles ${own.join(" and ")} itself, beyond forwarding to Iterable`); + else forwardOnly.add(m[1]); + } + } + for (const [file, src] of manifests) { + const xml = src.replace(//g, ""); + for (const m of xml.matchAll(/]*)>([\s\S]*?)<\/service>/g)) { + const name = (m[1].match(/android:name\s*=\s*"([^"]+)"/) || [])[1] || ""; + if (!m[2].includes("com.google.firebase.MESSAGING_EVENT")) continue; + if (/Iterable/.test(name) || forwardOnly.has(name.split(".").pop())) continue; + add("messaging_event_filter", file, `${name || "a service"} receives com.google.firebase.MESSAGING_EVENT`); + } + } + packageJson(root, add); + return found; +} + +function followUp(found) { + if (!Array.isArray(found) || !found.length) return ""; + return `Iterable push is proven. The app also has its own push path (${found.map(f => `${f.detail} in ${f.file}`).join("; ")}). If the developer wants, offer the optional after-integration check that the app's own notifications still show.`; +} + +const [command, platform, root] = process.argv.slice(2); +const stdin = () => { try { return fs.readFileSync(0, "utf8") } catch { return "" } }; +if (command === "scan" && platform === "ios" && root) { + process.stdout.write(JSON.stringify(scanIos(root, stdin().split("\0").filter(Boolean)))); +} else if (command === "scan" && platform === "android" && root) { + process.stdout.write(JSON.stringify(scanAndroid(root))); +} else if (command === "follow-up") { + let found = []; try { found = JSON.parse(stdin()) } catch {} + process.stdout.write(followUp(found)); +} else { + process.stderr.write("usage: existing-push.js scan ios|android | follow-up\n"); + process.exit(2); +} diff --git a/bin/ios-app-check.sh b/bin/ios-app-check.sh new file mode 100644 index 0000000..f1abac3 --- /dev/null +++ b/bin/ios-app-check.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Integrity reads of an installed or recorded .app: does it carry the Iterable +# SDK and the push entitlement, is it the recorded build, and was it built +# after the latest source edit. Needs ios-project.sh and ios-simulator.sh. + +# Prints the evidence of the native SDK, or fails. The SDK loads its Core Data +# model from its resource bundle, so every build that links it ships one. +ios_app_sdk_signal() { + local app="$1" candidate + for candidate in IterableSDKResources.bundle IterableSDK_IterableSDK.bundle \ + Frameworks/IterableSDK.framework; do + [[ ! -d "$app/$candidate" ]] || { printf '%s' "$candidate"; return 0; } + done + return 1 +} + +# The JavaScript package reaches native code only through this TurboModule +# name, so it survives minification and Hermes compilation. +ios_app_js_has_sdk() { + LC_ALL=C grep -aFq RNIterableAPI "$1/main.jsbundle" 2>/dev/null +} + +ios_app_executable() { + local name + name="$(plutil -extract CFBundleExecutable raw -o - "$1/Info.plist" 2>/dev/null)" && + [[ -n "$name" && -f "$1/$name" ]] || return 1 + printf '%s' "$1/$name" +} + +ios_app_simulator_aps_environment() { + local executable entitlements + executable="$(ios_app_executable "$1")" || return 1 + entitlements="$(ios_simulator_executable_entitlements "$executable")" || return 1 + printf '%s' "$entitlements" | plutil -extract aps-environment raw -o - - 2>/dev/null +} + +ios_app_time() { + date -u -r "$1" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || printf '%s' "$1" +} + +# The newest native binary in the app. An edit to an extension's or embedded +# framework's source relinks only that binary, not the main executable. +ios_app_newest_binary() { + local app="$1" main + main="$(ios_app_executable "$app")" || return 1 + { printf '%s\0' "$main" + find "$app/PlugIns" "$app/Frameworks" -mindepth 2 -maxdepth 2 -type f -perm -100 -print0 2>/dev/null + } | xargs -0 stat -f '%m%t%N' 2>/dev/null | sort -n | tail -1 | cut -f2- +} + +# Prints "\t" for the newest source newer than the reference +# file, or nothing. +ios_app_newer_source() { + local root="$1" reference="$2" kind="$3" scan_root + if [[ "$kind" == js ]]; then + ios_project_find_js "$root" \ + \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' \) -newer "$reference" + else + scan_root="$(ios_project_ios_dir "$root")" + [[ -z "$scan_root" ]] || + ios_project_find_native "$scan_root" \ + \( -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name '*.c' -o -name '*.cc' -o -name '*.cpp' \) \ + -newer "$reference" + fi | xargs -0 stat -f '%m%t%N' 2>/dev/null | sort -n | tail -1 +} + +ios_app_rebuild_fix() { + local root="$1" bin="$2" + printf 'Build Release with signing on (never CODE_SIGNING_ALLOWED=NO, which drops the aps-environment entitlement push needs): %s. Then record that exact .app with %s/ios-prepare-proof restart --confirm --app-path and install it with %s/ios-install.' \ + "$(ios_project_release_build_hint "$root" iphonesimulator)" "$bin" "$bin" +} + +# ios_app_installed_integrity +# +# On failure prints "\t" and returns 1; on success prints +# the evidence. +ios_app_installed_integrity() { + local app="$1" root="$2" framework="$3" environment="$4" recorded="$5" + local check_aps="$6" bin="$7" fix sdk aps="" kind built newer + local installed_executable recorded_executable + fix="$(ios_app_rebuild_fix "$root" "$bin")" + if [[ -n "$recorded" ]]; then + installed_executable="$(ios_app_executable "$app")" || installed_executable="" + recorded_executable="$(ios_app_executable "$recorded")" || { + printf 'installed_app_differs_from_recorded_build\tThe recorded build %s has no readable executable, so the installed app cannot be tied to it. %s\n' \ + "$recorded" "$fix" + return 1 + } + if [[ -z "$installed_executable" || + "${installed_executable##*/}" != "${recorded_executable##*/}" || + "$(shasum -a 256 < "$installed_executable")" != "$(shasum -a 256 < "$recorded_executable")" ]] || + { [[ "$framework" != native ]] && + [[ "$(shasum -a 256 < "$app/main.jsbundle" 2>/dev/null)" != "$(shasum -a 256 < "$recorded/main.jsbundle" 2>/dev/null)" ]]; }; then + printf 'installed_app_differs_from_recorded_build\tThe app installed on the Simulator is not byte-identical to the recorded build %s. Install the recorded build with %s/ios-install; if that build is not current either: %s\n' \ + "$recorded" "$bin" "$fix" + return 1 + fi + fi + sdk="$(ios_app_sdk_signal "$app")" || { + printf 'installed_app_without_sdk\tThe app installed on the Simulator has no Iterable iOS SDK (no IterableSDKResources.bundle, IterableSDK_IterableSDK.bundle, or Frameworks/IterableSDK.framework), so it is a build from before the integration. %s\n' \ + "$fix" + return 1 + } + if [[ "$framework" != native && ! -f "$app/main.jsbundle" ]]; then + printf 'installed_app_without_sdk\tThe installed app has no embedded main.jsbundle, so it is a Debug build that runs only while Metro serves its JavaScript. %s\n' \ + "$fix" + return 1 + fi + if [[ "$framework" != native ]] && ! ios_app_js_has_sdk "$app"; then + printf 'installed_app_without_sdk\tThe installed app'"'"'s main.jsbundle never references RNIterableAPI, so its JavaScript was bundled without @iterable/react-native-sdk; it is a build from before the integration. %s\n' \ + "$fix" + return 1 + fi + if [[ "$check_aps" == true ]]; then + aps="$(ios_app_simulator_aps_environment "$app")" || { + printf 'installed_app_missing_aps_environment\tThe app installed on the Simulator has no aps-environment entitlement, so iOS cannot register it for push; a build made with CODE_SIGNING_ALLOWED=NO or without the Push Notifications capability lacks it. %s\n' \ + "$fix" + return 1 + } + [[ "$aps" == "$environment" ]] || { + printf 'installed_app_missing_aps_environment\tThe app installed on the Simulator uses aps-environment=%s, not %s. %s\n' \ + "$aps" "$environment" "$fix" + return 1 + } + fi + for kind in native js; do + if [[ "$kind" == native ]]; then + built="$(ios_app_newest_binary "$app")" + else + [[ "$framework" != native ]] || continue + built="$app/main.jsbundle" + fi + [[ -n "$built" && -f "$built" ]] || continue + newer="$(ios_app_newer_source "$root" "$built" "$kind")" + [[ -n "$newer" ]] || continue + printf 'installed_app_older_than_source\tThe installed app is older than its source: %s was built at %s, but %s changed at %s. %s\n' \ + "${built#"$app"/}" "$(ios_app_time "$(stat -f '%m' "$built")")" \ + "${newer#*$'\t'}" "$(ios_app_time "${newer%%$'\t'*}")" "$fix" + return 1 + done + printf 'Iterable SDK via %s' "$sdk" + [[ "$framework" == native ]] || printf ' and RNIterableAPI in main.jsbundle' + [[ "$check_aps" != true ]] || printf '; aps-environment=%s' "$aps" + printf '; built after the latest source edit\n' +} + +# The same SDK read for a recorded artifact, before it is installed. Prints +# the reason and succeeds when the SDK is missing. +ios_app_artifact_without_sdk() { + local app="$1" root="$2" framework="$3" sdk="$4" bin="$5" fix + printf -v fix 'rebuild it with signing on (never CODE_SIGNING_ALLOWED=NO): %s, then record it with %s/ios-prepare-proof restart --confirm --app-path ' \ + "$(ios_project_release_build_hint "$root" "$sdk")" "$bin" + if ! ios_app_sdk_signal "$app" >/dev/null; then + printf 'the recorded .app has no Iterable iOS SDK (no IterableSDKResources.bundle, IterableSDK_IterableSDK.bundle, or Frameworks/IterableSDK.framework), so it was built before the integration; %s\n' "$fix" + elif [[ "$framework" != native ]] && ! ios_app_js_has_sdk "$app"; then + printf 'the recorded .app'"'"'s main.jsbundle never references RNIterableAPI, so its JavaScript was bundled without @iterable/react-native-sdk; %s\n' "$fix" + else + return 1 + fi +} diff --git a/bin/ios-deliver b/bin/ios-deliver index 006c078..45fd5e4 100755 --- a/bin/ios-deliver +++ b/bin/ios-deliver @@ -9,6 +9,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -97,7 +98,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-device b/bin/ios-device index bdbf5e1..6d7f5e2 100755 --- a/bin/ios-device +++ b/bin/ios-device @@ -5,6 +5,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -54,7 +55,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-install b/bin/ios-install index 988b5ab..e961efe 100755 --- a/bin/ios-install +++ b/bin/ios-install @@ -5,6 +5,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -50,7 +51,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-launch b/bin/ios-launch index 041bb91..eacd0cd 100755 --- a/bin/ios-launch +++ b/bin/ios-launch @@ -9,6 +9,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -67,7 +68,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-observe b/bin/ios-observe index 0e96ff9..2f60553 100755 --- a/bin/ios-observe +++ b/bin/ios-observe @@ -11,6 +11,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" source "$BIN/ios-simulator.sh" AGENT_DEVICE="${IOS_AGENT_DEVICE:-$TOOL_ROOT/tools/agent-device}" @@ -192,7 +193,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being verified, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-prepare-proof b/bin/ios-prepare-proof index b386cb9..a0f2486 100755 --- a/bin/ios-prepare-proof +++ b/bin/ios-prepare-proof @@ -11,8 +11,9 @@ BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { cat <<'EOF' Usage: - ios-prepare-proof status [--workspace PATH] - ios-prepare-proof proof-status [--workspace PATH] + ios-prepare-proof status [--identity-candidate VALUE ...] [--workspace PATH] + ios-prepare-proof proof-status [--identity-candidate VALUE ...] [--workspace PATH] + ios-prepare-proof read-identity --confirm [--identity-candidate VALUE ...] [--workspace PATH] ios-prepare-proof answer [--field FIELD --value VALUE | --clear] [--workspace PATH] ios-prepare-proof proof-answer [--field FIELD --value VALUE | --clear] [--workspace PATH] ios-prepare-proof start --confirm [--workspace PATH] @@ -26,6 +27,12 @@ diagnostics with the debug-only runtime harness: ios-prepare-proof answer --field proof-mode --value instrumented +On a Simulator, the identity screen offers an identity only after Iterable +shows this Simulator's current APNs token on that user. --identity-candidate +proposes one the agent read from the device; read-identity relaunches the app +with -iterableProofIdentity YES so the integration's ITBL_PROOF marker logs it. +Nothing is recorded until the developer chooses it. + Credential capture remains explicit and separate: ios-provision capture-server --confirm @@ -43,6 +50,9 @@ case "$command" in answer|proof-answer) exec "$BIN/ios-provision" proof-answer "$@" ;; + read-identity) + exec "$BIN/ios-provision" proof-read-identity "$@" + ;; start) exec "$BIN/ios-provision" record-proof --from-answers "$@" ;; diff --git a/bin/ios-project.sh b/bin/ios-project.sh new file mode 100644 index 0000000..8ef7916 --- /dev/null +++ b/bin/ios-project.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Shared answer to "where is the app, where is its Xcode project, and what kind +# of app is it" for every iOS actor. A native app keeps its Xcode project at the +# root; React Native keeps it in ios/; Expo may have no ios/ at all until +# prebuild. The verification workspace always lives at /.iterable/ios. + +ios_project_looks_like_ios() { + local candidate="$1" + [[ -n "$candidate" && -d "$candidate" ]] || return 1 + [[ -f "$candidate/project.yml" ]] || + compgen -G "$candidate/*.xcodeproj/project.pbxproj" >/dev/null || + compgen -G "$candidate/*.xcworkspace" >/dev/null +} + +# Prints react-native, expo, or nothing for a directory's package.json/app.json. +ios_project_js_kind() { + local dir="$1" + [[ -f "$dir/package.json" || -f "$dir/app.json" ]] || return 0 + node -e ' + const fs=require("fs"), path=require("path"), dir=process.argv[1]; + const read=file=>{ try { return JSON.parse(fs.readFileSync(path.join(dir,file),"utf8")) } catch { return null } }; + const pkg=read("package.json")||{}, app=read("app.json"); + const deps={...pkg.dependencies,...pkg.devDependencies}; + if ("expo" in deps || (app && typeof app.expo==="object")) process.stdout.write("expo"); + else if ("react-native" in deps || "@iterable/react-native-sdk" in deps) process.stdout.write("react-native"); + ' "$dir" 2>/dev/null +} + +# The app root: walking up from the start directory (no further than the git +# toplevel), the nearest React Native/Expo root, or the nearest iOS project +# that is not the ios/ folder of one. +ios_project_app_root() { + local start="${1:-$PWD}" stop dir parent + start="$(cd "$start" 2>/dev/null && pwd -P)" || return 1 + stop="$(cd "$start" && git rev-parse --show-toplevel 2>/dev/null)" || stop="/" + stop="$(cd "$stop" 2>/dev/null && pwd -P)" || stop="/" + dir="$start" + while :; do + if [[ -n "$(ios_project_js_kind "$dir")" ]]; then + printf '%s' "$dir" + return 0 + fi + if ios_project_looks_like_ios "$dir"; then + parent="$(dirname "$dir")" + if [[ "$(basename "$dir")" == ios && -n "$(ios_project_js_kind "$parent")" ]]; then + printf '%s' "$parent" + else + printf '%s' "$dir" + fi + return 0 + fi + [[ "$dir" != "$stop" && "$dir" != / ]] || break + dir="$(dirname "$dir")" + done + [[ "$stop" != / ]] && printf '%s' "$stop" || printf '%s' "$start" +} + +# The directory holding the Xcode or XcodeGen project: the app root itself, or +# its ios/ folder. Empty for an Expo app that has not been prebuilt. +ios_project_ios_dir() { + local root="$1" + if ios_project_looks_like_ios "$root"; then + printf '%s' "$root" + elif ios_project_looks_like_ios "$root/ios"; then + printf '%s' "$root/ios" + fi +} + +ios_project_framework() { + local kind + kind="$(ios_project_js_kind "$1")" + printf '%s' "${kind:-native}" +} + +# Expo's ios/ is regenerated and often gitignored, so app.json is the source of +# its Bundle ID. +ios_project_expo_bundle_id() { + local root="$1" + [[ -f "$root/app.json" ]] || return 0 + node -e ' + let v; try { v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")) } catch { process.exit(0) } + const id=String(v?.expo?.ios?.bundleIdentifier||""); + if (/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)) process.stdout.write(id); + ' "$root/app.json" 2>/dev/null +} + +ios_project_json() { + local root ios_dir framework bundle + root="$(ios_project_app_root "${1:-$PWD}")" || return 1 + ios_dir="$(ios_project_ios_dir "$root")" + framework="$(ios_project_framework "$root")" + bundle="" + [[ "$framework" != expo ]] || bundle="$(ios_project_expo_bundle_id "$root")" + node -e ' + const [root,iosDir,framework,bundle]=process.argv.slice(1); + process.stdout.write(JSON.stringify({app_root:root,ios_dir:iosDir||null,framework, + expo_bundle_id:bundle||null,workspace:`${root}/.iterable/ios`})); + ' "$root" "$ios_dir" "$framework" "$bundle" +} + +# A Debug React Native build for the Simulator skips embedding main.jsbundle +# and needs Metro; a Release build runs on its own. +ios_project_release_build_hint() { + local root="$1" sdk="${2:-iphonesimulator}" ios_dir container flag name + ios_dir="$(ios_project_ios_dir "$root")" + if [[ -z "$ios_dir" ]]; then + printf 'cd %q && npx expo prebuild --platform ios, then xcodebuild -workspace ios/.xcworkspace -scheme -configuration Release -sdk %s -derivedDataPath ios/build build' \ + "$root" "$sdk" + return + fi + container="$(compgen -G "$ios_dir/*.xcworkspace" | head -1)" + flag=-workspace + if [[ -z "$container" ]]; then + container="$(compgen -G "$ios_dir/*.xcodeproj" | head -1)" + flag=-project + fi + name="$(basename "${container%.*}")" + printf 'cd %q && xcodebuild %s %q -scheme %q -configuration Release -sdk %s -derivedDataPath build build (the app is %s/build/Build/Products/Release-%s/%s.app)' \ + "$ios_dir" "$flag" "$(basename "$container")" "$name" "$sdk" "$ios_dir" "$sdk" "$name" +} + +# Source walks skip dependencies, build output, tests, and the verification +# workspace; inspect and the installed-app freshness check share them. The +# arguments are find predicates for the files wanted. +ios_project_find_native() { + local root="$1" + shift + find "$root" \ + \( -path '*/.git' -o -path '*/.derived-data' -o -path '*/DerivedData' \ + -o -path '*/Pods' -o -path '*/.build' -o -path '*/node_modules' -o -path '*/build' \ + -o -path '*/.iterable' \ + -o -path '*/*Tests' -o -path '*/*Tests/*' \ + -o -path '*/*UITests' -o -path '*/*UITests/*' \) -prune -o \ + \( "$@" \) -type f -print0 2>/dev/null +} + +ios_project_find_js() { + local root="$1" + shift + find "$root" \ + \( -path "$root/ios" -o -path "$root/android" -o -path '*/node_modules' \ + -o -path '*/.git' -o -path '*/.expo' -o -path '*/build' -o -path '*/dist' \ + -o -path '*/.iterable' \ + -o -path '*/coverage' -o -path '*/__tests__' -o -path '*/__mocks__' -o -path '*/e2e' \) -prune -o \ + \( "$@" \) ! -name '*.test.*' ! -name '*.spec.*' -type f -print0 2>/dev/null +} + +# The app's own push handling, as a JSON array of {signal, file, detail}; see +# existing-push.js for the signals. +ios_project_existing_push() { + local root="$1" scan_root + scan_root="$(ios_project_ios_dir "$root")" + [[ -n "$scan_root" || -n "$(ios_project_js_kind "$root")" ]] || scan_root="$root" + { [[ -z "$scan_root" ]] || + ios_project_find_native "$scan_root" \ + -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name '*.h' -o -name 'project.pbxproj' \ + -o -name 'project.yml' -o -name 'Package.swift' -o -name 'Podfile' + true; } | + node "$(dirname "${BASH_SOURCE[0]}")/existing-push.js" scan ios "$root" 2>/dev/null || printf '[]' +} + +# Relative paths of app sources that still hold the temporary ITBL_PROOF +# identity marker, one per line. The marker is proof scaffolding, so inspect and +# the done summary keep naming it until it is removed. +ios_project_proof_marker_files() { + local root="$1" ios_dir + ios_dir="$(ios_project_ios_dir "$root")" + [[ -n "$ios_dir" ]] || ios_dir="$root" + { + ios_project_find_native "$ios_dir" \( -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name '*.h' \) + [[ -z "$(ios_project_js_kind "$root")" ]] || + ios_project_find_js "$root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' \) + } | xargs -0 grep -l 'ITBL_PROOF' 2>/dev/null | sed "s|^$root/||" | sort -u +} + +# Prints with a proof_identity_marker_present entry added when the +# marker is still in the app source. +ios_project_proof_marker_warning() { + local root="$1" warnings="$2" files + files="$(ios_project_proof_marker_files "$root")" + [[ -n "$files" ]] || { printf '%s' "$warnings"; return 0; } + node -e ' + const [warnings, files] = [JSON.parse(process.argv[1]), process.argv[2].split("\n")]; + warnings.push({kind: "proof_identity_marker_present", files, + summary: `The temporary ITBL_PROOF identity marker is still in the app (${files.join(", ")}). It logs only when the app is launched with -iterableProofIdentity YES, but it is proof scaffolding: remove it once the push is proved.`}); + process.stdout.write(JSON.stringify(warnings)); + ' "$warnings" "$files" +} + +# A sentence for the done summary while the marker remains, or nothing. +ios_project_proof_marker_note() { + local files + files="$(ios_project_proof_marker_files "$1" | paste -sd, - | sed 's/,/, /g')" + [[ -z "$files" ]] || + printf ' Remove the temporary ITBL_PROOF identity marker now that the proof is done (proof_identity_marker_present: %s).' "$files" +} diff --git a/bin/ios-prove b/bin/ios-prove index 2d5db3c..1ebca00 100755 --- a/bin/ios-prove +++ b/bin/ios-prove @@ -11,7 +11,9 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" source "$BIN/ios-simulator.sh" +source "$BIN/ios-app-check.sh" READER="$BIN/ios-notify-read.js" usage() { @@ -97,7 +99,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" @@ -473,9 +475,15 @@ ios_simulator_host_supports_apns || ios_simulator_device_supports_apns "$simulators" "$DEVICE_ID" || pending_without_send simulator_not_ready \ "The selected Simulator must be booted, available, and on an iOS 16+ runtime. Boot it, then run ios-prove again; nothing was sent." -xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" app >/dev/null 2>&1 || +INSTALLED_APP="$(xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" app 2>/dev/null)" || pending_without_send app_not_installed \ "$BUNDLE_ID is not installed on the selected Simulator. Build and install it, then run ios-prove again; nothing was sent." +# A stale or unsigned build fails every notification check below for a reason +# no permission fix can reach. +APP_ROOT="$(dirname "$(dirname "$WORKSPACE")")" +INTEGRITY="$(ios_app_installed_integrity "$INSTALLED_APP" "$APP_ROOT" \ + "$(ios_project_framework "$APP_ROOT")" "$ENVIRONMENT" "$(read_target app_path)" true "$BIN")" || + pending_without_send "${INTEGRITY%%$'\t'*}" "${INTEGRITY#*$'\t'} Nothing was sent." STORE_BEFORE="$(read_store)" [[ "$(json_field "$STORE_BEFORE" mapped)" == true ]] || diff --git a/bin/ios-provision b/bin/ios-provision index 890b354..adb58d2 100755 --- a/bin/ios-provision +++ b/bin/ios-provision @@ -5,14 +5,17 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" source "$BIN/ios-simulator.sh" +source "$BIN/ios-project.sh" +source "$BIN/ios-app-check.sh" FASTLANE_VERSION="2.240.1" PRIVATE_APNS_ACTOR="$TOOL_ROOT/tools/ios-apns-private.rb" usage() { cat <<'EOF' Usage: - ios-provision status [--offline] [--bundle-id ID] [--workspace PATH] - ios-provision question --kind KIND [--workspace PATH] + ios-provision status [--offline] [--bundle-id ID] [--features ID,...] [--identity MODE] [--workspace PATH] + ios-provision question --kind KIND [--features ID,...] [--identity MODE] [--workspace PATH] + ios-provision carry-scope [--features ID,...] [--identity email|user-id|unknown-user] --confirm [--workspace PATH] ios-provision answer [--field FIELD --value VALUE [--add] | --clear] [--workspace PATH] ios-provision record-scope [--from-answers | --features LIST --identity-mode MODE --region US|EU --jwt-enabled true|false] --confirm [--workspace PATH] ios-provision record-iterable-setup --driver guided|developer --confirm [--workspace PATH] @@ -30,9 +33,13 @@ Usage: ios-provision validate-apns [--workspace PATH] ios-provision capture-mobile --region US|EU --jwt-enabled true|false [--replace] --confirm [--workspace PATH] ios-provision validate-mobile [--workspace PATH] + ios-provision import-from-android --confirm [--features ID,...] [--identity MODE] [--framework native|react-native|expo] [--workspace PATH] + ios-provision deliver-mobile-key [--framework native|react-native|expo] [--workspace PATH] + ios-provision ignore-expo-env --confirm [--workspace PATH] ios-provision capture-server [--replace] --confirm [--workspace PATH] ios-provision validate-server [--workspace PATH] - ios-provision proof-status [--workspace PATH] + ios-provision proof-status [--identity-candidate VALUE ...] [--workspace PATH] + ios-provision proof-read-identity --confirm [--identity-candidate VALUE ...] [--workspace PATH] ios-provision proof-answer [--field FIELD --value VALUE | --clear] [--workspace PATH] ios-provision record-proof --proof-mode behavioral|instrumented --bundle-id ID --identity-type email|user-id --identity VALUE @@ -50,7 +57,9 @@ Usage: status and question are read-only and make no Apple or Iterable request. question --kind help_* renders a guide for a step the developer is stuck on; its options record the answer or return to status. -answer persists proposed selector answers; +answer persists proposed selector answers; carry-scope proposes the features +and identity mode the app's skill already confirmed, passed to status as +--features and --identity, so their screens are not asked again; record-scope confirms the developer's selected features, identity mode, region, and JWT policy before application code changes. record-iterable-setup records whether the coding agent will guide the @@ -74,8 +83,11 @@ create, or revoke Apple credentials. validate-apns signs a provider token with it and sends one probe to a random device token in the APNs sandbox: BadDeviceToken proves the key, Key ID, and Team ID, not the App ID. capture-mobile moves a copied Iterable Mobile API key from the clipboard into -the ignored iOS workspace without printing it. It also writes an ignored -IterableSecrets.xcconfig that the app can include directly. +the ignored iOS workspace without printing it, and writes the copy the app +reads: IterableSecrets.xcconfig for a native app, .iterable/rn/iterable.local.ts +for bare React Native, or EXPO_PUBLIC_ITERABLE_API_KEY in a gitignored +.env.local for Expo. import-from-android reuses the keys and region an Android +setup in the same project recorded. validate-mobile proves the captured value authenticates as a Mobile key in the recorded region, using an empty request and a deliberately invalid control. capture-server and validate-server prepare a separate Iterable verification @@ -88,6 +100,11 @@ expectations, and a second marker. Neither mode asserts that an outcome happened. restart-proof preserves standard identity/device choices while rotating run-scoped state; instrumented restart also creates fresh identities and baselines. +On a Simulator, proof-status offers an identity only after Iterable shows this +Simulator's current APNs token on that user. Candidates come from the app's +ITBL_PROOF marker, the Iterable SDK's own log lines, and --identity-candidate; +with none, it makes no request. proof-read-identity relaunches the app with +-iterableProofIdentity YES so the marker logs the identity it signs in with. experimental-apns-* is an explicit opt-in path that pins Fastlane Spaceship and uses unsupported Apple web-session endpoints. It never revokes existing keys, @@ -142,26 +159,25 @@ file_mode() { } looks_like_ios_project() { - local candidate="$1" - [[ -f "$candidate/project.yml" ]] || - compgen -G "$candidate/*.xcodeproj/project.pbxproj" >/dev/null || - compgen -G "$candidate/*.xcworkspace" >/dev/null + ios_project_looks_like_ios "$1" +} + +# An app root is an iOS project, or a React Native/Expo root whose project +# lives in ios/ (or, for Expo, has not been generated yet). +looks_like_app_root() { + ios_project_looks_like_ios "$1" || [[ -n "$(ios_project_js_kind "$1")" ]] } verification_project_root() { local root workspace_root="" if [[ -n "${IOS_WORKSPACE:-}" ]]; then workspace_root="$(cd "$IOS_WORKSPACE/../.." 2>/dev/null && pwd || true)" - if [[ -n "$workspace_root" ]] && looks_like_ios_project "$workspace_root"; then + if [[ -n "$workspace_root" ]] && looks_like_app_root "$workspace_root"; then printf '%s' "$workspace_root" return fi fi - root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" - if [[ "$root" == "$TOOL_ROOT" && "$PWD" != "$TOOL_ROOT" ]] && - looks_like_ios_project "$PWD"; then - root="$PWD" - fi + root="$(ios_project_app_root "$PWD")" || root="$PWD" printf '%s' "$root" } @@ -170,6 +186,7 @@ verification_source_files() { find "$root" \ \( -path '*/.git' -o -path '*/.iterable' -o -path '*/.derived-data' \ -o -path '*/DerivedData' -o -path '*/Pods' -o -path '*/.build' \ + -o -path '*/node_modules' -o -path '*/build' \ -o -path '*/*Tests' -o -path '*/*Tests/*' \ -o -path '*/*UITests' -o -path '*/*UITests/*' \) \ -prune -o \ @@ -179,7 +196,12 @@ verification_source_files() { } verification_detect_bundle() { - local root="$1" values="" project value="" + local app_root="$1" root values="" project value="" + root="$(ios_project_ios_dir "$app_root")" + if [[ -z "$root" ]]; then + ios_project_expo_bundle_id "$app_root" + return + fi if [[ -f "$root/project.yml" ]]; then values="$( sed -nE 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER:[[:space:]]*"?([^"#[:space:]]+)"?.*/\1/p' \ @@ -187,7 +209,7 @@ verification_detect_bundle() { )" fi if [[ -z "$values" ]]; then - project="$(find "$root" -path '*/.git' -prune -o \ + project="$(find "$root" -maxdepth 2 \( -path '*/.git' -o -path '*/Pods' -o -path '*/node_modules' \) -prune -o \ -name project.pbxproj -type f -print 2>/dev/null | head -1)" if [[ -n "$project" ]]; then values="$( @@ -201,26 +223,28 @@ verification_detect_bundle() { grep -Eiv '([.]tests?|[.]uitests?)$' | sort -u | awk 'NR==1{value=$0} NR>1{multiple=1} END{if(!multiple) print value}')" - valid_bundle_id "$value" && printf '%s' "$value" + if valid_bundle_id "$value"; then + printf '%s' "$value" + elif [[ "$(ios_project_framework "$app_root")" == expo ]]; then + ios_project_expo_bundle_id "$app_root" + fi } +# The directory holding the app's Xcode project (the app root, or ios/ in a +# React Native or prebuilt Expo app). app_project_root() { - local candidate workspace_root="" + local root="" workspace_root="" ios_dir if [[ -n "${WORKSPACE:-}" ]]; then workspace_root="$(cd "$WORKSPACE/../.." 2>/dev/null && pwd || true)" - if [[ -n "$workspace_root" ]] && looks_like_ios_project "$workspace_root"; then - printf '%s' "$workspace_root" - return - fi + [[ -n "$workspace_root" ]] && looks_like_app_root "$workspace_root" && root="$workspace_root" fi - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then - printf '%s' "$PWD" - return + if [[ -z "$root" ]]; then + root="$(ios_project_app_root "$PWD")" || return 1 fi - candidate="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 - [[ "$candidate" != "$TOOL_ROOT" ]] && looks_like_ios_project "$candidate" || - return 1 - printf '%s' "$candidate" + [[ "$root" != "$TOOL_ROOT" ]] || return 1 + ios_dir="$(ios_project_ios_dir "$root")" + [[ -n "$ios_dir" ]] || return 1 + printf '%s' "$ios_dir" } DETECTED_TARGET="" @@ -252,6 +276,8 @@ load_detected_target() { "${projects[0]}/project.pbxproj" 2>/dev/null | sort -u)" fi [[ -n "${teams:-}" && "$teams" != *$'\n'* ]] && static_team="$teams" + else + static_bundle="$(verification_detect_bundle "$(verification_project_root)")" fi DETECTED_TARGET="$(node -e ' const [raw, staticBundle, staticName, staticTeam] = process.argv.slice(1); @@ -373,9 +399,9 @@ resolve_workspace() { return fi local project_root - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" if [[ "$project_root" == "$TOOL_ROOT" && -z "${IOS_ALLOW_TOOL_ROOT:-}" ]]; then - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then + if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_app_root "$PWD"; then project_root="$PWD" else cat >&2 <= 2)) || die "--framework needs a value" + set_framework "$2" + shift 2 + ;; + --framework=*) + set_framework "${1#*=}" + shift + ;; --strategy) (($# >= 2)) || die "--strategy needs a value" APPLE_STRATEGY="$2" @@ -646,6 +684,15 @@ read_args() { PROOF_IDENTITY="$2" shift 2 ;; + --identity-candidate) + (($# >= 2)) || die "--identity-candidate needs a value" + PROOF_IDENTITY_CANDIDATES+=("$2") + shift 2 + ;; + --identity-candidate=*) + PROOF_IDENTITY_CANDIDATES+=("${1#*=}") + shift + ;; --identity=*) PROOF_IDENTITY="${1#*=}" shift @@ -867,6 +914,62 @@ normalize_features() { ' "$1" } +# Strict: only the ids the choose_features and choose_more_features screens record. +carried_features() { + node -e ' + const allowed = new Set([ + "sdk-initialization", "push-notifications", "event-tracking", "user-profiles", + "deep-linking", "in-app-messages", "mobile-inbox", "embedded-messaging" + ]); + const values = process.argv[1].split(",").map(value => value.trim()); + if (values.some(value => !allowed.has(value))) process.exit(2); + process.stdout.write([...new Set(["sdk-initialization", ...values])].sort().join(",")); + ' "$1" +} + +carried_identity() { + [[ "$1" == email || "$1" == user-id || "$1" == unknown-user ]] || return 2 + printf '%s' "$1" +} + +# The skill that owns the app ran Step 0; the label must name that skill. +step0_source() { + if [[ "$(ios_framework)" == native ]]; then + printf 'ios-step0' + else + printf 'react-native-step0' + fi +} + +# Shared by carry-scope and import-from-android. Each carried field keeps its +# own source, so changing one answer drops only that attribution. +propose_step0() { + node -e ' + const [current, features, identity, source] = process.argv.slice(1); + const value = JSON.parse(current); + if (features) { + delete value.feature_choices; + value.features = features.split(","); + value.scope_source = source; + } + if (identity) { + value.identity_mode = identity; + value.identity_source = source; + } + if (features || identity) value.step0_scope = "carried"; + process.stdout.write(JSON.stringify(value, null, 2) + "\n"); + ' "$1" "$2" "$3" "$4" +} + +# Refused before anything is read or written. +read_step0_flags() { + CARRY_IDENTITY="$PROOF_IDENTITY" + [[ -z "$FEATURES" ]] || carried_features "$FEATURES" >/dev/null || + die "--features takes the ids choose_features records: push-notifications, event-tracking, user-profiles, deep-linking, in-app-messages, mobile-inbox, embedded-messaging, or sdk-initialization" + [[ -z "$CARRY_IDENTITY" ]] || carried_identity "$CARRY_IDENTITY" >/dev/null || + die "--identity takes the values choose_identity records: email, user-id, or unknown-user" +} + answer_scope() { command -v node >/dev/null 2>&1 || die "node is not installed" if [[ "$ANSWER_CLEAR" == true && -z "$ANSWER_FIELD" ]]; then @@ -910,6 +1013,14 @@ answer_scope() { "$ANSWER_VALUE" == false ]] || die "$ANSWER_FIELD must be true or false" ;; + android-import) + [[ "$ANSWER_CLEAR" == true || "$ANSWER_VALUE" == skip ]] || + die "android-import can only be answered with skip; run import-from-android to reuse the Android setup" + ;; + step0-scope) + [[ "$ANSWER_CLEAR" == true || "$ANSWER_VALUE" == skip ]] || + die "step0-scope can only be answered with skip; run carry-scope to record the confirmed answers" + ;; *) die "unknown answer field: $ANSWER_FIELD" ;; @@ -931,9 +1042,13 @@ answer_scope() { "identity-mode": "identity_mode", "region": "data_region", "jwt-enabled": "jwt_enabled", - "jwt-check": "jwt_check" + "jwt-check": "jwt_check", + "android-import": "android_import", + "step0-scope": "step0_scope" }[field]; const featureKey=field.startsWith("feature-") ? field.slice(8) : ""; + if (field === "features" || featureKey) delete current.scope_source; + if (field === "identity-mode") delete current.identity_source; if (clear) { if (featureKey) { if (current.feature_choices) delete current.feature_choices[featureKey]; @@ -1010,6 +1125,10 @@ proof_answer() { identity|switch-identity|device|app-path|profile-field|event|tap-expect|logout-expect) ((${#ANSWER_VALUE} <= 500)) || die "proof answer is too long" ;; + app-source) + [[ "$ANSWER_CLEAR" == true || "$ANSWER_VALUE" == installed ]] || + die "proof app-source must be installed" + ;; *) die "unknown proof-answer field: $ANSWER_FIELD" ;; @@ -1033,6 +1152,7 @@ proof_answer() { "switch-identity":"switch_identity", "device":"device", "app-path":"app_path", + "app-source":"app_source", "profile-field":"profile_field", "event":"event", "tap-expect":"tap_expect", @@ -1154,7 +1274,7 @@ proof_min_os() { root="$(verification_project_root 2>/dev/null)" || root="" if [[ -n "$root" && -d "$root" ]]; then value="$(find "$root" -maxdepth 3 \( -name project.pbxproj -o -name project.yml -o -name '*.xcconfig' \) \ - -not -path '*/DerivedData/*' -not -path '*/Pods/*' -type f -print0 2>/dev/null | + -not -path '*/DerivedData/*' -not -path '*/Pods/*' -not -path '*/node_modules/*' -type f -print0 2>/dev/null | xargs -0 grep -h -E -o '(IPHONEOS_DEPLOYMENT_TARGET[ =:"]+|iOS:[ "]+)[0-9]+(\.[0-9]+)?' 2>/dev/null | grep -E -o '[0-9]+(\.[0-9]+)?$' | sort -t. -k1,1n -k2,2n | tail -1)" fi @@ -1162,6 +1282,214 @@ proof_min_os() { [[ "$value" =~ ^[0-9]+(\.[0-9]+)*$ ]] && printf '%s' "$value" } +proof_app_built_at() { + local binary + binary="$(ios_app_newest_binary "$1")" + [[ -n "$binary" && -f "$binary" ]] || return 1 + stat -f '%m' "$binary" +} + +# Prints "\t" for the newest Iterable-carrying .app of this bundle that a +# run recorded or the project's own -derivedDataPath holds, or nothing. +proof_built_app() { + local bundle="$1" platform="$2" root="$3" ios_dir framework sdk dir data app at + local best="" best_at=0 recorded + [[ -n "$bundle" && -n "$root" ]] || return 0 + ios_dir="$(ios_project_ios_dir "$root")" + framework="$(ios_project_framework "$root")" + [[ "$platform" == iphoneos ]] && sdk=iphoneos || sdk=iphonesimulator + while IFS= read -r app; do + [[ -n "$app" && -f "$app/Info.plist" ]] || continue + app="$(cd "$app" 2>/dev/null && pwd -P)" || continue + [[ "$(plutil -extract CFBundleIdentifier raw -o - "$app/Info.plist" 2>/dev/null)" == "$bundle" ]] || continue + [[ "$(plutil -extract DTPlatformName raw -o - "$app/Info.plist" 2>/dev/null)" == "$sdk" ]] || + [[ "$(basename "$(dirname "$app")")" == *-"$sdk" ]] || continue + ! ios_app_artifact_without_sdk "$app" "$root" "$framework" "$sdk" "$BIN" >/dev/null || continue + at="$(proof_app_built_at "$app")" || continue + ((at > best_at)) || continue + best="$app" + best_at="$at" + done < <( + for recorded in "$PROOF_TARGET" "$WORKSPACE"/archive/*/proof-target.json; do + [[ -f "$recorded" ]] || continue + node -e ' + try { process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).app_path||"")+"\n") } catch {} + ' "$recorded" + done + for dir in "$root" ${ios_dir:+"$ios_dir"}; do + for data in build build/DerivedData .derived-data DerivedData; do + [[ -d "$dir/$data/Build/Products" ]] || continue + find "$dir/$data/Build/Products" -mindepth 2 -maxdepth 2 -type d -name '*.app' -print 2>/dev/null + done + done + ) + [[ -z "$best" ]] || printf '%s\t%s' "$best" "$best_at" +} + +# Prints "\t": which copy the +# proof should test, given the newest known build and the installed copy. +proof_artifact_compare() { + local built="$1" built_at="$2" installed="$3" device_kind="$4" installed_at built_exec installed_exec + if [[ "$device_kind" != simulator ]]; then + printf 'build\tits copy cannot be compared from this Mac' + elif [[ -z "$installed" || ! -d "$installed" ]]; then + printf 'build\tnot installed' + elif ! ios_app_sdk_signal "$installed" >/dev/null; then + printf 'build\ta copy with no Iterable SDK, so a build from before the integration' + elif built_exec="$(ios_app_executable "$built")" && installed_exec="$(ios_app_executable "$installed")" && + [[ "$(shasum -a 256 < "$built_exec")" == "$(shasum -a 256 < "$installed_exec")" ]]; then + printf 'same\tthis same build' + elif installed_at="$(proof_app_built_at "$installed")" && ((installed_at > built_at)); then + printf 'installed\ta copy built %s, after this build' "$(ios_app_time "$installed_at")" + else + printf 'build\tan older build%s' \ + "$([[ -z "${installed_at:-}" ]] || printf ', from %s' "$(ios_app_time "$installed_at")")" + fi +} + +# How to find the identity by hand when no candidate is verified: Iterable has no +# lookup by device token, and the SDK only logs it when a request fails. +proof_identity_steps() { + local identity_type="$1" device="$2" device_kind="$3" executable="$4" + local call=userId setter="IterableAPI.userId or setUserId" lead="If the app generates it on the device" + [[ "$identity_type" != email ]] || + { call=email; setter="IterableAPI.email or setEmail"; lead="To read it from the running app instead"; } + printf '1. Search the app'"'"'s code for %s: the value passed there is the one Iterable knows.\n' "$setter" + if [[ "$identity_type" == email ]]; then + printf '2. It is usually the address you signed in with on this device.' + else + printf '2. If it is your own account or backend ID, read it from your backend or the app'"'"'s account screen.' + fi + if [[ "$device_kind" == simulator ]]; then + printf '\n3. %s, open the app on the Simulator and run this in Terminal; it prints the value the Iterable SDK holds and leaves the app running:\n' "$lead" + printf "lldb --batch -p \"\$(pgrep -f 'Devices/%s/.*/%s\\\\.app/%s( |\$)')\" -o 'expr -l objc -O -- [NSClassFromString(@\"IterableSDK.IterableAPI\") %s]' -o detach" \ + "$device" "${executable:-}" "${executable:-}" "$call" + fi +} + +# Prints "\t\t", newest first, for each identity the app's own +# log names: the integration's ITBL_PROOF marker, or the SDK echoing Iterable's +# "No user exists with userId|email " for a request sent before the user existed. +proof_log_identities() { + local device="$1" executable="$2" since="${3:-}" + local -a window=(--last 24h) + [[ "$executable" =~ ^[A-Za-z0-9\ ._-]+$ ]] || return 0 + [[ -z "$since" ]] || window=(--start "$since") + xcrun simctl spawn "$device" log show "${window[@]}" --style ndjson --predicate \ + "process == \"$executable\" AND (eventMessage BEGINSWITH \"ITBL_PROOF identity=\" OR eventMessage CONTAINS \"No user exists with\")" \ + 2>/dev/null | node -e ' + const marker=[], sdk=[]; + for (const line of require("fs").readFileSync(0,"utf8").split("\n")) { + if (!line.startsWith("{")) continue; + let entry; try { entry=JSON.parse(line) } catch { continue } + const message=String(entry.eventMessage||""); + const own=message.match(/^ITBL_PROOF identity=(.+)$/); + if (own) { marker.push(["-", own[1].trim()]); continue; } + if (!/^(\u2764\uFE0F?|\u{1F49B}|\u{1F49A}) /u.test(message)) continue; + for (const hit of message.matchAll(/No user exists with (userId|email) ([^\s"\x27,;]+)/g)) + sdk.push([hit[1]==="email" ? "email" : "user-id", hit[2].replace(/\.+$/,"")]); + } + const out=[], seen=new Set(); + for (const [source,list] of [["marker",marker],["sdk-log",sdk]]) + for (const [type,value] of list.reverse()) + if (value && !seen.has(value)) { seen.add(value); out.push([source,type,value].join("\t")); } + process.stdout.write(out.join("\n")); + ' +} + +proof_server_key() { + local region="$1" key hash + [[ -f "$MOBILE_ENV" && -f "$SERVER_VALIDATION" ]] || return 0 + key="$(sed -n 's/^ITERABLE_SERVER_KEY=//p' "$MOBILE_ENV" | head -1)" + [[ -n "$key" ]] || return 0 + hash="$(printf '%s' "$key" | shasum -a 256 | awk '{print $1}')" + node -e ' + const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); + process.exit(v.valid===true && v.data_region===process.argv[2] && v.key_sha256===process.argv[3] ? 0 : 1); + ' "$SERVER_VALIDATION" "$region" "$hash" 2>/dev/null && printf '%s' "$key" +} + +# Prints "\t\t" for at most three candidates: match when +# Iterable holds this Simulator's current APNs token on that user, as I12 checks. +# No candidates means no Iterable request. +proof_identity_checks() { + local device="$1" bundle="$2" executable="$3" identity_type="$4" region="$5" + local source type value token="" key="" verdict devices count=0 logged + local -a candidates=() + logged="$(proof_log_identities "$device" "$executable")" + while IFS=$'\t' read -r source type value; do + [[ -n "$value" ]] || continue + [[ "$type" == - || "$type" == "$identity_type" ]] || continue + candidates+=("$source"$'\t'"$value") + done < <( + grep '^marker' <<< "$logged" + for value in "${PROOF_IDENTITY_CANDIDATES[@]+"${PROOF_IDENTITY_CANDIDATES[@]}"}"; do + [[ "$value" == *$'\n'* || "$value" == *$'\t'* ]] || printf 'agent\t-\t%s\n' "$value" + done + grep '^sdk-log' <<< "$logged" + ) + local -a kept=() + local seen=$'\n' + for value in "${candidates[@]+"${candidates[@]}"}"; do + source="${value%%$'\t'*}" + value="${value#*$'\t'}" + [[ "$value" != *$'\n'* && "$value" != *$'\t'* && ${#value} -le 500 ]] || continue + [[ "$identity_type" != email || "$value" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$ ]] || continue + [[ "$seen" != *$'\n'"$value"$'\n'* ]] || continue + seen="$seen$value"$'\n' + kept+=("$source"$'\t'"$value") + ((++count < 3)) || break + done + ((${#kept[@]})) || return 0 + token="$(node "$BIN/ios-notify-read.js" store "$(ios_simulator_devices_root)" "$device" "$bundle" 2>/dev/null | + node -e 'let i="";process.stdin.on("data",c=>i+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(i).registration?.token_sha256||""))}catch{}})')" + [[ -z "$token" ]] || key="$(proof_server_key "$region")" + for value in "${kept[@]}"; do + source="${value%%$'\t'*}" + value="${value#*$'\t'}" + if [[ -z "$token" ]]; then + verdict=notoken + elif [[ -z "$key" ]]; then + verdict=nokey + else + proof_user_get "$key" "$region" "$identity_type" "$value" + if [[ "$PROOF_HTTP_CODE" == 200 ]]; then + devices="$(printf '%s' "$PROOF_HTTP_BODY" | + node "$BIN/ios-notify-read.js" iterable-devices "$bundle" "$token" 2>/dev/null)" + [[ "$devices" == *'"token_match":true'* ]] && verdict=match || verdict=absent + elif [[ "$PROOF_HTTP_CODE" == 400 ]] && grep -Eqi 'no user exists|not found' <<< "$PROOF_HTTP_BODY"; then + verdict=absent + else + verdict="unreadable:$PROOF_HTTP_CODE" + fi + PROOF_HTTP_BODY="" + fi + printf '%s\t%s\t%s\n' "$verdict" "$source" "$value" + done + unset key +} + +# Relaunches the installed app so the integration's marker logs the identity it +# signs in with, and waits briefly for that line. Prints a note for the screen. +proof_relaunch_for_identity() { + local device="$1" bundle="$2" executable="$3" started waited=0 wait="${IOS_PROOF_IDENTITY_WAIT:-20}" + started="$(ios_simulator_log_time "$(node -e 'process.stdout.write(new Date().toISOString())')" 2000)" + xcrun simctl launch --terminate-running-process "$device" "$bundle" -iterableProofIdentity YES >/dev/null 2>&1 || { + printf 'The Simulator could not relaunch %s, so nothing new was logged.' "$bundle" + return 0 + } + while :; do + proof_log_identities "$device" "$executable" "$started" | grep -q '^marker' && { + printf 'Relaunched %s with -iterableProofIdentity YES; its proof marker logged the identity below.' "$bundle" + return 0 + } + ((waited < wait)) || break + sleep 2 + waited=$((waited + 2)) + done + printf 'Relaunched %s with -iterableProofIdentity YES, but no ITBL_PROOF line appeared within %ss: this build has no proof marker, or the app has not identified a user yet.' "$bundle" "$wait" +} + proof_devices_json() { local environment="${1:-}" push_required="${2:-false}" min_os="${3:-}" local simulators='{}' physical='{}' host_apns=false @@ -1270,22 +1598,68 @@ proof_devices_json() { } proof_question_identity_and_confirm() { - local summary="$1" identity_type="$2" device="$3" noun="email address" + local summary="$1" identity_type="$2" device="$3" steps="${4:-}" findings="${5:-}" + local verified="${6:-}" verified_how="${7:-}" check="${8:-}" relaunch="${9:-}" bundle="${10:-the app}" + local noun="email address" use="" quoted rec=null shown=0 [[ "$identity_type" == email ]] || noun="user ID" + [[ -z "$findings" ]] || summary="$summary + +$findings" + [[ -z "$steps" ]] || summary="$summary + +To find the $noun: +$steps" + if [[ -n "$verified" ]]; then + use="Use $verified: Iterable has this Simulator's current device token on that user" + rec="$(jstr "$use")" + elif [[ -n "$relaunch" ]]; then + rec="$(jstr "Relaunch the app to read the identity it signs in with")" + fi printf '{"header":"Proof run","prompt":%s,"selection":"single",' \ - "$(jstr "Which $noun is the app signed in with? Entering it starts the proof run.")" - printf '"context":%s,' "$(printf '%s\n' "$summary" | ios_q_arr)" - printf '"requires":[],"recommended":null,"before_commands":[],"options":[' + "$(jstr "$summary + +Check which $noun the app is signed in with: typing it starts the proof run. Which is it?")" + printf '"context":%s,' "$(ios_q_arr <<'EOF' +Use the identity the app itself passes to Iterable. Nothing is sent until the proof step runs, and that one push goes only to this identity on this device. +EOF +)" + printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' "$rec" + if [[ -n "$verified" ]]; then + printf -v quoted '%q' "$verified" + ios_q_opt "$use" \ + "Records $verified as the proof identity and starts the run, exactly as typing it would. $verified_how" \ + "records the identity and starts the run; ios-prove later sends one marked push to it on $device" \ + "$BIN/ios-prepare-proof start --confirm --identity $quoted" + printf ',' + shown=1 + elif [[ -n "$relaunch" ]]; then + ios_q_opt "Relaunch the app to read the identity it signs in with" \ + "Relaunches $bundle on this Simulator with the launch argument -iterableProofIdentity YES, so the integration's ITBL_PROOF marker logs the identity it signs in with. Nothing is recorded until you choose it here." \ + "the app restarts on the Simulator; this screen comes back with what it logged" \ + "$relaunch" + printf ',' + shown=1 + fi + if [[ -z "$verified" && -n "$check" ]]; then + ios_q_opt "Check again" \ + "Reads the app's log and Iterable again, for example after you sign in or relaunch the app. Nothing is recorded." \ + "this screen comes back with a fresh check" \ + "$check" + printf ',' + shown=$((shown + 1)) + fi ios_q_opt "I don't have a test user yet — help me" \ "Shows how to sign in to the app with a test identity you control, then you answer with it." \ "opens the test-user guide; nothing is recorded yet" \ "$BIN/ios-provision question --kind help_proof_identity_start" printf ',' - ios_q_opt "Use a different device" \ - "Clears the prefilled device so you can pick another booted Simulator or iPhone." \ - "returns to device selection" \ - "$BIN/ios-prepare-proof answer --field device --clear" - printf ',' + if ((shown < 2)); then + ios_q_opt "Use a different device" \ + "Clears the prefilled device so you can pick another booted Simulator or iPhone." \ + "returns to device selection" \ + "$BIN/ios-prepare-proof answer --field device --clear" + printf ',' + fi ios_q_opt "Stop here" \ "No proof run is created and nothing is sent." \ "the integration remains unproved" @@ -1299,6 +1673,7 @@ proof_status_json() { local proposed='{}' scope_identity="" scope_features="" scope_region="" local target_bundle="" project_root verification_only=false detected_features='[]' local proof_mode bundle identity_type environment identity switch_identity device app_path + local app_source device_kind=physical installed="" built="" built_at="" artifact_pick="" artifact_state="" local profile_field event tap_expect logout_expect push_required=false data_region="" local next_kind summary question devices summary_text [[ ! -f "$PROOF_DISCOVERY_META" ]] || @@ -1348,6 +1723,7 @@ proof_status_json() { switch_identity="$(read_proposed switch_identity)" device="$(read_proposed device)" app_path="$(read_proposed app_path)" + app_source="$(read_proposed app_source)" profile_field="$(read_proposed profile_field)" event="$(read_proposed event)" tap_expect="$(read_proposed tap_expect)" @@ -1363,15 +1739,26 @@ proof_status_json() { process.stdout.write(list.length===1 ? String(list[0].id) : ""); ' "$candidates")" fi - if [[ -z "$environment" && -n "$device" ]] && is_booted_simulator "$device"; then + [[ -z "$device" ]] || ! is_booted_simulator "$device" || device_kind=simulator + if [[ -z "$environment" && "$device_kind" == simulator ]]; then environment=development fi + if [[ -n "$bundle" && -n "$device" && -z "$app_path" && "$proof_mode" == behavioral ]]; then + [[ "$device_kind" != simulator ]] || + installed="$(xcrun simctl get_app_container "$device" "$bundle" app 2>/dev/null)" + IFS=$'\t' read -r built built_at <<< "$(proof_built_app "$bundle" \ + "$([[ "$device_kind" == simulator ]] && printf iphonesimulator || printf iphoneos)" "$project_root")" + [[ -z "$built" ]] || + IFS=$'\t' read -r artifact_pick artifact_state <<< \ + "$(proof_artifact_compare "$built" "$built_at" "$installed" "$device_kind")" + fi if [[ -z "$bundle" ]]; then next_kind=proof_bundle elif [[ -z "$data_region" ]]; then next_kind=proof_region elif [[ -z "$identity_type" ]]; then next_kind=proof_identity_type elif [[ -z "$device" ]]; then next_kind=proof_device elif [[ -z "$environment" ]]; then next_kind=proof_environment + elif [[ "$proof_mode" == behavioral && -n "$built" && -z "$app_source" ]]; then next_kind=proof_artifact elif [[ -z "$identity" && "$proof_mode" == behavioral ]]; then next_kind=confirm_proof elif [[ -z "$identity" ]]; then next_kind=proof_identity elif [[ "$proof_mode" == instrumented && -z "$switch_identity" ]]; then next_kind=proof_switch_identity @@ -1392,6 +1779,30 @@ proof_status_json() { case "$next_kind" in existing_proof) + local run_device run_kind run_bundle run_app fresh="" fresh_at="" fresh_note="" + local rec="Keep this run" rebuild="" quoted_fresh + IFS=$'\t' read -r run_device run_kind run_bundle run_app <<< "$(node -e ' + const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); + process.stdout.write([v.device,v.device_kind,v.bundle_id,v.app_path||""].map(String).join("\t")); + ' "$PROOF_TARGET")" + IFS=$'\t' read -r fresh fresh_at <<< "$(proof_built_app "$run_bundle" \ + "$([[ "$run_kind" == simulator ]] && printf iphonesimulator || printf iphoneos)" "$project_root")" + # The run's own build is a candidate, so any other winner is newer or the run's is unusable. + if [[ -n "$fresh" && "$fresh" != "$run_app" ]]; then + if [[ -n "$run_app" && ! -d "$run_app" ]]; then + fresh_note="the build this run tests is no longer on disk" + elif [[ -n "$run_app" ]] && ! ios_app_sdk_signal "$run_app" >/dev/null; then + fresh_note="the build this run tests has no Iterable SDK" + elif [[ -n "$run_app" ]]; then + fresh_note="this run tests an older build" + else + [[ "$run_kind" != simulator ]] || + installed="$(xcrun simctl get_app_container "$run_device" "$run_bundle" app 2>/dev/null)" + IFS=$'\t' read -r artifact_pick artifact_state <<< \ + "$(proof_artifact_compare "$fresh" "$fresh_at" "$installed" "$run_kind")" + [[ "$artifact_pick" != build ]] || fresh_note="on the device: $artifact_state" + fi + fi summary_text="$(node -e ' const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); const age=Math.round((Date.now()-Date.parse(v.started_at))/60000); @@ -1399,44 +1810,72 @@ proof_status_json() { `Run: ${v.run_id}`, `Started: ${v.started_at} (${age} minutes ago)`, `Identity: ${v.identity}`, - `Device: ${v.device} (${v.device_kind})` + `Device: ${v.device} (${v.device_kind})`, + `App: ${v.app_path || "the copy installed on the device"}` ].join("\n")); ' "$PROOF_TARGET")" + if [[ -n "$fresh_note" ]]; then + rebuild="Start a fresh run with the build at $(basename "$(dirname "$fresh")")/${fresh##*/}" + rec="$rebuild" + summary_text="$summary_text +Newer build: $fresh (built $(ios_app_time "$fresh_at")); $fresh_note" + fi question="$( printf '{"header":"Proof run","prompt":%s,"selection":"single",' \ - "$(jstr "A proof run already exists. Keep it or start a fresh one?")" - printf '"context":%s,' "$(printf '%s\n' "$summary_text" | ios_q_arr)" - printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' "$(jstr "Keep this run")" + "$(jstr "$summary_text + +A proof run already exists. Keep it or start a fresh one?")" + printf '"context":%s,' "$(printf '%s\n' \ + "Keeping reads where this run stands. A fresh run archives the current evidence first, then records a new run ID and marker." | ios_q_arr)" + printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' "$(jstr "$rec")" ios_q_opt "Keep this run" \ "Reads this run's current state; nothing is sent or reset." \ "the verifier reports where this run stands" \ "$BIN/ios-verify status" printf ',' ios_q_opt "Start a fresh run" \ - "Archives this run's evidence and records a new run ID and marker for the same identity and device." \ + "Archives this run's evidence and records a new run ID and marker for the same identity, device, and app." \ "a new proof run replaces this one" \ "$BIN/ios-prepare-proof restart --confirm" + if [[ -n "$rebuild" ]]; then + printf -v quoted_fresh '%q' "$fresh" + printf ',' + ios_q_opt "$rebuild" \ + "Archives this run's evidence and starts a new run for the same identity and device, bound to $fresh." \ + "a new proof run bound to the newer build replaces this one" \ + "$BIN/ios-prepare-proof restart --confirm --app-path $quoted_fresh" + fi printf ']}' )" summary="A proof run already exists; keep it or start a fresh one with the same identity and device." ;; proof_device) devices="$(proof_devices_json "$environment" "$push_required" "$min_os")" - question="$(ios_question proof_device "$devices")" + question="$(ios_question proof_device "$devices" "$min_os")" summary="Choose the exact device that will run and display this proof." [[ -z "$min_os" ]] || summary="$summary Only Simulators on iOS $min_os or later are listed: that is the app's deployment target." ;; confirm_proof) + local framework js_build="" + framework="$(ios_project_framework "$project_root")" + if [[ "$framework" != native ]]; then + local sdk=iphoneos + ! is_booted_simulator "$device" || sdk=iphonesimulator + if [[ -z "$app_path" ]]; then + js_build=" +Build: the installed app must be a Release build, which embeds its JavaScript and runs without Metro: $(ios_project_release_build_hint "$project_root" "$sdk")" + elif [[ ! -f "$app_path/main.jsbundle" ]]; then + js_build=" +Build: this app has no embedded main.jsbundle, so it is a Debug build that needs Metro and the verifier will refuse it; build Release instead: $(ios_project_release_build_hint "$project_root" "$sdk")" + fi + fi if [[ "$proof_mode" == behavioral ]]; then - summary_text="Verification: standard integration proof -Bundle: $bundle -Iterable region: $data_region -Environment: $environment -Identity type: $identity_type -Identity: ${identity:-the exact $identity_type the app is signed in with (type it to start)} -Device: $device -App artifact: ${app_path:-use the app already installed for this bundle} + summary_text="Bundle: $bundle +Device: $device ($device_kind, $environment) +Iterable region: $data_region${identity:+ +Identity: $identity ($identity_type)} +App: ${app_path:-the copy already installed on this device}$js_build Checks: installed app, Iterable identity, device registration, then one marked push sent through Iterable and read back from the device's own records" else summary_text="Verification: instrumented evaluation @@ -1447,23 +1886,84 @@ Identity type: $identity_type User A: $identity User B: $switch_identity Device: $device -App: $app_path +App: $app_path$js_build Profile expectation: ${profile_field:-none selected} Event expectation: ${event:-none selected} Tap expectation: ${tap_expect:-not applicable} Logout expectation: $logout_expect" fi if [[ "$proof_mode" == behavioral && -z "$identity" ]]; then - question="$(proof_question_identity_and_confirm "$summary_text" "$identity_type" "$device")" + local executable="" app findings="" verified="" verified_how="" check="" relaunch="" flags="" + local candidate quoted_candidate verdict source value how note matches=0 ambiguous="" + [[ -n "$installed" || "$device_kind" != simulator ]] || + installed="$(xcrun simctl get_app_container "$device" "$bundle" app 2>/dev/null)" + for app in "$installed" "$app_path" "$built"; do + [[ -n "$app" && -z "$executable" ]] || continue + executable="$(plutil -extract CFBundleExecutable raw -o - "$app/Info.plist" 2>/dev/null)" + done + for candidate in "${PROOF_IDENTITY_CANDIDATES[@]+"${PROOF_IDENTITY_CANDIDATES[@]}"}"; do + printf -v quoted_candidate '%q' "$candidate" + flags="$flags --identity-candidate $quoted_candidate" + done + if [[ "$device_kind" == simulator ]]; then + [[ "$PROOF_RELAUNCH" != true || -z "$installed" ]] || + findings="$(proof_relaunch_for_identity "$device" "$bundle" "$executable")" + while IFS=$'\t' read -r verdict source value; do + [[ -n "$value" ]] || continue + check="$BIN/ios-prepare-proof status$flags" + case "$source" in + marker) how="the app's proof marker logged it"; note="The app's proof marker logged $value" ;; + agent) how="the agent proposed it"; note="The agent proposed $value" ;; + *) how="the Iterable SDK's log mentions it"; note="The app's log mentions $value" ;; + esac + case "$verdict" in + match) + matches=$((matches + 1)) + ambiguous="${ambiguous:+$ambiguous, }$value" + [[ -n "$verified" ]] || { + verified="$value" + verified_how="It is offered because $how, and Iterable has this Simulator's current device token on that user." + } + note="$note, and Iterable has this Simulator's current device token on that user." + ;; + absent) note="$note, but Iterable doesn't have this Simulator's token on it yet; sign in or relaunch, then check again." ;; + notoken) note="$note, but this Simulator has no APNs token for $bundle yet, so Iterable cannot be checked; open the app, allow notifications, then check again." ;; + nokey) note="$note, but the workspace has no Server-side key validated for the $data_region region, so Iterable cannot be checked." ;; + *) note="$note, but Iterable could not be read (HTTP ${verdict#*:}); check again." ;; + esac + findings="${findings:+$findings +}$note" + done < <(proof_identity_checks "$device" "$bundle" "$executable" "$identity_type" "$data_region") + if ((matches > 1)); then + findings="${findings:+$findings +}Iterable has this Simulator's token on more than one of these: $ambiguous. Type the one the app is signed in with." + verified="" + fi + [[ -z "$installed" ]] || relaunch="$BIN/ios-prepare-proof read-identity --confirm$flags" + elif ((${#PROOF_IDENTITY_CANDIDATES[@]})); then + findings="Proposed identities are checked against a Simulator's APNs token, which a physical iPhone does not expose, so none is offered here." + fi + question="$(proof_question_identity_and_confirm "$summary_text" "$identity_type" "$device" \ + "$(proof_identity_steps "$identity_type" "$device" "$device_kind" "$executable")" \ + "$findings" "$verified" "$verified_how" "$check" "$relaunch" "$bundle")" summary="Type the identity the app is signed in with; that starts the run on the device shown." + [[ -z "$verified" ]] || + summary="Iterable has this Simulator's current device token on $verified: the developer can choose it, or type the identity the app is signed in with." else question="$(ios_question confirm_proof "$summary_text")" summary="Review the exact proof target before the live freshness checks create the run." fi ;; + proof_artifact) + question="$(ios_question proof_artifact "$bundle" "$built" "$(ios_app_time "$built_at")" \ + "$artifact_state" "$artifact_pick")" + summary="Choose which copy of the app this run tests: the newest build, or the copy already installed." + ;; proof_app) question="$(ios_question proof_app "$proof_mode")" summary="Select the exact app artifact for the chosen proof mode." + [[ "$(ios_project_framework "$project_root")" == native ]] || + summary="$summary Select a Release build, which embeds its JavaScript and runs without Metro: $(ios_project_release_build_hint "$project_root")." ;; *) question="$(ios_question "$next_kind")" @@ -1472,10 +1972,10 @@ Logout expectation: $logout_expect" esac printf '{"tool":"iterable-ios-proof-setup","contract":2,"workspace":%s,' \ "$(jstr "$WORKSPACE")" - printf '"verification_only":%s,"proposed":{"proof_mode":%s,"bundle_id":%s,"data_region":%s,"identity_type":%s,"environment":%s,"identity":%s,"switch_identity":%s,"device":%s,"app_path":%s,"profile_field":%s,"event":%s,"tap_expect":%s,"logout_expect":%s},' \ + printf '"verification_only":%s,"proposed":{"proof_mode":%s,"bundle_id":%s,"data_region":%s,"identity_type":%s,"environment":%s,"identity":%s,"switch_identity":%s,"device":%s,"app_path":%s,"app_source":%s,"profile_field":%s,"event":%s,"tap_expect":%s,"logout_expect":%s},' \ "$verification_only" "$(jstr "$proof_mode")" "$(jstr "$bundle")" "$(jstr "$data_region")" "$(jstr "$identity_type")" "$(jstr "$environment")" \ "$(jstr "$identity")" "$(jstr "$switch_identity")" "$(jstr "$device")" \ - "$(jstr "$app_path")" "$(jstr "$profile_field")" "$(jstr "$event")" \ + "$(jstr "$app_path")" "$(jstr "$app_source")" "$(jstr "$profile_field")" "$(jstr "$event")" \ "$(jstr "$tap_expect")" "$(jstr "$logout_expect")" printf '"next":{"owner":"human","kind":%s,"command":%s,"summary":%s,"question":%s}}\n' \ "$(jstr "$next_kind")" "$(jstr "$BIN/ios-prepare-proof status")" \ @@ -1483,6 +1983,7 @@ Logout expectation: $logout_expect" } record_scope() { + local scope_source="" identity_source="" [[ "$CONFIRM" == true ]] || die "record-scope requires --confirm after the developer answers the integration questions" if [[ "$FROM_ANSWERS" == true ]]; then @@ -1519,6 +2020,14 @@ record_scope() { typeof value.jwt_enabled === "boolean" ? String(value.jwt_enabled) : "" ); ' "$DISCOVERY_META")" + scope_source="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.scope_source || "")); + ' "$DISCOVERY_META")" + identity_source="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.identity_source || "")); + ' "$DISCOVERY_META")" fi [[ -n "$FEATURES" ]] || die "record-scope requires --features or --from-answers" [[ "$IDENTITY_MODE" == email || "$IDENTITY_MODE" == user-id || @@ -1547,9 +2056,11 @@ record_scope() { features: JSON.parse(process.argv[1]), identity_mode: process.argv[2], data_region: process.argv[3], - jwt_enabled: process.argv[4] === "true" + jwt_enabled: process.argv[4] === "true", + ...(process.argv[5] ? { scope_source: process.argv[5] } : {}), + ...(process.argv[6] ? { identity_source: process.argv[6] } : {}) }, null, 2) + "\n"); - ' "$normalized" "$IDENTITY_MODE" "$DATA_REGION" "$JWT_ENABLED")" + ' "$normalized" "$IDENTITY_MODE" "$DATA_REGION" "$JWT_ENABLED" "${scope_source:-}" "${identity_source:-}")" if [[ -f "$SCOPE_META" ]]; then if node -e ' @@ -1659,7 +2170,8 @@ status_json() { local discovery_jwt="" discovery_jwt_present=false discovery_jwt_check=false local discovery_push="" discovery_events="" discovery_profiles="" local discovery_more="" discovery_in_app="" discovery_inbox="" discovery_embedded="" - local discovery_deep_links="" + local discovery_deep_links="" discovery_scope_source="" scope_source_recorded="" + local discovery_identity_source="" scope_identity_source="" discovery_step0="" local iterable_setup_present=false iterable_setup_secure=false local iterable_setup_valid=false iterable_setup_driver="" local apple_setup_present=false apple_setup_secure=false @@ -1722,6 +2234,14 @@ status_json() { const scope = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(scope.verification_only===true)); ' "$SCOPE_META")" + scope_source_recorded="$(node -e ' + const scope = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(scope.scope_source || "")); + ' "$SCOPE_META")" + scope_identity_source="$(node -e ' + const scope = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(scope.identity_source || "")); + ' "$SCOPE_META")" if [[ ",$scope_features," == *,push-notifications,* ]]; then push_required=true fi @@ -1801,6 +2321,18 @@ status_json() { const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(value.jwt_check === true)); ' "$DISCOVERY_META")" + discovery_scope_source="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.scope_source || "")); + ' "$DISCOVERY_META")" + discovery_identity_source="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.identity_source || "")); + ' "$DISCOVERY_META")" + discovery_step0="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.step0_scope || "")); + ' "$DISCOVERY_META")" fi fi @@ -1978,6 +2510,16 @@ status_json() { [[ "$(file_mode "$MOBILE_XCCONFIG")" == 600 ]] && mobile_xcconfig_secure=true fi + local key_framework key_delivery key_delivery_ok=false key_delivery_ready=true + key_framework="$(ios_framework)" + key_delivery="$(key_delivery_for "$key_framework")" + [[ "$key_delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || key_delivery_ready=false + if [[ "$mobile_key_present" == true ]] && + [[ "$(env_value ITERABLE_KEY_DELIVERY)" == "$key_delivery" || + ( -z "$(env_value ITERABLE_KEY_DELIVERY)" && "$key_delivery" == xcconfig ) ]] && + key_delivery_ok "$key_delivery" "$mobile_key_value"; then + key_delivery_ok=true + fi if [[ -f "$MOBILE_VALIDATION" ]]; then mobile_validation_present=true [[ "$(file_mode "$MOBILE_VALIDATION")" == 600 ]] && @@ -2166,9 +2708,22 @@ status_json() { fi fi + local step0_features="" step0_identity="$CARRY_IDENTITY" + [[ -z "$FEATURES" ]] || step0_features="$(carried_features "$FEATURES")" || step0_features="" + local android_import_answered=false + [[ -f "$DISCOVERY_META" ]] && + node -e 'process.exit(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).android_import ? 0 : 1)' \ + "$DISCOVERY_META" 2>/dev/null && android_import_answered=true if [[ "$scope_valid" != true || "$scope_secure" != true ]]; then next_owner="human" - if [[ "$discovery_valid" != true || + if [[ "$android_import_answered" != true && "$mobile_key_present" != true ]] && + android_setup_present; then + next_kind="import_android" + next_summary="The Android setup in $(dirname "$WORKSPACE") already recorded the Iterable keys and region. Offer to reuse them for iOS instead of asking again." + elif [[ -n "$step0_features$step0_identity" && -z "$discovery_step0" ]]; then + next_kind="carry_scope" + next_summary="Show the answers the $(ios_q_step0_skill "$(step0_source)") already confirmed and record them only if the developer agrees, instead of asking those questions again." + elif [[ "$discovery_valid" != true || "$discovery_push" == unset || "$discovery_events" == unset || "$discovery_profiles" == unset || "$discovery_deep_links" == unset || "$discovery_more" == unset ]]; then @@ -2305,8 +2860,16 @@ status_json() { next_kind="validate_apns" next_owner="tool" next_summary="Sign an APNs provider token with the imported key and send one probe to a random device token in the APNs sandbox. BadDeviceToken proves the key, Key ID, and Team ID; it does not check the App ID or Push capability." - elif [[ "$mobile_key_present" != true || "$mobile_env_secure" != true || - "$mobile_xcconfig_present" != true || "$mobile_xcconfig_secure" != true ]]; then + elif [[ "$key_delivery_ready" != true && "$key_delivery_ok" != true ]]; then + next_kind="fix_expo_gitignore" + next_owner="human" + next_summary="This Expo app reads the Mobile key from .env.local, but its .gitignore does not ignore that file. Add .env*.local before any key is written." + elif [[ "$mobile_key_present" == true && "$mobile_env_secure" == true && + "$key_delivery_ok" != true ]]; then + next_kind="deliver_mobile_key" + next_owner="tool" + next_summary="Write the captured Mobile key where this $key_framework app reads it ($(key_delivery_path "$key_delivery")), at mode 0600, without printing it." + elif [[ "$mobile_key_present" != true || "$mobile_env_secure" != true ]]; then if [[ "$iterable_setup_valid" == true ]]; then next_kind="capture_iterable" next_owner="human" @@ -2370,7 +2933,8 @@ status_json() { ;; confirm_scope) next_question="$(ios_question confirm_scope "$discovery_features" \ - "$discovery_identity" "$discovery_region" "$discovery_jwt")" + "$discovery_identity" "$discovery_region" "$discovery_jwt" "$discovery_scope_source" \ + "$discovery_identity_source")" next_command="$BIN/ios-provision question --kind confirm_scope" ;; configure_iterable) @@ -2406,6 +2970,22 @@ status_json() { asc_read) next_command="$BIN/ios-provision asc-read" ;; + import_android) + next_question="$(ios_question import_android "$(android_import_summary)" "$step0_features" \ + "$step0_identity" "$(ios_q_step0_skill "$(step0_source)")")" + next_command="$BIN/ios-provision question --kind import_android${step0_features:+ --features $step0_features}${step0_identity:+ --identity $step0_identity}" + ;; + carry_scope) + next_question="$(ios_question carry_scope "$step0_features" "$step0_identity" "$(step0_source)")" + next_command="$BIN/ios-provision question --kind carry_scope${step0_features:+ --features $step0_features}${step0_identity:+ --identity $step0_identity}" + ;; + fix_expo_gitignore) + next_question="$(ios_question fix_expo_gitignore "$(app_root)")" + next_command="$BIN/ios-provision question --kind fix_expo_gitignore" + ;; + deliver_mobile_key) + next_command="$BIN/ios-provision deliver-mobile-key" + ;; asc_key|asc_issuer|asc_rejected|asc_conflict|asc_plan) next_question="$(ios_question "$next_kind" "$BUNDLE_ID" "$asc_reason" "$asc_actions" "$(asc_app_name)")" next_command="$BIN/ios-provision question --kind $next_kind" @@ -2452,16 +3032,18 @@ status_json() { printf '"target":{"bundle_id":%s,"name":%s,"recorded":%s,"detected_bundle_id":%s,"detected_name":%s,"detected_source":%s},' \ "$(jstr "$BUNDLE_ID")" "$(jstr "$APP_NAME")" "$target_recorded" \ "$(jstr "$detected_bundle")" "$(jstr "$detected_name")" "$(jstr "$detected_source")" - printf '"scope":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s,"push_required":%s,"verification_only":%s},' \ + printf '"scope":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s,"push_required":%s,"verification_only":%s,"source":%s,"identity_source":%s},' \ "$(jstr "$SCOPE_META")" "$scope_present" "$scope_secure" "$scope_valid" \ "$(jstr "$scope_features")" "$(jstr "$scope_identity")" \ "$(jstr "$scope_region")" "$(jstr "$scope_jwt")" "$push_required" \ - "$scope_verification_only" - printf '"discovery":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s},' \ + "$scope_verification_only" "$(jstr "$scope_source_recorded")" \ + "$(jstr "$scope_identity_source")" + printf '"discovery":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s,"scope_source":%s,"identity_source":%s},' \ "$(jstr "$DISCOVERY_META")" "$discovery_present" "$discovery_secure" \ "$discovery_valid" "$(jstr "$discovery_features")" \ "$(jstr "$discovery_identity")" "$(jstr "$discovery_region")" \ - "$(jstr "$discovery_jwt")" + "$(jstr "$discovery_jwt")" "$(jstr "$discovery_scope_source")" \ + "$(jstr "$discovery_identity_source")" printf '"tools":{"xcodebuild":%s,"node":%s,"npx":%s,"curl":%s},' \ "$has_xcode" "$has_node" "$has_npx" "$has_curl" printf '"apple_setup":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"strategy":%s,"readiness_path":%s,"readiness_present":%s,"readiness_mode_0600":%s,"readiness_valid":%s,"evidence_level":%s,"guide_steps":%s,"guide_next":%s,"asc_key_present":%s,"asc_issuer_present":%s,"asc_state":%s,"asc_actions":%s},' \ @@ -2479,7 +3061,7 @@ status_json() { "$apns_rejected" "$(jstr "$apns_rejection_reason")" \ "$(jstr "$([[ "$apns_classification" == - ]] || printf '%s' "$apns_classification")")" \ "$apns_credentials_valid" - printf '"iterable":{"setup_path":%s,"setup_present":%s,"setup_mode_0600":%s,"setup_valid":%s,"setup_driver":%s,"mobile_env_path":%s,"mobile_xcconfig_path":%s,"mobile_validation_path":%s,"mobile_key_present":%s,"mobile_key_validated":%s,"mode_0600":%s,"xcconfig_present":%s,"xcconfig_mode_0600":%s,"validation_present":%s,"validation_mode_0600":%s,"validation_classification":%s,"server_validation_path":%s,"server_key_present":%s,"server_key_validated":%s,"server_validation_present":%s,"server_validation_mode_0600":%s,"server_validation_classification":%s,"mobile_key_rejected":%s,"mobile_rejection_reason":%s,"server_key_rejected":%s,"server_rejection_reason":%s,"keys_identical":%s,"data_region":%s,"jwt_enabled":%s},' \ + printf '"iterable":{"setup_path":%s,"setup_present":%s,"setup_mode_0600":%s,"setup_valid":%s,"setup_driver":%s,"mobile_env_path":%s,"mobile_xcconfig_path":%s,"mobile_validation_path":%s,"mobile_key_present":%s,"mobile_key_validated":%s,"mode_0600":%s,"xcconfig_present":%s,"xcconfig_mode_0600":%s,"validation_present":%s,"validation_mode_0600":%s,"validation_classification":%s,"server_validation_path":%s,"server_key_present":%s,"server_key_validated":%s,"server_validation_present":%s,"server_validation_mode_0600":%s,"server_validation_classification":%s,"mobile_key_rejected":%s,"mobile_rejection_reason":%s,"server_key_rejected":%s,"server_rejection_reason":%s,"keys_identical":%s,"framework":%s,"key_delivery":%s,"key_delivery_path":%s,"key_delivery_ok":%s,"imported_from":%s,"data_region":%s,"jwt_enabled":%s},' \ "$(jstr "$ITERABLE_SETUP_META")" "$iterable_setup_present" \ "$iterable_setup_secure" "$iterable_setup_valid" \ "$(jstr "$iterable_setup_driver")" \ @@ -2494,6 +3076,9 @@ status_json() { "$mobile_validation_rejected" "$(jstr "$mobile_rejection_reason")" \ "$server_validation_rejected" "$(jstr "$server_rejection_reason")" \ "$keys_identical" \ + "$(jstr "$key_framework")" "$(jstr "$key_delivery")" \ + "$(jstr "$(key_delivery_path "$key_delivery")")" "$key_delivery_ok" \ + "$(jstr "$(env_value ITERABLE_IMPORTED_FROM)")" \ "$(jstr "$mobile_region")" "$(jstr "$mobile_jwt")" printf '"proof":{"target_path":%s,"present":%s,"mode_0600":%s,"valid":%s,"proof_mode":%s,"identity_type":%s,"identity":%s,"switch_identity":%s,"device":%s,"device_kind":%s,"simulator_runtime":%s,"app_path":%s,"environment":%s,"apns_environment":%s,"production_follow_up_required":%s,"profile_fields":%s,"events":%s,"started_at":%s,"run_id":%s,"tap_expect":%s,"logout_expect":%s,"direct_marker":%s,"targeted_marker":%s,"switch_marker":%s},' \ "$(jstr "$PROOF_TARGET")" "$proof_target_present" "$proof_target_secure" \ @@ -2953,6 +3538,302 @@ write_key_receipt() { chmod 600 "$path" } +app_root() { + (cd "$(dirname "$(dirname "$WORKSPACE")")" 2>/dev/null && pwd -P) +} + +set_framework() { + case "$1" in + native|react-native|expo) IOS_FRAMEWORK="$1" ;; + *) die "--framework must be native, react-native, or expo" ;; + esac +} + +ios_framework() { + case "${IOS_FRAMEWORK:-}" in + native|react-native|expo) printf '%s' "$IOS_FRAMEWORK"; return ;; + esac + local root + root="$(app_root)" && [[ -n "$root" ]] || { printf native; return; } + ios_project_framework "$root" +} + +key_delivery_for() { + case "$1" in + react-native) printf 'rn-module' ;; + expo) printf 'expo-env' ;; + *) printf 'xcconfig' ;; + esac +} + +key_delivery_path() { + case "$1" in + rn-module) printf '%s/.iterable/rn/iterable.local.ts' "$(app_root)" ;; + expo-env) printf '%s/.env.local' "$(app_root)" ;; + *) printf '%s' "$MOBILE_XCCONFIG" ;; + esac +} + +# Expo reads .env.local into the bundle; refuse to write a key into a file git +# would commit. +expo_env_ignored() { + local root="$1" + if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$root" check-ignore -q .env.local + return + fi + [[ -f "$root/.gitignore" ]] && + grep -Eq '^[[:space:]]*/?(\.env\*\.local|\.env\.local|\.env\*|\*\.local)[[:space:]]*$' "$root/.gitignore" +} + +require_key_delivery_ready() { + local delivery="$1" + [[ "$delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || + die "$(app_root)/.gitignore does not ignore .env.local, so the Mobile key would be committed. Add .env*.local to .gitignore, then run this again." +} + +deliver_mobile_key() { + local value="$1" region="$2" jwt="$3" delivery="$4" path + path="$(key_delivery_path "$delivery")" + case "$delivery" in + rn-module) + mkdir -p "$(dirname "$path")" + [[ -f "$(dirname "$path")/.gitignore" ]] || printf '*\n' > "$(dirname "$path")/.gitignore" + ( + umask 077 + { + printf '// Written by ios-provision. Local and ignored; load it with an optional require.\n' + printf 'export const ITERABLE_API_KEY = "%s";\n' "$value" + } > "$path" + ) + ;; + expo-env) + local kept="" + [[ ! -f "$path" ]] || kept="$(grep -v '^EXPO_PUBLIC_ITERABLE_API_KEY=' "$path" || true)" + ( + umask 077 + { + [[ -z "$kept" ]] || printf '%s\n' "$kept" + printf 'EXPO_PUBLIC_ITERABLE_API_KEY=%s\n' "$value" + } > "$path" + ) + ;; + *) + ( + umask 077 + { + printf '// Written by ios-provision. Local and ignored.\n' + printf 'ITERABLE_API_KEY = %s\n' "$value" + printf 'ITERABLE_DATA_REGION = %s\n' "$region" + printf 'ITERABLE_JWT_ENABLED = %s\n' "$jwt" + } > "$path" + ) + ;; + esac + chmod 600 "$path" + DELIVERY_PATH="$path" +} + +write_mobile_env() { + local value="$1" region="$2" jwt="$3" delivery="$4" server_lines="$5" origin="$6" + local deliver="${7:-deliver}" + init_workspace + ( + umask 077 + { + printf '# Written by ios-provision %s. This file is local and ignored.\n' "$origin" + printf 'ITERABLE_API_KEY=%s\n' "$value" + printf 'ITERABLE_DATA_REGION=%s\n' "$region" + printf 'ITERABLE_JWT_ENABLED=%s\n' "$jwt" + printf 'ITERABLE_KEY_DELIVERY=%s\n' "$delivery" + [[ -z "$server_lines" ]] || printf '\n%s\n' "$server_lines" + } > "$MOBILE_ENV" + ) + chmod 600 "$MOBILE_ENV" + [[ "$deliver" != deliver ]] || deliver_mobile_key "$value" "$region" "$jwt" "$delivery" +} + +key_delivery_ok() { + local delivery="$1" value="$2" path + path="$(key_delivery_path "$delivery")" + [[ -f "$path" && "$(file_mode "$path")" == 600 ]] || return 1 + local line + case "$delivery" in + rn-module) line="export const ITERABLE_API_KEY = \"$value\";" ;; + expo-env) line="EXPO_PUBLIC_ITERABLE_API_KEY=$value" ;; + *) line="ITERABLE_API_KEY = $value" ;; + esac + printf '%s\n' "$line" | grep -qxF -f - "$path" +} + +# Nearest .iterable/.env from the app root up to the git toplevel: a monorepo keeps it at the root. +android_env_path() { + local root dir stop + root="$(app_root)" + dir="$root" + stop="$(git -C "$root" rev-parse --show-toplevel 2>/dev/null)" && + stop="$(cd "$stop" 2>/dev/null && pwd -P)" || stop="$root" + while [[ -n "$dir" ]]; do + if [[ -f "$dir/.iterable/.env" ]]; then + printf '%s/.iterable/.env' "$dir" + return + fi + [[ "$dir" != "$stop" && "$dir" != / ]] || break + dir="$(dirname "$dir")" + done + printf '%s/.iterable/.env' "$root" +} + +android_env_value() { + local path + path="$(android_env_path)" + [[ -f "$path" ]] || return 0 + sed -n "s/^$1=//p" "$path" | tail -1 | sed -e "s/^[\"']//" -e "s/[\"']\$//" +} + +android_setup_present() { + [[ -n "$(android_env_value ITBL_MOBILE_KEY)" ]] +} + +import_from_android() { + [[ "$CONFIRM" == true ]] || die "import-from-android requires --confirm" + command -v node >/dev/null 2>&1 || die "node is not installed" + local carried="" carried_mode="$CARRY_IDENTITY" + [[ -z "$FEATURES" ]] || carried="$(carried_features "$FEATURES")" + android_setup_present || + die "no Android setup with a Mobile key was found at $(android_env_path)" + require_ios_next "import-from-android" import_android + local mobile server base email region delivery existing server_lines="" android_env + android_env="$(android_env_path)" + mobile="$(android_env_value ITBL_MOBILE_KEY)" + server="$(android_env_value ITBL_SERVER_KEY)" + base="$(android_env_value ITBL_BASE)" + email="$(android_env_value ITBL_EMAIL)" + region=US + [[ "$base" != https://api.eu.iterable.com ]] || region=EU + [[ "$mobile" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]] || + die "the Android Mobile key in $(android_env_path) does not look like an Iterable API key" + if [[ -n "$server" && ! "$server" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]]; then + die "the Android Server-side key in $(android_env_path) does not look like an Iterable API key" + fi + existing="$(env_value ITERABLE_API_KEY)" + if [[ -n "$existing" && "$existing" != "$mobile" ]]; then + unset mobile server existing + die "the iOS workspace already holds a different Mobile key; it was not overwritten" + fi + unset existing + delivery="$(key_delivery_for "$(ios_framework)")" + local deliver=deliver + [[ "$delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || deliver=later + [[ -z "$server" ]] || + server_lines="$(printf '# Read-only verification credential; never linked into the app.\nITERABLE_SERVER_KEY=%s' "$server")" + write_mobile_env "$mobile" "$region" false "$delivery" "$server_lines" import-from-android "$deliver" + printf 'ITERABLE_IMPORTED_FROM=%s\n' "$android_env" >> "$MOBILE_ENV" + rm -f "$MOBILE_VALIDATION" "$SERVER_VALIDATION" + unset mobile server server_lines + init_workspace + local current='{}' + [[ ! -f "$DISCOVERY_META" ]] || current="$(cat "$DISCOVERY_META")" + current="$(propose_step0 "$current" "$carried" "$carried_mode" "$(step0_source)")" || + die "could not read $DISCOVERY_META" + ( + umask 077 + node -e ' + const [path, current, region, email, source] = process.argv.slice(1); + const value = JSON.parse(current); + value.android_import = "imported"; + value.android_import_path = source; + value.data_region = region; + value.jwt_enabled = false; + if (email && !value.identity_mode) value.identity_mode = "email"; + require("fs").writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + ' "$DISCOVERY_META" "$current" "$region" "$email" "$android_env" + ) + chmod 600 "$DISCOVERY_META" + printf 'Reused the Android setup from %s: the Mobile%s key, region %s, and No JWT. Both keys are validated again for iOS later; nothing was printed.\n' \ + "$android_env" "$([[ -n "$(env_value ITERABLE_SERVER_KEY)" ]] && printf ' and Server-side')" "$region" >&2 + [[ -z "$carried" ]] || + printf 'Proposed the feature scope confirmed in the %s: %s. It is recorded only after the confirmation screen.\n' \ + "$(ios_q_step0_skill "$(step0_source)")" "$carried" >&2 + [[ -z "$carried_mode" ]] || + printf 'Proposed the identity confirmed in the %s: %s. It is recorded only after the confirmation screen.\n' \ + "$(ios_q_step0_skill "$(step0_source)")" "$carried_mode" >&2 + status_json +} + +carry_scope() { + [[ "$CONFIRM" == true ]] || + die "carry-scope requires --confirm after the developer chooses Use these answers" + command -v node >/dev/null 2>&1 || die "node is not installed" + [[ -n "$FEATURES$CARRY_IDENTITY" ]] || die "carry-scope requires --features, --identity, or both" + local carried="" carried_mode="$CARRY_IDENTITY" current='{}' candidate + [[ -z "$FEATURES" ]] || carried="$(carried_features "$FEATURES")" + require_ios_next "carry-scope" carry_scope + [[ ! -f "$DISCOVERY_META" ]] || current="$(cat "$DISCOVERY_META")" || + die "could not read $DISCOVERY_META" + candidate="$(propose_step0 "$current" "$carried" "$carried_mode" "$(step0_source)")" || + die "could not read $DISCOVERY_META" + init_workspace + ( + umask 077 + printf '%s' "$candidate" > "$DISCOVERY_META" + ) + chmod 600 "$DISCOVERY_META" + printf 'Proposed the answers confirmed in the %s:%s%s. They are recorded only after the final scope screen.\n' \ + "$(ios_q_step0_skill "$(step0_source)")" "${carried:+ features $carried}" \ + "${carried_mode:+${carried:+;} identity $carried_mode}" >&2 + status_json +} + +android_import_summary() { + local region=US server=no email + [[ "$(android_env_value ITBL_BASE)" != https://api.eu.iterable.com ]] || region=EU + [[ -z "$(android_env_value ITBL_SERVER_KEY)" ]] || server=yes + email="$(android_env_value ITBL_EMAIL)" + printf 'Android workspace: %s\nRegion: %s\nMobile key: recorded\nServer-side key: %s\nTest identity: %s' \ + "$(dirname "$(android_env_path)")" "$region" \ + "$([[ "$server" == yes ]] && printf recorded || printf 'not recorded; iOS asks for it later')" \ + "${email:-not recorded}" +} + +deliver_mobile_key_cmd() { + require_ios_next "deliver-mobile-key" deliver_mobile_key + local value region jwt delivery + value="$(env_value ITERABLE_API_KEY)" + region="$(env_value ITERABLE_DATA_REGION)" + jwt="$(env_value ITERABLE_JWT_ENABLED)" + [[ -n "$value" ]] || die "no Mobile key has been captured" + delivery="$(key_delivery_for "$(ios_framework)")" + require_key_delivery_ready "$delivery" + local kept + kept="$(grep -v '^ITERABLE_KEY_DELIVERY=' "$MOBILE_ENV")" + ( + umask 077 + printf '%s\nITERABLE_KEY_DELIVERY=%s\n' "$kept" "$delivery" > "$MOBILE_ENV" + ) + chmod 600 "$MOBILE_ENV" + deliver_mobile_key "$value" "$region" "$jwt" "$delivery" + unset value kept + printf 'Wrote the Mobile key to %s at mode 0600; the key was not printed.\n' "$DELIVERY_PATH" >&2 + status_json +} + +ignore_expo_env_cmd() { + [[ "$CONFIRM" == true ]] || die "ignore-expo-env requires --confirm" + require_ios_next "ignore-expo-env" fix_expo_gitignore + local root + root="$(app_root)" + [[ -n "$root" ]] || die "the app root could not be resolved from the workspace" + if [[ -s "$root/.gitignore" && -n "$(tail -c1 "$root/.gitignore")" ]]; then + printf '\n' >> "$root/.gitignore" + fi + printf '\n# local env files\n.env*.local\n' >> "$root/.gitignore" + expo_env_ignored "$root" || + die "$root/.gitignore now lists .env*.local, but git still does not ignore .env.local; it is already tracked or a later rule re-includes it." + printf 'Added .env*.local to %s/.gitignore.\n' "$root" >&2 + status_json +} + capture_mobile() { [[ "$CONFIRM" == true ]] || die "capture-mobile requires --confirm" [[ "$DATA_REGION" == US || "$DATA_REGION" == EU ]] || @@ -2983,6 +3864,9 @@ capture_mobile() { die "the ignored iOS workspace already contains Mobile-key configuration; it was not overwritten. Use --replace only when status offers it" require_ios_next "capture-mobile" configure_iterable capture_iterable fi + local delivery + delivery="$(key_delivery_for "$(ios_framework)")" + require_key_delivery_ready "$delivery" read_clipboard_key local value="$CLIPBOARD_KEY" @@ -3001,30 +3885,13 @@ capture_mobile() { [[ ! -f "$MOBILE_ENV" ]] || server_lines="$(grep -E '^(# Read-only verification credential|ITERABLE_SERVER_KEY=)' "$MOBILE_ENV" || true)" - init_workspace - ( - umask 077 - { - printf '# Written by ios-provision capture-mobile. This file is local and ignored.\n' - printf 'ITERABLE_API_KEY=%s\n' "$value" - printf 'ITERABLE_DATA_REGION=%s\n' "$DATA_REGION" - printf 'ITERABLE_JWT_ENABLED=%s\n' "$JWT_ENABLED" - [[ -z "$server_lines" ]] || printf '\n%s\n' "$server_lines" - } > "$MOBILE_ENV" - { - printf '// Written by ios-provision capture-mobile. Local and ignored.\n' - printf 'ITERABLE_API_KEY = %s\n' "$value" - printf 'ITERABLE_DATA_REGION = %s\n' "$DATA_REGION" - printf 'ITERABLE_JWT_ENABLED = %s\n' "$JWT_ENABLED" - } > "$MOBILE_XCCONFIG" - ) - chmod 600 "$MOBILE_ENV" "$MOBILE_XCCONFIG" + write_mobile_env "$value" "$DATA_REGION" "$JWT_ENABLED" "$delivery" "$server_lines" capture-mobile rm -f "$MOBILE_VALIDATION" unset value server_lines clip_clear printf 'Stored the Iterable Mobile API key in %s and %s at mode 0600; the key was not printed.\n' \ - "$MOBILE_ENV" "$MOBILE_XCCONFIG" >&2 + "$MOBILE_ENV" "$DELIVERY_PATH" >&2 printf 'The clipboard was cleared.\n' >&2 status_json } @@ -3723,9 +4590,10 @@ json_array_from_args() { ' } -proof_user_exists() { - local key="$1" region="$2" identity_type="$3" identity="$4" - local base encoded path out code body +# The same user read the verifier's I12 gate makes. Sets PROOF_HTTP_CODE and +# PROOF_HTTP_BODY. +proof_user_get() { + local key="$1" region="$2" identity_type="$3" identity="$4" base encoded path out [[ "$region" == EU ]] && base="https://api.eu.iterable.com" || base="https://api.iterable.com" @@ -3737,8 +4605,15 @@ proof_user_exists() { fi out="$(curl_with_secret_header "Api-Key: $key" \ -sS -w $'\n%{http_code}' "$base$path" 2>&1)" || true - code="${out##*$'\n'}" - body="${out%$'\n'*}" + PROOF_HTTP_CODE="${out##*$'\n'}" + PROOF_HTTP_BODY="${out%$'\n'*}" +} + +proof_user_exists() { + local code body + proof_user_get "$@" + code="$PROOF_HTTP_CODE" + body="$PROOF_HTTP_BODY" case "$code" in 200) printf 'true' @@ -4214,7 +5089,9 @@ restart_proof() { local requested_device="$PROOF_DEVICE" PROOF_DEVICE="$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.device||""))' "$PROOF_TARGET")" [[ -z "$requested_device" ]] || PROOF_DEVICE="$requested_device" + local requested_app_path="$PROOF_APP_PATH" PROOF_APP_PATH="$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.app_path||""))' "$PROOF_TARGET")" + [[ -z "$requested_app_path" ]] || PROOF_APP_PATH="$requested_app_path" PROOF_ENVIRONMENT="$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.environment||""))' "$PROOF_TARGET")" if [[ -n "$requested_device" ]] && is_booted_simulator "$requested_device"; then PROOF_ENVIRONMENT=development @@ -4438,10 +5315,16 @@ read_args "$@" case "$COMMAND" in status) + read_step0_flags status_json ;; + carry-scope) + read_step0_flags + carry_scope + ;; question) [[ -n "$QUESTION_KIND" ]] || die "question requires --kind" + read_step0_flags case "$QUESTION_KIND" in confirm_scope) [[ -f "$DISCOVERY_META" ]] || @@ -4468,7 +5351,9 @@ case "$COMMAND" in ' "$DISCOVERY_META")" \ "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.identity_mode||""))' "$DISCOVERY_META")" \ "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.data_region||""))' "$DISCOVERY_META")" \ - "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof v.jwt_enabled==="boolean"?String(v.jwt_enabled):"")' "$DISCOVERY_META")" + "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof v.jwt_enabled==="boolean"?String(v.jwt_enabled):"")' "$DISCOVERY_META")" \ + "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.scope_source||""))' "$DISCOVERY_META")" \ + "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.identity_source||""))' "$DISCOVERY_META")" printf '\n' ;; choose_features|choose_more_features|choose_identity|choose_region|choose_jwt|choose_apple_setup) @@ -4512,6 +5397,25 @@ case "$COMMAND" in ios_question help_apns_replace "$(detected_field team_id "$BUNDLE_ID")" printf '\n' ;; + import_android) + require_ios_next "question import_android" import_android + question_carried="" + [[ -z "$FEATURES" ]] || question_carried="$(carried_features "$FEATURES")" + ios_question import_android "$(android_import_summary)" "$question_carried" \ + "$CARRY_IDENTITY" "$(ios_q_step0_skill "$(step0_source)")" + printf '\n' + ;; + carry_scope) + require_ios_next "question carry_scope" carry_scope + question_carried="" + [[ -z "$FEATURES" ]] || question_carried="$(carried_features "$FEATURES")" + ios_question carry_scope "$question_carried" "$CARRY_IDENTITY" "$(step0_source)" + printf '\n' + ;; + fix_expo_gitignore) + ios_question fix_expo_gitignore "$(app_root)" + printf '\n' + ;; asc_key|asc_issuer|asc_rejected|asc_conflict|asc_plan|help_asc_key) asc_status="$(status_json)" ios_question "$QUESTION_KIND" "$BUNDLE_ID" \ @@ -4695,9 +5599,25 @@ case "$COMMAND" in validate-apns) validate_apns ;; + import-from-android) + read_step0_flags + import_from_android + ;; + ignore-expo-env) + ignore_expo_env_cmd + ;; + deliver-mobile-key) + deliver_mobile_key_cmd + ;; proof-status) proof_status_json ;; + proof-read-identity) + [[ "$CONFIRM" == true ]] || + die "proof-read-identity requires --confirm: it relaunches the app on the Simulator" + PROOF_RELAUNCH=true + proof_status_json + ;; proof-answer) proof_answer ;; diff --git a/bin/ios-questions.sh b/bin/ios-questions.sh index b3cc66d..44673a3 100644 --- a/bin/ios-questions.sh +++ b/bin/ios-questions.sh @@ -37,6 +37,84 @@ ios_q_free_text() { "$(jstr "$1")" "$(jstr "$2")" "$(jstr "$3")" } +ios_q_step0_skill() { + case "$1" in + react-native-step0) printf 'React Native skill' ;; + ios-step0) printf 'iOS skill' ;; + esac +} + +ios_q_import_android() { + local summary="$1" features="${2:-}" identity="${3:-}" + local skill="${4:-React Native skill}" + local scope_note="Feature scope is still asked, because the Android setup does not record it." + local reuse_description="Copies the Android Mobile and Server-side keys into the ignored iOS workspace and where the app reads them, without printing them." + local reuse_effect="keys, region, and No JWT recorded for iOS; the feature questions come next" + local reuse_command="$BIN/ios-provision import-from-android --confirm" + if [[ -n "$features" ]]; then + scope_note="Reusing also proposes the feature scope confirmed in the $skill: ${features//,/, }.${identity:+ It also proposes the identity $identity confirmed there.} It is recorded only after you confirm it on the scope screen, and Change the answers there asks it again." + reuse_description="Copies the Android Mobile and Server-side keys into the ignored iOS workspace and where the app reads them, without printing them, and proposes the features ${features//,/, }${identity:+ and the identity $identity}." + reuse_effect="keys, region, No JWT, and the proposed features recorded for iOS; the scope confirmation comes next" + reuse_command="$reuse_command --features $features" + elif [[ -n "$identity" ]]; then + scope_note="Reusing also proposes the identity $identity confirmed in the $skill. Feature scope is still asked, because the Android setup does not record it." + reuse_description="$reuse_description It also proposes the identity $identity." + fi + reuse_command="$reuse_command${identity:+ --identity $identity}" + printf '{"header":"Android","prompt":%s,"selection":"single",' \ + "$(jstr "$summary + +Reusing copies both keys for iOS without printing them, and records No JWT: the Android setup requires Require JWT off. $scope_note + +Reuse the Iterable keys and region the Android setup already recorded for this app?")" + printf '"context":%s,' "$(ios_q_arr <<'EOF' +One Mobile key and one Server-side key serve both platforms. Reusing them skips the region and JWT questions and the key-copy steps; the keys are still validated again for iOS. +EOF +)" + printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' \ + "$(jstr "Reuse the Android setup")" + ios_q_opt "Reuse the Android setup" \ + "$reuse_description" \ + "$reuse_effect" \ + "$reuse_command" + printf ',' + ios_q_opt "Set up iOS separately" \ + "Ask every iOS question and capture keys again, for example when iOS should use a different Iterable project." \ + "nothing imported; the feature questions come next" \ + "$BIN/ios-provision answer --field android-import --value skip" + printf ']}' +} + +ios_q_fix_expo_gitignore() { + local root="$1" + printf '{"header":"Expo env","prompt":%s,"selection":"single",' \ + "$(jstr "1. Open $root/.gitignore. +2. Add the line .env*.local (the Expo template keeps it under # local env files). +3. Save the file. + +Add .env*.local to .gitignore so the Mobile key in .env.local is never committed — done?")" + printf '"context":%s,' "$(ios_q_arr <<'EOF' +This Expo app reads the Mobile key from EXPO_PUBLIC_ITERABLE_API_KEY in .env.local. The tool writes it there only when git already ignores that file. +EOF +)" + printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' \ + "$(jstr "Done — check again")" + ios_q_opt "Done — check again" \ + "Re-reads .gitignore and continues once .env.local is ignored." \ + "status runs again" \ + "$BIN/ios-provision status" + printf ',' + ios_q_opt "Help me — add it to .gitignore for me" \ + "Appends .env*.local under # local env files in $root/.gitignore, then checks git ignores .env.local." \ + ".gitignore updated; the Mobile key screen comes next" \ + "$BIN/ios-provision ignore-expo-env --confirm" + printf ',' + ios_q_opt "Stop here" \ + "No key is written into the project." \ + "the Mobile key stays out of the app" + printf ']}' +} + ios_q_choose_features() { printf '{"header":"Features","prompt":%s,"selection":"multiple",' \ "$(jstr "Which Iterable features should this integration include?")" @@ -257,8 +335,50 @@ EOF printf ']}' } +ios_q_carry_scope() { + local features="$1" identity="$2" source="$3" skill confirmed="" skipped remaining + skill="$(ios_q_step0_skill "$source")" + [[ -z "$features" ]] || confirmed="features ${features//,/, }" + [[ -z "$identity" ]] || confirmed="${confirmed:+$confirmed; }identity $identity" + if [[ -n "$features" && -n "$identity" ]]; then + skipped="the feature and identity questions" + remaining="Region and JWT are" + elif [[ -n "$features" ]]; then + skipped="the feature questions" + remaining="Identity, region, and JWT are" + else + skipped="the identity question" + remaining="The feature questions, region, and JWT are" + fi + printf '{"header":"Scope","prompt":%s,"selection":"single",' \ + "$(jstr "Confirmed in the $skill: $confirmed. Record these answers instead of asking them again?")" + printf '"context":%s,' "$(ios_q_arr < API Keys). + "$(jstr "$reason + +1. Open $dashboard_url (or go to Integrations > API Keys). 2. Copy an existing Mobile key, or select New API Key and choose Mobile. 3. Require JWT must be $jwt_label for this integration. 4. Copy the key. Do not paste it into this conversation. In Iterable → Integrations → API Keys, copy a working Mobile key (Require JWT $jwt_label) to replace the rejected one — copied?")" printf '"context":%s,' "$(ios_q_arr < API Keys). + "$(jstr "$reason + +1. Open $dashboard_url (or go to Integrations > API Keys). 2. Select New API Key. 3. Choose Server-side. 4. Name it for local SDK verification, create it, and copy the value. In Iterable → Integrations → API Keys, create a new Server-side key and copy it to replace the rejected one — copied?")" printf '"context":%s,' "$(ios_q_arr <.p8. Check the key, Issuer ID, and access in App Store Connect — then try again?")" - printf '"context":%s,' "$(ios_q_arr <.p8 file (APNs keys are at https://developer.apple.com/account/resources/authkeys/list) — or how else should the key be supplied?")" + "$(jstr "Key ID: read from the AuthKey_.p8 file name. +$team_line + +Answer with the path to your AuthKey_.p8 file (APNs keys are at https://developer.apple.com/account/resources/authkeys/list) — or how else should the key be supplied?")" printf '"context":%s,' "$(ios_q_arr <.p8 file name. -$team_line The import verifies that the file is an EC P-256 private key and records its external path. Other Apple keys share that shape, so the tool cannot prove which Apple service the .p8 belongs to. Confirm in Apple Developer that Apple Push Notification service is enabled for that Key ID. The command never copies, prints, creates, revokes, or replaces the key. EOF @@ -1051,28 +1185,28 @@ ios_q_replace_apns_key() { local reason="$1" key_id="$2" team_id="$3" classification="${4:-}" bundle_id="${5:-}" if [[ "$classification" == apns-key-topic-not-allowed ]]; then printf '{"header":"APNs key","prompt":%s,"selection":"single",' \ - "$(jstr "1. Open https://developer.apple.com/account/resources/authkeys/list and open Key ID $key_id. + "$(jstr "$reason + +1. Open https://developer.apple.com/account/resources/authkeys/list and open Key ID $key_id. 2. If its key restriction is Topic Specific, add $bundle_id to its topics, then validate the same key again. 3. Or use a Team Scoped key, which can send to every app on the team: re-import its .p8 by answering with its path. In Apple Developer → Keys, let Key ID $key_id send to $bundle_id, or re-import a Team Scoped key — which file?")" printf '"context":%s,' "$(ios_q_arr </dev/null || - compgen -G "$candidate/*.xcworkspace" >/dev/null + ios_project_looks_like_ios "$1" +} + +looks_like_app_root() { + ios_project_looks_like_ios "$1" || [[ -n "$(ios_project_js_kind "$1")" ]] } resolve_workspace() { @@ -56,9 +59,9 @@ resolve_workspace() { return fi local project_root - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" if [[ "$project_root" == "$TOOL_ROOT" ]]; then - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then + if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_app_root "$PWD"; then project_root="$PWD" else return 30 @@ -100,62 +103,128 @@ done [[ -z "$WORKSPACE_OVERRIDE" ]] || IOS_WORKSPACE="$WORKSPACE_OVERRIDE" inspect_project() { - local project_root files dependency=false initialization=false identity=false - local push=false runtime_proof=false workspace_exists=false next_kind - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + local project_root dependency=false initialization=false identity=false + local push=false runtime_proof=false workspace_exists=false next_kind scan_root framework + local existing_push existing_push_path=false + local native_files="$TMP/inspect-native" js_files="$TMP/inspect-js" summary warnings='[]' + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" if [[ "$project_root" == "$TOOL_ROOT" ]]; then - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then + if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_app_root "$PWD"; then project_root="$PWD" else die "run ios-verify inspect from the iOS app, not from the skill repository" fi fi - looks_like_ios_project "$project_root" || - die "the current directory does not look like an iOS project" - files="$( - find "$project_root" \ - \( -path '*/.git' -o -path '*/.derived-data' -o -path '*/DerivedData' \ - -o -path '*/Pods' -o -path '*/.build' \ - -o -path '*/*Tests' -o -path '*/*Tests/*' \ - -o -path '*/*UITests' -o -path '*/*UITests/*' \) -prune -o \ - \( -name '*.swift' -o -name 'project.yml' -o -name 'project.pbxproj' \ - -o -name 'Package.swift' -o -name 'Package.resolved' \ - -o -name '*.entitlements' \) \ - -type f -print 2>/dev/null - )" - if [[ -n "$files" ]]; then - printf '%s\n' "$files" | while IFS= read -r file; do - [[ -f "$file" ]] && printf '%s\0' "$file" - done > "$TMP/inspect-files" - xargs -0 grep -E -l 'IterableSDK|iterable-swift-sdk' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && dependency=true - xargs -0 grep -E -l '^[[:space:]]*(_[[:space:]]*=[[:space:]]*|let[[:space:]]+[A-Za-z0-9_]+[[:space:]]*=[[:space:]]*)?IterableAPI[.]initialize' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && initialization=true - xargs -0 grep -E -l '^[[:space:]]*(IterableAPI[.](email|userId)[[:space:]]*=|IterableAPI[.](setEmail|setUserId)|setEmail[[:space:]]*[(]|setUserId[[:space:]]*[(])' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && identity=true - xargs -0 grep -E -l '^[[:space:]]*([^/].*)?(registerForRemoteNotifications|didRegisterForRemoteNotifications|IterableAPI[.]register)|aps-environment' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && push=true - xargs -0 grep -E -l 'IterableRuntimeProof|ITERABLE_RUNTIME_PROOF|iterable-ios-runtime-proof/1' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && runtime_proof=true + looks_like_app_root "$project_root" || + die "the current directory does not look like an iOS, React Native, or Expo app" + framework="$(ios_project_framework "$project_root")" + scan_root="$(ios_project_ios_dir "$project_root")" + [[ -n "$scan_root" || "$framework" != native ]] || scan_root="$project_root" + : > "$native_files" + : > "$js_files" + [[ -z "$scan_root" ]] || + ios_project_find_native "$scan_root" \ + -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name 'project.yml' -o -name 'project.pbxproj' \ + -o -name 'Package.swift' -o -name 'Package.resolved' \ + -o -name '*.entitlements' > "$native_files" + if [[ "$framework" == native ]]; then + if [[ -s "$native_files" ]]; then + xargs -0 grep -E -l 'IterableSDK|iterable-swift-sdk' \ + < "$native_files" >/dev/null 2>&1 && dependency=true + xargs -0 grep -E -l '^[[:space:]]*(_[[:space:]]*=[[:space:]]*|let[[:space:]]+[A-Za-z0-9_]+[[:space:]]*=[[:space:]]*)?IterableAPI[.]initialize' \ + < "$native_files" >/dev/null 2>&1 && initialization=true + xargs -0 grep -E -l '^[[:space:]]*(IterableAPI[.](email|userId)[[:space:]]*=|IterableAPI[.](setEmail|setUserId)|setEmail[[:space:]]*[(]|setUserId[[:space:]]*[(])' \ + < "$native_files" >/dev/null 2>&1 && identity=true + xargs -0 grep -E -l '^[[:space:]]*([^/[:space:]].*)?(registerForRemoteNotifications|didRegisterForRemoteNotifications|IterableAPI[.]register)|aps-environment' \ + < "$native_files" >/dev/null 2>&1 && push=true + fi + else + ios_project_find_js "$project_root" \ + -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' > "$js_files" + node -e ' + const pkg=(()=>{ try { return JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")) } catch { return {} } })(); + process.exit("@iterable/react-native-sdk" in {...pkg.dependencies,...pkg.devDependencies} ? 0 : 1); + ' "$project_root/package.json" && dependency=true + if [[ -s "$js_files" ]]; then + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?Iterable[.]initialize[(]' \ + < "$js_files" >/dev/null 2>&1 && initialization=true + # React state setters are often named setEmail too, so the Iterable receiver is required. + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?Iterable[.](setEmail|setUserId)[[:space:]]*[(]' \ + < "$js_files" >/dev/null 2>&1 && identity=true + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?Iterable[.]registerDeviceToken[(]' \ + < "$js_files" >/dev/null 2>&1 && push=true + fi + if [[ -s "$native_files" ]] && + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?(IterableAPI[.]register[(]token:|IterableAPI[[:space:]]+registerToken:|IterableAppIntegration)' \ + < "$native_files" >/dev/null 2>&1; then + push=true + fi + if [[ "$framework" == expo ]] && node -e ' + const fs=require("fs"), path=require("path"), root=process.argv[1]; + let app; try { app=JSON.parse(fs.readFileSync(path.join(root,"app.json"),"utf8")) } catch {} + const entry=(app?.expo?.plugins||[]).map(p=>Array.isArray(p)?p:[p]).find(([name])=>name==="@iterable/expo-plugin"); + if (entry) process.exit(entry[1]?.autoConfigurePushNotifications===false ? 1 : 0); + const dynamic=["app.config.js","app.config.ts"].some(f=>{ + try { return fs.readFileSync(path.join(root,f),"utf8").includes("@iterable/expo-plugin") } catch { return false } + }); + process.exit(dynamic ? 0 : 1); + ' "$project_root"; then + push=true + fi + if [[ "$framework" == expo ]] && node -e ' + const fs=require("fs"), path=require("path"), root=process.argv[1]; + let app; try { app=JSON.parse(fs.readFileSync(path.join(root,"app.json"),"utf8")) } catch {} + const entry=(app?.expo?.plugins||[]).map(p=>Array.isArray(p)?p:[p]).find(([name])=>name==="@iterable/expo-plugin"); + if (entry) process.exit(entry[1]?.requestPermissionsForPushNotifications===true ? 1 : 0); + const dynamic=["app.config.js","app.config.ts"].map(f=>{ + try { return fs.readFileSync(path.join(root,f),"utf8") } catch { return "" } + }).find(text=>text.includes("@iterable/expo-plugin")); + process.exit(dynamic!==undefined && !/requestPermissionsForPushNotifications\s*:\s*true/.test(dynamic) ? 0 : 1); + ' "$project_root" && ! { [[ -s "$js_files" ]] && + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?(requestPermissions|requestPermissionsAsync)[[:space:]]*[(]' \ + < "$js_files" >/dev/null 2>&1; }; then + warnings="$(node -e ' + process.stdout.write(JSON.stringify([{kind:"expo_push_permission_not_requested", + pitfall:"iterable-react-native/PITFALLS.md#14", + summary:"@iterable/expo-plugin does not ask for notification permission unless requestPermissionsForPushNotifications is true, and no requestPermissions or requestPermissionsAsync call was found in the app sources, so iOS never prompts and pushes arrive without a banner. Set the plugin option to true or request permission in the app (React Native pitfall 14)."}])); + ')" + fi + fi + if [[ -s "$native_files" ]] && + xargs -0 grep -E -l 'IterableRuntimeProof|ITERABLE_RUNTIME_PROOF|iterable-ios-runtime-proof/1' \ + < "$native_files" >/dev/null 2>&1; then + runtime_proof=true fi [[ -d "$project_root/.iterable/ios" ]] && workspace_exists=true + existing_push="$(ios_project_existing_push "$project_root")" + [[ "$existing_push" == '[]' ]] || existing_push_path=true + warnings="$(node -e ' + const warnings=JSON.parse(process.argv[1]), found=JSON.parse(process.argv[2]); + if (found.length) warnings.push({kind:"existing_push_path",pitfall:"iterable-ios/PITFALLS.md#23", + summary:`The app has its own push handling besides Iterable: ${found.map(f=>`${f.detail} (${f.file})`).join("; ")}. Ask the developer whether to keep these notifications alongside Iterable or use Iterable notifications only. If they keep both, status reports existing-push-coexistence in verification_limitations as an optional after-integration check; it never blocks Iterable proof.`, + evidence:found}); + process.stdout.write(JSON.stringify(warnings)); + ' "$warnings" "$existing_push")" if [[ "$dependency" == true && "$initialization" == true ]]; then next_kind=prepare_proof + summary='The app source contains Iterable dependency and initialization signals. Prepare a fresh verification run; installed runtime evidence, not this heuristic scan, decides behavior.' + [[ "$framework" == native ]] || + summary="$summary The proof needs a Release Simulator build, which embeds its JavaScript and runs without Metro: $(ios_project_release_build_hint "$project_root")." else next_kind=review_integration + summary='The source scan could not find both Iterable dependency and initialization signals in non-test files. Review the selected application target before attempting proof.' fi + warnings="$(ios_project_proof_marker_warning "$project_root" "$warnings")" printf '{' printf '"tool":"iterable-ios-inspect","contract":2,"read_only":true,"signals_are_heuristic":true,' - printf '"project_root":%s,"verification_workspace_exists":%s,' \ - "$(jstr "$project_root")" "$workspace_exists" - printf '"signals":{"sdk_dependency":%s,"sdk_initialization":%s,"identity_wiring":%s,"push_wiring":%s,"runtime_proof_source":%s},' \ - "$dependency" "$initialization" "$identity" "$push" "$runtime_proof" + printf '"project_root":%s,"framework":%s,"verification_workspace_exists":%s,' \ + "$(jstr "$project_root")" "$(jstr "$framework")" "$workspace_exists" + printf '"signals":{"sdk_dependency":%s,"sdk_initialization":%s,"identity_wiring":%s,"push_wiring":%s,"runtime_proof_source":%s,"existing_push_path":%s},"warnings":%s,' \ + "$dependency" "$initialization" "$identity" "$push" "$runtime_proof" "$existing_push_path" "$warnings" printf '"next":{"kind":%s,"command":%s,"summary":%s}' \ "$(jstr "$next_kind")" \ "$(jstr "$([[ "$next_kind" == prepare_proof ]] && printf '%s' "$BIN/ios-prepare-proof status")")" \ - "$(jstr "$([[ "$next_kind" == prepare_proof ]] && - printf '%s' 'The app source contains Iterable dependency and initialization signals. Prepare a fresh verification run; installed runtime evidence, not this heuristic scan, decides behavior.' || - printf '%s' 'The source scan could not find both Iterable dependency and initialization signals in non-test files. Review the selected application target before attempting proof.')")" + "$(jstr "$summary")" printf '}\n' } @@ -171,6 +240,9 @@ WORKSPACE="$(resolve_workspace)" || { exit 30 } +APP_ROOT="$(dirname "$(dirname "$WORKSPACE")")" +APP_FRAMEWORK="$(ios_project_framework "$APP_ROOT")" + SCOPE="$WORKSPACE/scope.json" ENV_FILE="$WORKSPACE/.env" PROOF_TARGET="$WORKSPACE/proof-target.json" @@ -1151,6 +1223,10 @@ artifact_contains_runtime_proof() { return 1 } +debug_js_build_detail() { + echo "$1 has no embedded main.jsbundle, so it is a Debug build that only runs while Metro serves its JavaScript; build Release instead: $(ios_project_release_build_hint "$APP_ROOT" "$2")" +} + g9() { [[ -n "$APP_PATH" ]] || { echo "no .app path was recorded for this push run" @@ -1173,6 +1249,16 @@ g9() { echo "the app artifact bundle ID is $artifact_bundle, not $BUNDLE_ID" return 1 } + if [[ "$APP_FRAMEWORK" != native && ! -f "$APP_PATH/main.jsbundle" ]]; then + local sdk=iphonesimulator + [[ "$RECORDED_DEVICE_KIND" != physical ]] || sdk=iphoneos + debug_js_build_detail "this $APP_FRAMEWORK app" "$sdk" + return 1 + fi + local artifact_sdk=iphonesimulator + [[ "$RECORDED_DEVICE_KIND" != physical ]] || artifact_sdk=iphoneos + ios_app_artifact_without_sdk "$APP_PATH" "$APP_ROOT" "$APP_FRAMEWORK" "$artifact_sdk" "$BIN" && + return 1 if [[ "$PUSH_REQUIRED" != true ]]; then echo "exact app artifact uses bundle $BUNDLE_ID" return 0 @@ -1237,7 +1323,7 @@ g9() { aps_environment="$(printf '%s' "$entitlements" | plutil -extract aps-environment raw -o - - 2>/dev/null)" || { - echo "the app has no aps-environment in its $entitlement_source" + echo "the app has no aps-environment in its $entitlement_source, so iOS cannot register it for push; rebuild it with signing on and the Push Notifications capability (never CODE_SIGNING_ALLOWED=NO, which drops the entitlement): $(ios_project_release_build_hint "$APP_ROOT" "$artifact_sdk"), then record it with $BIN/ios-prepare-proof restart --confirm --app-path " return 1 } [[ "$aps_environment" == "$ENVIRONMENT" ]] || { @@ -1252,6 +1338,19 @@ g9() { echo "artifact valid for $PROOF_MODE proof; bundle $BUNDLE_ID; aps-environment=$aps_environment from $entitlement_source; platform=$expected_platform" } +# Records the named diagnosis so the next action can tell a reinstall from a rebuild. +installed_app_integrity() { + local result + if result="$(ios_app_installed_integrity "$1" "$APP_ROOT" "$APP_FRAMEWORK" \ + "$ENVIRONMENT" "$APP_PATH" "$PUSH_REQUIRED" "$BIN")"; then + printf '%s' "$result" + return 0 + fi + printf '%s\n' "${result%%$'\t'*}" > "$TMP/I6.diagnosis" + printf '%s: %s\n' "${result%%$'\t'*}" "${result#*$'\t'}" + return 1 +} + g10() { [[ "$OFFLINE" != true ]] || { echo "installed-app read was skipped by --offline" @@ -1272,7 +1371,16 @@ g10() { echo "the selected Simulator returned a different installed bundle" return 1 } - echo "$BUNDLE_ID is already installed on the selected Simulator; standard proof does not require reinstalling it" + if [[ "$APP_FRAMEWORK" != native && ! -f "$installed_app/main.jsbundle" ]]; then + debug_js_build_detail "the installed $APP_FRAMEWORK app" iphonesimulator + return 1 + fi + local integrity + integrity="$(installed_app_integrity "$installed_app")" || { + echo "$integrity" + return 1 + } + echo "$BUNDLE_ID is already installed on the selected Simulator; standard proof does not require reinstalling it; $integrity" return 0 fi local installed_apps @@ -1314,7 +1422,6 @@ g10() { } if [[ "$DEVICE_KIND" == simulator ]]; then local installed_app installed_version installed_build - local local_executable installed_executable local_hash installed_hash installed_app="$(xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" app 2>/dev/null)" || { echo "$BUNDLE_ID is not installed on the selected Simulator" return 2 @@ -1325,27 +1432,16 @@ g10() { return 3 [[ "$installed_version" == "$local_version" && "$installed_build" == "$local_build" ]] || { - echo "the Simulator has $installed_version ($installed_build), not the recorded $local_version ($local_build)" - return 1 - } - local_executable="$(plist_value "$APP_PATH/Info.plist" CFBundleExecutable)" || - return 1 - installed_executable="$(plist_value "$installed_app/Info.plist" CFBundleExecutable)" || - return 1 - [[ "$installed_executable" == "$local_executable" && - -f "$APP_PATH/$local_executable" && - -f "$installed_app/$installed_executable" ]] || { - echo "the installed Simulator app executable does not match the recorded artifact" + printf 'installed_app_differs_from_recorded_build\n' > "$TMP/I6.diagnosis" + echo "installed_app_differs_from_recorded_build: the Simulator has $installed_version ($installed_build), not the recorded $local_version ($local_build); install the recorded build with $BIN/ios-install" return 1 } - local_hash="$(shasum -a 256 "$APP_PATH/$local_executable" | awk '{print $1}')" - installed_hash="$(shasum -a 256 "$installed_app/$installed_executable" | - awk '{print $1}')" - [[ "$installed_hash" == "$local_hash" ]] || { - echo "the Simulator has a different executable despite matching version/build" + local integrity + integrity="$(installed_app_integrity "$installed_app")" || { + echo "$integrity" return 1 } - echo "$BUNDLE_ID $local_version ($local_build) with the exact executable is installed on the selected Simulator" + echo "$BUNDLE_ID $local_version ($local_build) with the exact executable is installed on the selected Simulator; $integrity" return 0 fi local apps @@ -2423,7 +2519,11 @@ if [[ -n "$NEXT_ID" ]]; then NEXT_COMMAND="" ;; I6) - if [[ -n "$APP_PATH" ]]; then + I6_DIAGNOSIS="$(cat "$TMP/I6.diagnosis" 2>/dev/null)" + if [[ -n "$I6_DIAGNOSIS" && "$I6_DIAGNOSIS" != installed_app_differs_from_recorded_build ]]; then + NEXT_KIND=build_app + NEXT_COMMAND="" + elif [[ -n "$APP_PATH" ]]; then NEXT_KIND=install_app NEXT_COMMAND="$BIN/ios-install" else @@ -2512,7 +2612,7 @@ if [[ -n "$NEXT_ID" ]]; then fi else NEXT_KIND=inspect_app - NEXT_SUMMARY="$NEXT_SUMMARY Inspect the current screen and registration logs before asking for notification consent or repeating an app action." + NEXT_SUMMARY="$NEXT_SUMMARY Inspect the current screen before asking for notification consent or repeating an app action. This gate reads the device record from Iterable, and only it decides registration: the SDK logs a successful token registration at debug level, which its default logger drops, so a log with no registerToken line proves nothing. Re-run ios-verify status after any app action or rebuild." NEXT_COMMAND="$BIN/ios-observe inspect --kind app --purpose registration" fi ;; @@ -2717,6 +2817,13 @@ fi if [[ "$NEXT_KIND" == done && "$PHYSICAL_DEVELOPER_OBSERVED" == true ]]; then NEXT_SUMMARY="Delivery to the physical iPhone is developer-observed: the developer read back the code the notification showed, and the physical path has not been tested by the maintainers. $NEXT_SUMMARY" fi +# Only push scope edits the notification callbacks the app's own push shares. +EXISTING_PUSH='[]' +[[ "$PUSH_REQUIRED" != true ]] || EXISTING_PUSH="$(ios_project_existing_push "$APP_ROOT")" +if [[ "$NEXT_KIND" == done && "$EXISTING_PUSH" != '[]' ]]; then + NEXT_SUMMARY="$NEXT_SUMMARY $(node "$BIN/existing-push.js" follow-up <<< "$EXISTING_PUSH")" +fi +[[ "$NEXT_KIND" != done ]] || NEXT_SUMMARY="$NEXT_SUMMARY$(ios_project_proof_marker_note "$APP_ROOT")" PUSH_PROVEN=false if [[ "$PUSH_REQUIRED" == true && "$(gate_status I12)" == green && @@ -2754,8 +2861,9 @@ FEATURE_EVIDENCE_REQUIRED="$(node -e ' process.stdout.write(JSON.stringify(separate)); ' "$FEATURES" "$JWT_ENABLED" "$PROOF_MODE")" VERIFICATION_LIMITATIONS="$(node -e ' + const followUp=process.argv[5]!=="[]" ? ["existing-push-coexistence"] : []; if (process.argv[1]!=="behavioral") { - process.stdout.write("[]"); + process.stdout.write(JSON.stringify(followUp)); process.exit(0); } const limitations=[ @@ -2770,9 +2878,10 @@ VERIFICATION_LIMITATIONS="$(node -e ' if (process.argv[2]!=="true") { limitations.push("current-source-build-not-linked"); } - process.stdout.write(JSON.stringify(limitations)); + process.stdout.write(JSON.stringify([...limitations,...followUp])); ' "$PROOF_MODE" "$([[ -n "$APP_PATH" ]] && printf true || printf false)" "$TOKEN_CORRELATED" \ - "$([[ "$PROOF_MODE" == behavioral && "$RECORDED_DEVICE_KIND" == physical ]] && printf true || printf false)")" + "$([[ "$PROOF_MODE" == behavioral && "$RECORDED_DEVICE_KIND" == physical ]] && printf true || printf false)" \ + "$EXISTING_PUSH")" SOURCE_BUILD_LINKED=false VERIFICATION_SUBJECT=existing-installed-application if [[ -n "$APP_PATH" ]]; then @@ -2789,8 +2898,8 @@ fi printf '{' printf '"tool":"iterable-ios-verify","contract":1,"rc":%d,"verdict":%s,' \ "$RC" "$(jstr "$VERDICT")" -printf '"workspace":%s,"proof_mode":%s,"verification_level":%s,"environment":%s,"apns_environment":%s,"device_kind":%s,"push_required":%s,"push_proven":%s,"production_follow_up_required":%s,' \ - "$(jstr "$WORKSPACE")" "$(jstr "$PROOF_MODE")" \ +printf '"workspace":%s,"framework":%s,"proof_mode":%s,"verification_level":%s,"environment":%s,"apns_environment":%s,"device_kind":%s,"push_required":%s,"push_proven":%s,"production_follow_up_required":%s,' \ + "$(jstr "$WORKSPACE")" "$(jstr "$APP_FRAMEWORK")" "$(jstr "$PROOF_MODE")" \ "$(jstr "$([[ "$PROOF_MODE" == behavioral ]] && printf behavioral || printf instrumented)")" \ "$(jstr "$ENVIRONMENT")" \ "$(jstr "$APNS_ENVIRONMENT")" "$(jstr "$RECORDED_DEVICE_KIND")" \ diff --git a/bin/iterable-keys b/bin/iterable-keys index d795ed1..757ed46 100755 --- a/bin/iterable-keys +++ b/bin/iterable-keys @@ -20,12 +20,17 @@ source "$(dirname "${BASH_SOURCE[0]}")/config.sh" # skim: what survives skimming is a numbered list and an arrow at the value that matters. # The two arrows are not decoration — JWT and the FCM type are the whole reason this walk # is narrated rather than linked. +# EU projects sign in on their own host; a US link there lands on the wrong login. +dash_host() { + [[ "$ITBL_BASE" == https://api.eu.iterable.com ]] && printf app.eu.iterable.com || printf app.iterable.com +} + step_keys() { cat <}") ← character for character @@ -85,6 +90,26 @@ step_identity() { EOF } +# The question-screen form: the identity text above is a paragraph a chat selector can't +# hold, and the join-key point is the one thing in it that has to survive. +step_identity_short() { + echo "Use the exact address your app passes to setEmail(). It is the join key: if the app and Iterable disagree, the token is filed under one identity and the proof push goes to the other." +} + +# A step as a question prompt: no heading or colour, and each numbered line or paragraph +# joined onto one line so a chat selector doesn't break it mid-sentence. +as_prompt() { + plain | awk ' + function flush() { if (cur != "") { if (gap && wrote) print ""; print cur; cur = ""; gap = 0; wrote = 1 } } + { sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, ""); gsub(/[[:space:]]+/, " ") } + /^Step [0-9] of 4/ { next } + $0 == "" { flush(); gap = 1; next } + /^[0-9]+\. / { flush(); cur = $0; next } + { cur = cur == "" ? $0 : cur " " $0 } + END { flush() } + ' +} + # Where the values go, printed with the step that produces them. A destination named in a # block at the end is a destination nobody reads until after they have already pasted the # key somewhere else — and on the agent path there is no end to put it at, because the @@ -249,7 +274,7 @@ banner() { # experiences it. Four screens of instructions handed over at once is a document; # a developer who is on step 2 wants step 2. # -# --relay prints the clicks and nothing around them, for a question to carry into a chat. +# --prompt prints the clicks and nothing around them, for a question to carry into a chat. # What it leaves out is everything that names a command: the screen it goes on already # has the two capture lines and the next step as options, and a command line printed # beside an option that runs it is the "type this yourself" the chat path exists to @@ -258,33 +283,40 @@ banner() { if [[ "${1:-}" == "--step" ]]; then ensure_env || true STEP_N="${2:-}" - RELAY="" + PROMPT="" case "${3:-}" in "") ;; - --relay) RELAY=1 ;; - *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--relay]" >&2; exit 30 ;; + --prompt) PROMPT=1 ;; + *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--prompt]" >&2; exit 30 ;; esac case "$STEP_N" in - 1) [[ -n "$RELAY" ]] || banner ;; + 1) [[ -n "$PROMPT" ]] || banner ;; 2|3|4) ;; - *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--relay]" >&2; exit 30 ;; + *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--prompt]" >&2; exit 30 ;; esac + if [[ -n "$PROMPT" ]]; then + case "$STEP_N" in + 1) step_keys | as_prompt ;; + 2) step_mobile_app | as_prompt ;; + 3) step_push_integration | as_prompt ;; + 4) step_identity_short ;; + esac + exit 0 + fi # Every step says where it is, not just the first: somebody who arrives at step 3 by # following the one before it has no other way of knowing how much is left. In the # heading rather than on a line of its own, so the inline walk gets it too. case "$STEP_N" in - 1) step_keys; [[ -n "$RELAY" ]] || where_keys ;; + 1) step_keys; where_keys ;; 2) step_mobile_app ;; 3) step_push_integration ;; - 4) step_identity; [[ -n "$RELAY" ]] || { offer_identity; where_identity; } ;; + 4) step_identity; offer_identity; where_identity ;; esac # A walk has to name its own next step. Only the fourth knew it had a successor, so a # developer handed step 1 — which is exactly what the pending ending hands them — did # that one step and hit a dead end, with nothing on screen saying three more existed. # It reads as a one-step job that silently failed. - if [[ -n "$RELAY" ]]; then - : - elif ((STEP_N < 4)); then + if ((STEP_N < 4)); then echo " When that is done:" echo run_line iterable-keys --step $((STEP_N + 1)) diff --git a/bin/questions.sh b/bin/questions.sh index a1f31d1..3c05cb9 100644 --- a/bin/questions.sh +++ b/bin/questions.sh @@ -92,7 +92,7 @@ q_opening() { here="$(screen_copy_option_scalar opening agent Label)" printf '{"header":%s,"prompt":%s,' \ "$(jstr "$(screen_copy_scalar opening Header)")" \ - "$(jstr "$(screen_copy_scalar opening Prompt)")" + "$(jstr "$(screen_copy_section opening Prompt)")" printf '"context":%s,' "$(q_opening_context | q_arr)" printf '"requires":%s,' "$(screen_copy_section opening Requirements | q_arr)" printf '"recommended":%s,' "$(jstr "$here")" @@ -151,7 +151,9 @@ q_choose_target() { local look="Yes — list my Firebase projects" tok tok="$(ask_for choose_target)" || tok="" printf '{"header":"Discover","prompt":%s,' \ - "$(jstr "List your Firebase projects?")" + "$(jstr "The skill lists the Firebase projects your gcloud account can see and the Android apps in each. This is a read: nothing is created or changed. + +List your Firebase projects?")" printf '"context":%s,' "$(q_look_context | q_arr)" printf '"requires":%s,' "$(q_approve_requires | q_arr)" printf '"recommended":%s,' "$(jstr "$look")" @@ -186,7 +188,11 @@ q_sign_in() { # wizard runs it in the terminal it already has. What varies goes in the option's # description, which the chat shows and the terminal menu does not. local go="Sign in with gcloud" - printf '{"header":"Sign in","prompt":%s,' "$(jstr "Ready to sign in to Google?")" + printf '{"header":"Sign in","prompt":%s,' "$(jstr "Sign in with this command in your own terminal. It opens your browser, and the password stays between you and Google: + +gcloud auth login + +Ready to sign in to Google?")" printf '"context":%s,' "$(q_arr <<'EOF' Nothing here reads your Google projects until gcloud has a session with your sign-in: this action opens your browser, and neither the tool nor the skill ever see the password. @@ -219,14 +225,16 @@ EOF # project is how the wrong account gets provisioned. q_keep_target() { local keep="Yes — use $PID / $PACKAGE" again="No — let me pick again" - printf '{"header":"Target","prompt":%s,' "$(jstr "Still the same project and app?")" + printf '{"header":"Target","prompt":%s,' "$(jstr "On record from an earlier run: project $PID, package $PACKAGE. From here on, the skill reads and changes this project only. + +Still the same project and app?")" printf '"context":%s,' "$(q_arr < } q_pick_app() { - local rows sep="" i=0 pkg appid + local rows sep="" i=0 pkg appid detected source rec=null rows="$(apps_of_chosen)" || return 1 [[ -n "$rows" ]] || return 1 + IFS=$'\t' read -r detected source <<< "$(project_package)" + if [[ -n "$detected" ]] && cut -f1 <<< "$rows" | head -"$Q_PICK_MAX" | grep -qxF "$detected"; then + rec="$(jstr "$detected")" + fi - printf '{"header":"App","prompt":%s,' "$(jstr "Which Android app is in this repository?")" - printf '"context":%s,' "$(q_pick_app_context "$rows" | q_arr)" + local ask="Which Android app is in this repository?" + [[ -z "$detected" ]] || ask="This repository builds $detected (read from $source). Pick the app with that exact package. + +$ask" + printf '{"header":"App","prompt":%s,' "$(jstr "$ask")" + printf '"context":%s,' "$( { q_pick_app_context "$rows" + [[ -z "$detected" ]] || { echo ""; echo "This repository builds $detected (read from $source)."; }; } | q_arr)" printf '"requires":[],' - printf '"recommended":null,' + printf '"recommended":%s,' "$rec" printf '"options":[' while IFS=$'\t' read -r pkg appid; do ((i < Q_PICK_MAX)) || break @@ -469,15 +486,21 @@ q_register_app_context() { q_register_app() { local yes="Yes — register $PACKAGE in $PID" prompt="Register this Android app in $PID?" + local detected source rec=null + [[ -n "$PACKAGE" ]] || IFS=$'\t' read -r detected source <<< "$(project_package)" + [[ -z "$detected" ]] || rec="$(jstr "Use $detected from $source")" # The listing is also what makes "pick a different project" a working answer: with no # cache behind it the picker has nothing to draw, so that option has to fetch first. local -a back=("$BIN/agent question pick_project") listed_project || back=("$BIN/agent discover" "$BIN/agent question pick_project") listed_project || [[ -z "$PACKAGE" ]] || prompt="Look before creating anything in $PID?" + [[ -n "$PACKAGE" ]] && listed_project && prompt="This creates one Android app in Firebase project $PID: package $PACKAGE, display name $APP_DISPLAY_NAME. Nothing else is created or changed. + +$prompt" printf '{"header":"New app","prompt":%s,' "$(jstr "$prompt")" printf '"context":%s,' "$(q_register_app_context | q_arr)" printf '"requires":[],' - printf '"recommended":null,' + printf '"recommended":%s,' "$rec" printf '"options":[' if [[ -n "$PACKAGE" ]] && ! listed_project; then q_opt "Check whether it already exists first" \ @@ -489,6 +512,11 @@ q_register_app() { "Records your approval for $PID and nothing more. The provisioning step creates the app, and that step asks for its own approval first." \ "app registration approved for $PID; ladder re-read" "" \ "$BIN/agent approve create-app" "$BIN/agent" + elif [[ -n "$detected" ]]; then + q_opt "Use $detected from $source" \ + "Records the applicationId this repository builds. Naming the package doesn't create anything. This screen comes back with the package named, and you still choose whether to approve it." \ + "package recorded as $detected; ladder re-read" "" \ + "$BIN/agent set PACKAGE=$detected" "$BIN/agent" else q_opt "I'll give you the package name" \ "Type the applicationId from build.gradle. Naming the package doesn't create anything. This screen comes back with the package named, and you still choose whether to approve it." \ @@ -585,8 +613,14 @@ q_run_app_context() { } q_run_app() { + local who="Sign in, then tell the skill which address you used" + [[ -z "${ITBL_EMAIL:-}" ]] || who="Sign in as $ITBL_EMAIL, that exact address" printf '{"header":"Your app","prompt":%s,' \ - "$(jstr "Signed in on ${TARGET_DEVICE:-the device}, with notifications allowed?")" + "$(jstr "In your app on ${TARGET_DEVICE:-the device}: +1. $who +2. Allow notifications when Android asks + +Signed in on ${TARGET_DEVICE:-the device}, with notifications allowed?")" printf '"context":%s,' "$(q_run_app_context | q_arr)" printf '"requires":[],' # Nothing out here can see their screen, so nothing out here gets to nudge "done" — the @@ -643,6 +677,15 @@ q_approve_requires() { echo "Sign in to Google yourself. Run 'gcloud auth login' in your terminal. Setup never sees your password." } +# The changes themselves, because the prompt is the one part of a screen every host shows. +q_approve_prompt() { + echo "Setting up the Google part changes Firebase project ${PID:-}, package ${PACKAGE:-}. Nothing has happened yet. It would:" + firebase_plan | awk '/^ / { sub(/^ +/, ""); cur = cur " " $0; next } { if (cur != "") print cur; cur = "· " $0 } END { if (cur != "") print cur }' \ + | sed 's/ */ /g' + echo "" + echo "Do you want to proceed?" +} + q_approve_firebase() { # The recommendation comes out of the same variable as the label it points at, because # a recommendation naming an option that has since been reworded recommends nothing. @@ -650,8 +693,7 @@ q_approve_firebase() { # have: unanswered, the ladder reports choose_driver and the opening screen asks. So the # recommendation here is "carry on the way you said", not an opinion about which is better. local manual="No — I want to do this manually" yes="Yes — the tool does it, and I agree" - printf '{"header":"Firebase","prompt":%s,' \ - "$(jstr "Do you want to proceed?")" + printf '{"header":"Firebase","prompt":%s,' "$(jstr "$(q_approve_prompt)")" printf '"context":%s,' "$(q_approve_context | q_arr)" printf '"requires":%s,' "$(q_approve_requires | q_arr)" # Follows what they already said. Somebody who answered the opening question with "set @@ -665,7 +707,7 @@ q_approve_firebase() { fi printf '"options":[' q_opt "$yes" \ - "Records your approval for ${PID:-this project} and hands the Google steps to the skill. Only the changes listed above are made, and nothing that's already working is redone." \ + "Records your approval for ${PID:-this project} and hands the Google steps to the skill. Only the changes this question lists are made, and nothing that's already working is redone." \ "approval recorded against ${PID:-}; DRIVER=agent recorded" "" \ "$BIN/agent approve firebase" "$BIN/agent set DRIVER=agent" "$BIN/agent" printf ',' @@ -696,7 +738,9 @@ q_iterable_context() { q_iterable_entry() { local start="Start — step 1 of 4" printf '{"header":"Iterable","prompt":%s,' \ - "$(jstr "Start the four Iterable steps now?")" + "$(jstr "Four steps in the Iterable dashboard, about five minutes. Iterable has no API for them, so you click and the skill checks each result. The keys go from your clipboard into a mode-0600 file, never into this conversation. + +Start the four Iterable steps now?")" printf '"context":%s,' "$(q_iterable_context | q_arr)" printf '"requires":[],' printf '"recommended":%s,' "$(jstr "$start")" @@ -738,18 +782,10 @@ q_iterable_entry() { # The relay instruction, not a summary of the step — and on step 1, the one rule the # relaying agent has to follow: it has shell access, so "never handle the key" has to say # which command handles it instead. +# The step's clicks live in the prompt, not here: agents were seen relaying the prompt and +# never printing context, which left the developer a question with no steps above it. q_step_context() { - # The step itself, not a reference to one. This used to open with "the block above is - # step N" — an assertion about output a *second* command had to have produced first, and - # an option carrying two commands is an ordering an agent can get wrong. Run only the - # question and the developer gets the right choices with no instructions above them, and - # a line pointing at a block that was never printed. So one call carries both, and the - # cheapest thing to do is also the whole screen. - # Dedented by two, because the terminal's left margin inside a chat message is just a - # ragged edge; the leading blank goes because q_arr keeps every line it is given. - "$BIN/iterable-keys" --step "$1" --relay 2>/dev/null | plain | sed 's/^ //' \ - | sed '1{/^[[:space:]]*$/d;}' - echo "Show the steps above exactly as written, including every value. The exact strings matter. Shortening this step is the costliest mistake in setup." + echo "The step is in the prompt. Show it whole, with every value and line break. The exact strings matter. Shortening this step is the costliest mistake in setup." if (( $1 == 1 )); then echo "" echo "Don't ask for either key, and don't read the clipboard yourself. pbpaste and similar commands are off limits here. --take is what moves a key, and it is the only thing in this tool that ever holds one." @@ -772,6 +808,9 @@ q_iterable_step() { && prompt="The test identity is $ITBL_EMAIL. Prove the whole thing now?" \ || prompt="What address does your app sign in as?" ;; esac + prompt="$("$BIN/iterable-keys" --step "$n" --prompt 2>/dev/null) + +$prompt" printf '{"header":%s,"prompt":%s,' "$(jstr "Step $n of 4")" "$(jstr "$prompt")" # The relay instruction, not a summary of the step. A model that condenses these # screens is how "FCM type: Data notifications" becomes "enable notifications", and @@ -813,7 +852,7 @@ q_iterable_step() { q_opt "Show step 1 again" \ "The step is shown again, unchanged, including the two lines that take a key. Nothing is skipped. A missing key shows up later as a push that never sends." \ "step 1 still open" "" \ - "$BIN/iterable-keys --step 1" + "$BIN/agent question iterable_step 1" elif ((n < 4)); then rec="Done — next step" q_opt "$rec" \ @@ -824,7 +863,7 @@ q_iterable_step() { q_opt "It doesn't look like that" \ "The step is shown again, unchanged, and stays open. Help stays on this step. The Iterable screens can change, but the values and what they mean do not. Nothing is skipped. A step marked done that wasn't done shows up later as a broken integration." \ "step $n still open" "" \ - "$BIN/iterable-keys --step $n" + "$BIN/agent question iterable_step $n" else # The identity is not a secret, so unlike the keys it can be said out loud — and the # free-text answer is the point of this screen. The device knows it already when the @@ -851,12 +890,12 @@ q_iterable_step() { q_opt "Show step 4 again" \ "The step is shown again, including any addresses the device has registered." \ "step 4 still open" "" \ - "$BIN/iterable-keys --step 4" + "$BIN/agent question iterable_step 4" else q_opt "Show step 4 again" \ "The step is shown again, unchanged. Enter the address below, or set ITBL_EMAIL in the 0600 file." \ "step 4 still open" "" \ - "$BIN/iterable-keys --step 4" + "$BIN/agent question iterable_step 4" printf ',' # The way out for somebody who does not know the answer yet — the app may not sign # anyone in at all. Everything up to here still gets proved; the identity gate says @@ -912,10 +951,7 @@ q_render() { # Paragraphs arrive unwrapped on purpose — a chat window reflows them anyway — so the # width belongs to whoever is drawing. Wrapped here rather than with fold, which would # lose the indent on every continuation line. - prompt="$(printf '%s' "$q" | jqn 'process.stdout.write(j.prompt||"")')" - echo - [[ -n "$prompt" ]] && printf ' %s\n\n' "$(bold "$prompt")" - printf '%s' "$q" | jqn ' + local wrap=' const W = 76, pad = " ", out = []; const wrap = s => { if (!s) { out.push(""); return; } @@ -925,7 +961,18 @@ q_render() { else cur = cur ? cur + " " + word : word; } if (cur) out.push(pad + cur); - }; + };' + # The prompt's last line is the question; any lines before it are the steps it asks about. + prompt="$(printf '%s' "$q" | jqn 'process.stdout.write((j.prompt||"").split("\n").pop())')" + echo + printf '%s' "$q" | jqn "$wrap"' + const lead = (j.prompt || "").split("\n").slice(0, -1); + while (lead.length && !lead[lead.length - 1]) lead.pop(); + lead.forEach(wrap); + if (out.length) process.stdout.write(out.join("\n") + "\n\n"); + ' + [[ -n "$prompt" ]] && printf ' %s\n\n' "$(bold "$prompt")" + printf '%s' "$q" | jqn "$wrap"' (j.context || []).forEach(wrap); (j.requires || []).forEach(s => { out.push(""); wrap(s); }); process.stdout.write(out.join("\n") + "\n\n"); diff --git a/copy/screens/opening.md b/copy/screens/opening.md index 621140c..7b7ed8d 100644 --- a/copy/screens/opening.md +++ b/copy/screens/opening.md @@ -6,6 +6,10 @@ Setup ## Prompt +This setup creates your app's `google-services.json` file and a push-only service +account and key, then walks you through the Iterable API keys, mobile app and push +integration. Each change is listed for your approval before it happens. + Do you want to proceed with setup? ## Context diff --git a/eval/scenarios/ios-benchmark.md b/eval/scenarios/ios-benchmark.md index fa017c9..40ff4f2 100644 --- a/eval/scenarios/ios-benchmark.md +++ b/eval/scenarios/ios-benchmark.md @@ -1,6 +1,6 @@ -# iOS benchmark rules — Dog Shelter trials +# iOS benchmark rules — Sample Shop trials -These rules come from the controlled iOS integration trials (the Dog Shelter +These rules come from the controlled iOS integration trials (the Sample Shop adoption app: email identity, favorites, adoption alerts, several accounts on one device). They are that product's policy plus the scoring bar for [`ios.yml`](ios.yml). **They are not requirements for every Iterable iOS diff --git a/eval/scenarios/ios.yml b/eval/scenarios/ios.yml index 124b7c9..5efa983 100644 --- a/eval/scenarios/ios.yml +++ b/eval/scenarios/ios.yml @@ -413,7 +413,7 @@ scenarios: - id: contextual-notification-permission title: Request notification permission after a real product value moment prompt: >- - Ask Dog Shelter users to enable adoption alerts after they save their + Ask Sample Shop users to enable adoption alerts after they save their first favorite. They must be able to choose Not Now, recover later from settings, and keep the preference separate across accounts. Implement and test the complete product flow without prompting at launch. diff --git a/iterable-android-verify/SKILL.md b/iterable-android-verify/SKILL.md index 464e9dc..28d657e 100644 --- a/iterable-android-verify/SKILL.md +++ b/iterable-android-verify/SKILL.md @@ -87,7 +87,7 @@ upstream is broken tests a system that is still wrong. | `send_proof` | Announce it, then run `/bin/proof-push`. It sends one marked push and deliberately does not check whether it arrived. Then re-run `/bin/agent`. | | `campaign_send` | Server-side corroboration needs `ITBL_CAMPAIGN_ID`; without it there is nothing to read. Optional. | | `iterable_keys`, `provision`, `choose_target`, … | Not this skill's half—hand back to `iterable-provision`. | -| `done` | The push arrived. Say which push, on which device, and how long after the send. | +| `done` | The push arrived. Say which push, on which device, and how long after the send. If `next.summary` also says the app has its own push path, relay that line and offer the optional after-integration check: the developer triggers one of the app's own notifications and confirms it shows. Run it only if they ask. It never changes the verdict. | ## When the developer says nothing arrived and the check says it did diff --git a/iterable-android/PITFALLS.md b/iterable-android/PITFALLS.md index 5b6165b..303feda 100644 --- a/iterable-android/PITFALLS.md +++ b/iterable-android/PITFALLS.md @@ -67,16 +67,20 @@ hot-path subset; the full list lives here and is loaded on demand. `setEmail(email, onSuccess, onFailure)`. Chain dependent calls inside `onSuccess`. The SDK always provides a proper callback for ordering. -## 5. Custom deep-link scheme dropped silently - -- **Symptom:** Iterable push opens the app, but the URL never reaches the - app's `UrlHandler`. -- **Cause:** The SDK's default allowed-protocols list is `https` and - `http`. A custom scheme like `myapp://` is rejected before the handler - fires. +## 5. A URL scheme is missing from `setAllowedProtocols` + +- **Symptom:** A push, in-app, or embedded link opens the app (or nothing), and + the URL never reaches the app's `urlHandler`. +- **Cause:** Before calling any handler the SDK checks the scheme, and only + `https` passes without being listed — the default `allowedProtocols` is an + empty array, so `http` is **not** implicit. The check compares the text + before `://` exactly (`url.split("://")[0].equals(...)`): it is + case-sensitive, and a URL with no `//` — `tel:`, `mailto:`, `sms:` — never + matches anything, listed or not. - **Fix:** - `IterableConfig.Builder().setAllowedProtocols(arrayOf("myapp"))`. The list - is additive — `http`/`https` stay allowed automatically. + `IterableConfig.Builder().setAllowedProtocols(arrayOf("http", "myapp"))`, + in the exact case your links use. Don't send `tel:` / `mailto:` links + through Iterable's URL path; use a custom action for them. ## 6. `registerForPush()` called explicitly with auto-registration on @@ -297,9 +301,12 @@ hot-path subset; the full list lives here and is loaded on demand. email-shaped field in the app (a license email, account email, etc.). For most users that field is `null`, so `setEmail(null)` runs and the user is never identified. Identity source cannot be inferred from the codebase. -- **Fix:** Ask the developer two things: which identifier (`setEmail` vs - `setUserId`) and where its value comes from. If there's no real account - system, a stable per-install `userId` (a persisted UUID) is a common answer. +- **Fix:** Ask the project type first, then which identifier (`setEmail` vs + `setUserId`) and where its value comes from (SKILL.md, Step 0 → Identity). A + `userId` follows `reference/user-id-validation-in-iterable.md`: the account's + stable internal ID, or a random UUID; never an email, handle, or other PII. + With no account system, a persisted per-install UUID qualifies, but every + reinstall or new device is a new Iterable user, so confirm that first. Identify from the login/restore flow inside `IterableApi.onSDKInitialized { }`, never in the init callback (pitfall #2). Use one mode consistently (pitfall #12). @@ -352,27 +359,25 @@ hot-path subset; the full list lives here and is loaded on demand. that silently does nothing. Never commit a placeholder; never disable a required plugin to compile. -## 20. Link tap routed to a handler that isn't set (silently dropped) +## 20. Link tap routed to a handler that isn't set - **Symptom:** A push notification (or in-app / embedded message) opens the - app but never navigates — the deep link silently does nothing. A registered - `urlHandler` is never called for these taps. -- **Cause:** Iterable routes an action by its **type / URL scheme**, not - through one catch-all handler. Plain `http`/`https` links (and push actions - of type `openUrl`) go to `IterableConfig.urlHandler` → `handleIterableURL()`. - Links whose scheme is `action://` or `itbl://`, **and push actions whose - type is a custom action**, go to `IterableConfig.customActionHandler` → - `handleIterableCustomAction()`. (`iterable://` URLs are SDK-reserved and - handled internally.) If the handler an action routes to has **not** been set, - the action is silently dropped — no crash, no log. So an integration that - registers only `urlHandler` looks complete but loses every custom-action - link; "opens the app but doesn't navigate" is the only symptom. + app but never navigates, or opens something other than the app's handler. +- **Cause:** Iterable routes an action by its **type / URL scheme**. URLs + (push actions of type `openUrl`, `https` links, and listed schemes) go to + `IterableConfig.urlHandler` → `handleIterableURL()`. `action://` and `itbl://` + links, **and push actions whose type is a custom action**, go to + `IterableConfig.customActionHandler` → `handleIterableCustomAction()` + (`iterable://` URLs are SDK-reserved). The two fail differently: + - **No `customActionHandler`:** the action is dropped — no crash, no log. + - **No `urlHandler`, or one that returns `false`:** the URL is not dropped. + The SDK fires an `ACTION_VIEW` intent for it, which can come straight back + into the app (pitfall #24). - **Fix:** Register **both** `urlHandler` and `customActionHandler` on `IterableConfig`, unless you've confirmed the dashboard only ever sends - `openUrl` / plain-URL actions. Don't assume one handler catches everything. - When a link opens the app but doesn't navigate, suspect a missing - `customActionHandler` first — the dashboard template likely carries the link - in the action *type* rather than as an `openUrl`. + `openUrl` / plain-URL actions. When a link opens the app but doesn't + navigate, suspect a missing `customActionHandler` first — the template likely + carries the link in the action *type* rather than as an `openUrl`. ## 21. Setting `CommerceItem.categories` after construction (compiles, crashes at runtime) @@ -500,3 +505,33 @@ hot-path subset; the full list lives here and is loaded on demand. *their* pushes as well as Iterable's on Android 13+, and looks exactly like "the Iterable change broke our push." Check the grant before accepting that diagnosis, and don't accept a culprit inside your own diff without evidence. + +## 24. A link bounces between the SDK and the app + +- **Symptom:** Tapping a link reopens the app over and over, or the screen + flickers between the same activity — sometimes only offline. +- **Cause:** When `urlHandler` is unset or returns `false`, the SDK fires an + `ACTION_VIEW` intent for the URL and, when several apps match, pins it to the + app itself. If the app's own intent filter claims that URL — its scheme, or + the tracking domain — the activity receives it again, hands it to + `handleAppLink`, and the cycle repeats. A tracking-link redirect that fails + passes the original `/a/` URL to the handler, so the loop also runs offline. +- **Fix:** Make `urlHandler` return `true` for every scheme and host the app + claims, the tracking domain included, and send unknown paths to a fallback + screen; return `false` only for other apps' URLs. Pass a URL to + `handleAppLink` only when it is `https`, on your tracking domain, and + `IterableApi.isIterableDeeplink` agrees (the check in `SKILL.md` → Deep links + and App Links). + +## 25. The App Links reference uses names that don't compile + +- **Symptom:** Code copied from `reference/android-app-links.md` fails to build: + unresolved `getAndTrackDeeplink` or `isIterableDeepLink`, or "overrides + nothing" on `onCreate()`. +- **Cause:** At 3.7.0 the SDK exports the instance method + `IterableApi.getInstance().getAndTrackDeepLink(url, handler)` (capital `L`) + and the static `IterableApi.isIterableDeeplink(url)` (lower-case `l`). The + article calls them the other way round, and declares `onCreate()` without the + `Bundle` parameter. +- **Fix:** Use the names in `SKILL.md` → Deep links and App Links, and + `onCreate(savedInstanceState: Bundle?)`. diff --git a/iterable-android/SKILL.md b/iterable-android/SKILL.md index 404b61b..bc6cb7f 100644 --- a/iterable-android/SKILL.md +++ b/iterable-android/SKILL.md @@ -67,17 +67,21 @@ and that is a question to ask if they raise it, not up front. project already depends on `com.iterable:iterableapi`, the scope question above is the wrong one — they have working code and a version to move off. Ask which version they're on and what they want out of the upgrade, then follow the -upgrade row in the slug table. The always-on rules still apply (existing -integrations frequently violate rules 2 and 3), but don't re-derive their -integration from scratch. +upgrade row in the [decisions table](#decisions-read-before-recommending). The +always-on rules still apply (existing integrations frequently violate rules 2 +and 3), but don't re-derive their integration from scratch. **And check whether the app already sends push without Iterable.** Grep for `FirebaseMessagingService` and `com.google.firebase.MESSAGING_EVENT` before scoping push work. If either is present the app has a push channel of its own — often transactional (order status, shipping, security codes) and more important -to the business than the marketing push being added. That is an integration -constraint, not a thing to replace: rule 8 has the shape. Say what you found -and confirm the plan before editing their service. +to the business than the marketing push being added. Say what you found and +ask: "Keep your app's own notifications working alongside Iterable's, or use +Iterable notifications only?" **Keep both** (recommend it when their push is +transactional): their service forwards only Iterable payloads to Iterable +(rule 8). **Iterable only:** propose, in the one diff they confirm, how their +service and provider stop handling pushes, and touch nothing until they +confirm it. Confirm the scope, *then* run Preflight for the inputs that scope needs, *then* build. "Finish, don't stub" (below) applies to **the scope you agreed on** — @@ -85,6 +89,64 @@ it is not licence to implement every feature unprompted. After delivering the agreed scope, it's fine to offer next steps — but the initial scope is a question, not an assumption. +### Identity: project type first + +Ask which kind of Iterable project the app sends to, per +`project-types-and-unique-identifiers`: + +- **Hybrid**: `email` and `userId` are both unique identifiers. +- **UserID-based**: `userId` only. +- **Email-based**: `email` only; limited availability (some older US-hosted + organizations). +- **I don't have a project yet / I don't know — help me.** + +For an existing project, also ask which identifier their backend, CRM, or other +SDKs already send to Iterable, and reuse exactly that value: another value is +another user. Decide from what a signed-in session holds, not what sign-up asks +for: an app that asks for an email only at sign-up has none to pass later. +**Help me** means inspect, then recommend: what identifier exists after *every* +sign-in path, a restored session included, and what the app's other SDKs +already identify the user with. Recommend a project type and identity mode from +that, citing the docs in the table below. With no project yet, guide creating +one and warn that its type can't be changed later. + +A `userId` is the app's own stable internal account ID when it meets +`user-id-validation-in-iterable` → `### User ID best practices`, otherwise a +random UUID created once per account and stored with it. Never an email, phone +number, username, or handle, nothing built from them, and nothing sequential. +An app with no accounts can use a persisted per-install UUID (that doc lists +random UUIDs), but every reinstall or new device becomes a new Iterable user; +say so and confirm it. That doc's 128-character limit supersedes +`identifying-the-user`'s "52 characters" and its "email recommended". + +### Decisions: read before recommending + +Before recommending an option for a decision below, open the named section and +cite it in the option ("per `user-id-validation-in-iterable`"). If no shipped +doc covers a decision, offer no recommendation and say so. Slugs are +`reference/.md`. + +| Decision | Read | +|---|---| +| New integration or upgrade | `android-sdk` → `## Upgrading the SDK` (newest-first: read every entry newer than their version, after asking which one) | +| Project type | `project-types-and-unique-identifiers` → `### Choosing a project type`, `#### Project type availability` | +| Identity mode, and which app value supplies it | `project-types-and-unique-identifiers` → `### Comparing project types`; `user-id-validation-in-iterable` → `### User ID best practices`; `identifying-the-user` | +| An existing client's identifier | `creating-users-with-apis` → `## Understanding user identification in API requests`; `updating-a-users-email-address` | +| Unknown users | `configure-the-android-sdk` → `## Step 3`; `setting-up-unknown-user-activation`; `handling-anonymous-users` | +| Data region | `android-sdk` → `#### Step 5.2`; `european-data-center` → `## Signing in to Iterable: region-specific URLs` | +| Key type | `api-keys` → `### Selecting a key type`, `#### Mobile` | +| JWT | `api-keys` → `#### JWT authentication`; `jwt-enabled-api-keys` | +| Event creation | `api-keys` → `#### Event Creation` | +| FCM type | `setting-up-android-push-notifications` → `#### Step 3.3` | +| Push integration name | `android-sdk` → `#### Step 5.5` | +| Notification permission | `setting-up-android-push-notifications` → `## Android 13: Push notification permissions` | +| Existing push | `setting-up-android-push-notifications` → `#### Handling Firebase push messages and tokens` | +| Deep links | `android-sdk` → `#### Step 5.3`; `android-app-links` | +| Profile fields | `updating-user-profiles`; `user-profile-fields-used-by-iterable` → `## Other reserved field names` | +| Events | `tracking-events-with-iterables-mobile-sdks` → `## Custom event names` | +| Logout | `handling-anonymous-users` → `## Best practices for handling anonymous users`; `tracking-events-with-iterables-mobile-sdks` → `### Multiple users` | +| Test identity | `project-types-and-unique-identifiers` → `### Checking the unique identifier for a project`; `setting-up-android-push-notifications` → `#### Step 3.4` | + ## Definition of done — finish the agreed scope, don't stub it The pitfalls below are about avoiding silent failures — but avoiding traps is @@ -168,8 +230,9 @@ done. > interactive question tool (in Claude Code, `AskUserQuestion`; in Cursor, > `AskQuestion`), use it so they get interactive choices instead of a plain-text > list they must answer by typing. Offer realistic options with a short -> description each (e.g. identity: "Stable per-install UUID via `setUserId`" / -> "Account email via `setEmail`"; region: "US" / "EU"). With those tools, +> description each (e.g. identity: "The account's stable ID via `setUserId`, +> per `user-id-validation-in-iterable`" / "Account email via `setEmail`"; +> region: "US" / "EU"). With those tools, > **offer at most 4 options per question** — they reject more than 4 and the > call fails with an "invalid parameters" error; if you have more than 4, group > them or split into a second question. If no such tool is available, ask in @@ -180,9 +243,9 @@ done. |---|---|---| | **`google-services.json`** (real, from the developer's Firebase Console) | Any push / FCM work — the `com.google.gms.google-services` plugin **fails the build without it** | **Route into `iterable-provision`**, which downloads the real one for their project and package; if it isn't installed, STOP and ask. Either way you cannot generate it. Once you have it, check its `client[].client_info.android_client_info.package_name` against the module's `applicationId` **before building** — a mismatch fails the build with a message about the file, not about the mismatch. Also confirm it's gitignored or intended to be committed; it usually isn't ignored by default. | | **Mobile API key** | Always | Ask where it lives; expect `local.properties` (gitignored). Never hardcode. If the file **already** has a key, don't assume it's this project's — show the developer what you found and have them confirm it (pitfall #16). | -| **Identity model** — `setEmail` vs `setUserId`, and where the value comes from | Always | Ask. Never guess (e.g. grabbing a license email). See rule 7. | +| **Identity model** — `setEmail` vs `setUserId`, and where the value comes from | Always | [Project type first](#identity-project-type-first). Never guess (e.g. grabbing a license email). See rule 7. | | **JWT?** — is the mobile key JWT-protected? | Always | Ask, and **assume yes until told otherwise**: JWT is selected *by default* when a client-side key is created, and the setting can never be changed afterwards. If yes, an auth handler is mandatory (rule 1) **and the team needs a backend endpoint that mints the tokens** — if they don't have one, that is a blocker to raise, not something to work around (pitfall #22). | -| **Data region** — US or EU | Always | Ask if their dashboard is `app.eu.iterable.com`. See pitfall #8. | +| **Data region** — US or EU | Always | Ask if they sign in at `app.eu.iterable.com` (`european-data-center`). See pitfall #8. | | **Push integration name** | Push, if it differs from the package name | Ask. See pitfall #9. | | **Placement IDs** | Embedded messages | Ask — they're dashboard-assigned numbers. See pitfall #10. | @@ -277,10 +340,8 @@ uploaded to it. That doc is *framed* as a migration, but `### Step 2.4`–`### S sandbox project to test in before touching production. Read those sections; skip the legacy-key migration narrative around them. -Two things these docs do **not** settle, so keep asking the developer: -**data region** (confirm it from their dashboard URL — `app.eu.iterable.com` -means EU; see pitfall #8) and **`google-services.json`**, which comes from their -Firebase Console and cannot be generated. +These docs do **not** settle `google-services.json`: it comes from the +developer's Firebase Console and cannot be generated. --- @@ -361,12 +422,21 @@ app already had. Full explanations and the remaining ~15 traps are in `updateUser`, `track`, etc. inside `onSuccess`. Delays are fragile under real network conditions and will break in production. -5. **Custom deep-link schemes need `setAllowedProtocols(arrayOf("yourscheme"))`.** - Without this the SDK refuses to dispatch the URL to your `UrlHandler` and - silently drops the link. Also register **both** `urlHandler` and - `customActionHandler` — `action://`/`itbl://` links and custom-action push - types route to the latter, and a tap to an unset handler is silently dropped - (PITFALLS #20). +5. **Deep links: list every non-`https` scheme, answer `true` for your own + links, and send only tracking links to `handleAppLink`.** + - Only `https` passes the SDK's URL gate without being listed. `http` and + your own scheme need `setAllowedProtocols(arrayOf("http", "myapp"))`, + spelled exactly — the match is case-sensitive on the text before `://`, + so `tel:` and `mailto:` links never pass at all (pitfall #5). + - When `urlHandler` is unset or returns `false`, the SDK fires an + `ACTION_VIEW` intent — pinned to your own app when it also matches — which + lands back in your own intent filter and loops. Return `true` for every + scheme and host the app claims, the tracking domain included (pitfall #24). + - Register `customActionHandler` too: `action://` / `itbl://` links and + custom-action push types go there, and an unset one drops them + (pitfall #20). + - Pass only `https` links on your tracking domain with an `/a/` path to + `handleAppLink` — see [Deep links and App Links](#deep-links-and-app-links). 6. **Never hardcode the API key into a tracked file.** A *mobile* API key is safe to embed in the compiled app — that's its intended use — but it must @@ -381,7 +451,8 @@ app already had. Full explanations and the remaining ~15 traps are in #16). Confirm the key is a **mobile** key, not server-side — a server key in an app exposes the whole project. -7. **Never assume how the app identifies a user — ask.** The identifier +7. **Never assume how the app identifies a user — ask** the project type + first ([Step 0](#identity-project-type-first)). The identifier (`setEmail` vs `setUserId`) and its source are app-specific and cannot be inferred from the codebase. Do **not** wire identity to the first email-shaped field you find (e.g. a license/account email) — that often @@ -392,6 +463,10 @@ app already had. Full explanations and the remaining ~15 traps are in 8. **If the app already has its own `FirebaseMessagingService`, forward to Iterable from it — never replace, delete, or `tools:node="remove"` it.** + That is Step 0's **Keep both** answer, the default to recommend when their + push is transactional. For **Iterable only**, propose in the one confirmed + diff how their service and provider stop handling pushes, and touch nothing + until the developer confirms it. Before touching push, grep for `FirebaseMessagingService` and `com.google.firebase.MESSAGING_EVENT`. FCM delivers to **one** service, and the SDK registers `IterableFirebaseMessagingService` at @@ -421,6 +496,12 @@ app already had. Full explanations and the remaining ~15 traps are in than assuming. See `setting-up-android-push-notifications` → `#### Handling Firebase push messages and tokens`, and pitfall #23. + If they kept their push, offer one optional check after + `iterable-android-verify` proves Iterable's push: they trigger one of the + app's own notifications and confirm it shows. Run it only if they ask. + Iterable's proof alone completes the integration; the verifier's `done` + summary carries the offer when it finds the app's own push path. + --- ## Canonical minimum integration (start here) @@ -460,10 +541,9 @@ object IterableTracker { } } - // Account-less / local-first apps (common; the UUA doc is for - // anonymous→identified *upgrades*, not this): generate a stable per-install - // UUID once and persist it. Each reinstall = a new Iterable user — confirm - // that's acceptable with the developer. + // Account-less apps only (UUA is for anonymous→identified upgrades). A random UUID is + // a valid userId (user-id-validation-in-iterable), but each reinstall or new device is + // a new Iterable user: confirm that. Apps with accounts pass the account's stable ID. fun stableUserId(context: Context): String { val prefs = context.getSharedPreferences("iterable", Context.MODE_PRIVATE) return prefs.getString("user_id", null) ?: java.util.UUID.randomUUID().toString() @@ -485,7 +565,7 @@ override fun onCreate() { IterableTracker.initialize( this, BuildConfig.ITERABLE_API_KEY, - IterableTracker.stableUserId(this), + IterableTracker.stableUserId(this), // account-less; signed-in apps: the Variant below ) } ``` @@ -528,6 +608,86 @@ handles one that arrives *after*. Two ways to get this wrong: captures one value, so the account switch never reaches Iterable and events land on the previous user — pitfall #3, which is about exactly this. +### Deep links and App Links + +Only when deep links are in scope. The manifest filter claims the tracking +domain's `/a/` links; `android:autoVerify="true"` asks Android to verify them, +and on Android 12+ an unverified link opens in the browser instead of the app. + +```xml + + + + + + + +``` + +```kotlin +// The Iterable link-tracking domain: Settings > Domains, and the host in the intent filter. +const val LINK_TRACKING_DOMAIN = "links.example.com" + +fun isIterableTrackingLink(url: String): Boolean { + val uri = Uri.parse(url) + return uri.scheme == "https" && uri.host == LINK_TRACKING_DOMAIN && + IterableApi.isIterableDeeplink(url) +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // ...setContent { } / setContentView(...)... + handleLink(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleLink(intent) + } + + private fun handleLink(intent: Intent?) { + val url = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString ?: return + setIntent(Intent(Intent.ACTION_MAIN)) // don't replay the link from Recents + if (isIterableTrackingLink(url)) { + // handleAppLink returns false, dropping the link, until init has finished. + IterableApi.onSDKInitialized { IterableApi.getInstance().handleAppLink(url) } + } else { + AppRouter.open(this, url) + } + } +} +``` + +`handleAppLink` tracks the click, follows the redirect, and hands the +destination to your `urlHandler` (`IterableActionSource.APP_LINK`). If the +request fails — offline, or an error response — the handler gets the original +tracking URL, so it must answer `true` for that too. `handleAppLink` called +before initialization returns `false` and the link is lost; Android doesn't hold +it for later, which is why the snippet waits in `onSDKInitialized` (callbacks +run on the main thread). If the app skips initialization — no API key — that +callback never runs, so route the link yourself in that build. + +Names the SDK actually exports at 3.7.0: the static +`IterableApi.isIterableDeeplink(url)` and the instance +`IterableApi.getInstance().getAndTrackDeepLink(url, handler)`. The bundled +`reference/android-app-links.md` spells them `IterableApi.getAndTrackDeeplink` +(static) and `getInstance().isIterableDeepLink`, and its `onCreate()` takes no +`Bundle` — none of those compile (pitfall #25). + +**Iterable prerequisites** (`reference/android-app-links.md`, and Iterable's +"Configuring Deep Links for Email or SMS"): + +1. A custom link-tracking domain with HTTPS — not `links.iterable.com` or + `links.eu.iterable.com`. +2. In Iterable, **Settings > Domains** → edit the tracking domain → **New + Destination Path** → **Edit Configuration** → upload `assetlinks.json` + (one file per tracking domain) — and an `apple-app-site-association` file, + which Iterable uses to decide which links to rewrite as `/a/` deep links even + when there is no iOS app. +3. Check `https:///.well-known/assetlinks.json` lists your + package and signing-certificate fingerprint. + The full per-feature docs (push, in-app, inbox, embedded, deep links, events, profiles, UUA) are in `reference/` — see the routing table below. @@ -580,8 +740,9 @@ the version they're on. | If the user is asking about… | Slug | | ---------------------------- | ---- | | First-time setup, dependencies, gradle setup, `IterableApi.initializeInBackground`, `IterableConfig` builder | `android-sdk` (read `## Installing the SDK`; skip `## Upgrading the SDK`) | -| Upgrading an existing integration from an older SDK version | `android-sdk` → `## Upgrading the SDK`. Entries run newest-first (`### Upgrading to 3.10.0` down to `3.2.0`); read every entry **newer than** the version they're on and stop there. Ask their current version first — don't guess it. | -| Configuration deep-dive (every `IterableConfig` option, `setDataRegion`, allowed protocols, log level) | `configure-the-android-sdk` | +| `IterableConfig` options: `setDataRegion`, log level, handlers | `android-sdk` → `### Step 5: Set SDK configuration options` | +| Allowed URL protocols (`setAllowedProtocols`) | `android-sdk` → `#### Step 5.3: Set allowed URL protocols` — then rule 5: only `https` is implicit | +| Unknown User Activation configuration and consent | `configure-the-android-sdk` | | FCM push, notification channels, `POST_NOTIFICATIONS`, device registration | `setting-up-android-push-notifications` | | The app **already has** its own `FirebaseMessagingService`, or a second push provider (OneSignal, Braze, a home-grown one) | `setting-up-android-push-notifications` → `#### Handling Firebase push messages and tokens`. Forward, don't replace — rule 8 and pitfall #23. | | Push behavior overview (silent push, foreground vs background, deep link from notification) | `push-notification-overview` | @@ -589,12 +750,12 @@ the version they're on. | Mobile inbox UI, `IterableInboxFragment`, default rendering | `setting-up-mobile-inbox-on-android` | | Mobile inbox theming, custom cells, swipe actions | `customizing-mobile-inbox-on-android` | | Embedded messaging, placements, `IterableEmbeddedView` | `embedded-messages-with-iterables-android-sdk` | -| Deep linking, App Links, `UrlHandler`, `setAllowedProtocols` | `deep-linking-with-partners` | -| Android App Links (verified domains, `assetlinks.json`) | `android-app-links` | +| Deep links: `urlHandler` and `customActionHandler` routing | `embedded-messages-with-iterables-android-sdk` → `### Step 4.1: Define a URL handler`, `in-app-messages-on-android`, and rule 5 | +| Android App Links (verified domains, `assetlinks.json`, `handleAppLink`) | `android-app-links`, with the corrected names in [Deep links and App Links](#deep-links-and-app-links) | +| Branch or AppsFlyer deep links | `deep-linking-with-partners` (links out to those integrations) | | `track`, `trackPurchase`, `updateCart`, custom event payloads | `tracking-events-with-iterables-mobile-sdks` | -| `setEmail`, `setUserId`, login / logout flow, user identity | `identifying-the-user` | +| `setEmail`, `setUserId`, login / logout flow, user identity | `identifying-the-user`; the rules behind it are in [Decisions](#decisions-read-before-recommending) | | `updateUser`, profile data fields, JSON merging | `updating-user-profiles` | -| Unknown User Activation (anonymous → identified upgrade) | `setting-up-unknown-user-activation` | **Dashboard-side prerequisites** (`archetype: prerequisite`). These document Iterable's web app, not the SDK — reach for them when the developer owns the @@ -603,8 +764,6 @@ See [If they don't have an input yet](#if-they-dont-have-an-input-yet). | If the user is asking about… | Slug | | ---------------------------- | ---- | -| Creating a mobile API key, key types, client-side key security, key scope | `api-keys` | -| Turning on JWT for a key, the shared secret, token claims and expiry | `jwt-enabled-api-keys` | | Creating a Firebase service account, FCM JSON private key, defining a mobile app in Iterable, configuring the push integration, sandbox projects | `fcm-http-v1-migration` (read `## Step 1`, `### Step 2.4`–`### Step 2.7`) | | Creating or finding a placement ID in the dashboard | `embedded-message-placements` | | Embedded message subscription channels and message types | `embedded-message-subscription-channels` | diff --git a/iterable-android/reference/creating-users-with-apis.md b/iterable-android/reference/creating-users-with-apis.md new file mode 100644 index 0000000..acc96a3 --- /dev/null +++ b/iterable-android/reference/creating-users-with-apis.md @@ -0,0 +1,650 @@ +--- +slug: creating-users-with-apis +feature: user-profiles +archetype: identity +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: Creating and Identifying Users with Iterable APIs +source_url: https://support.iterable.com/hc/articles/29156459027348 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/creating-users-with-APIs/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: ad12bc20c0e81b4a3adb18555bbc5dd430e5ea45 +fetched_at: 2026-10-22T09:59:44.570Z +summary: Understanding user identification is fundamental to working with + Iterable's API effectively. The steps for creating and identifying users vary + depending on your project type and the way you identify users. This article + describes how to identify and create users with Iterable's APIs for + email-based, userID-based, and hybrid projects. +--- +# Creating and Identifying Users with Iterable APIs + +Understanding user identification is fundamental to working with Iterable's API +effectively. The steps for creating and identifying users vary depending on +your project type and the way you identify users. This article describes how to +identify and create users with Iterable's APIs for email-based, userID-based, +and hybrid projects. + +For more information about the relationship between project types and user +identification, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +> [!NOTE] +> This article covers _most_ aspects of user identification and creation, +> however, it doesn't cover how to manage unknown users (for example, users who +> don't have an `email` address). +> +> Iterable provides two different ways to manage unknown users: +> - [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956) +> - [Unknown User Activation](https://support.iterable.com/hc/articles/38755339847188) + +## Understanding user identification in API requests + +Many of Iterable's API endpoints require you to specify a particular user (for +example, to modify their user profile or fetch data about them). The way you +identify users depends on your project type. + +### Project types and user identification + +To identify a user, provide a `userId` or an `email` in your API request body. +This field typically goes at the top level of your request, outside of +`dataFields`. + +Your **project type** determines which fields you can use to identify users: + +- **UserID-based projects** identify users by `userId`, and Iterable enforces + the uniqueness of this field. + +- **Hybrid projects** identify users by `email` and/or `userId`, and Iterable + enforces the uniqueness of both. However, you don't have to provide both + fields to every user. + +- **Email-based projects** identify users by `email` or `userId`. However, + Iterable only enforces `email` uniqueness, _not_ `userId`. (Email-based projects have limited availability. To learn more, read [Project Type Availability](https://support.iterable.com/hc/articles/9216719179796#project-type-availability).) + +### Field validation and requirements + +When you send an API request that includes an `email` or `userId`, Iterable +validates these fields regardless of your project type. + +- **`email`**: Iterable checks that the field is a valid email address format + and converts the value to lowercase. Iterable does _not_ check + deliverability. To learn more, read [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +- **`userId`**: Iterable rejects requests with `userId` values that don't meet + certain requirements. To learn more, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + + Additionally, when identifying a user, the `userId` field must be + **non-empty**: + + - Blank, null, or empty values for `userId` are not accepted in requests to + identify users. + + - API requests with a `userId` lookup field that is blank, null, or empty + are rejected. Instead, if a user doesn't have a `userId` value, such as + in a hybrid project, use the `email` field to identify the user and + _don't_ include a blank `userId` field in the API request. + + - For email-based and hybrid projects, API requests that include `userId` + with a null value in `dataFields` with intention to clear the data from a + user profile are accepted. + +### Precedence rules when both fields are provided + +If you include both `email` and `userId` at the top level of an API request, +precedence rules apply based on your project type: + +- **Email-based projects**: `email` takes precedence +- **UserID-based projects**: Only `userId` is valid; `email` is treated as a + regular data field +- **Hybrid projects**: `userId` takes precedence + +### Basic API request structure + +Here's the standard structure for API requests that identify users: + +```json +{ + "userId": "user1234567", // User identifier (outside dataFields) + "dataFields": { // User profile updates go here + "favoriteColor": "red" + } +} +``` + +**Important guidelines:** + +- Place user identifiers (`userId` or `email`) at the top level of your + request, _outside_ of `dataFields`. + +- Place profile updates you'd like to make for that user _inside_ the + `dataFields` object. + +- _Do not include_ empty `userId` values to look up users in your API request. + Iterable rejects blank `userId` when provided to locate a record. However, + API requests for email-based and hybrid projects that include `userId` with a + null value nested in `dataFields`, with intention to clear the data from a + user profile, are still accepted. + +- Some requests require you to place `userId` or `email` inside another object, + rather than at the top level of your API request. For example, when calling + [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart), + you need to place `userId` or `email` inside a `user` object. + +- Additionally, some requests use slightly different field names. For example, + [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + requires `recipientEmail` and `recipientUserId`, rather than `email` and + `userId`. For endpoint-specific details, consult our [API Endpoints and Sample Payloads](https://support.iterable.com/hc/articles/204780579). + +## Email-based projects + +> [!NOTE] +> Email-based projects have limited availability. To learn more, read [Project Type Availability](https://support.iterable.com/hc/articles/9216719179796#project-type-availability). + +In email-based projects, you can pass `email` and/or `userId` in requests to +identify users. The behavior depends on which fields you provide: + +- If both fields are present, `email` is prioritized to identify the user, and + the `userId` field is updated/overwritten for most requests that use `PUT` or + `POST` methods. + +- If `email` is passed and there is no user with that email address, Iterable + creates a new user when using the following endpoints: + + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- If only `userId` is passed (and not `email`), and there is no matching user, + Iterable returns an error. To avoid this and create a user without an email, + include the parameter `preferUserId` with a value of `true`. This creates the + user with an auto-generated placeholder email address using the + `@placeholder.email` domain. For more information about placeholder email + addresses, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). + +- If only `userId` is passed in a request to an email-based project, and + multiple users share the same `userId` but have different `email` values, + Iterable updates only one of the users. This occurs because `userId` is not + enforced as unique in email-based projects. The API selects the first user + returned in the query, but this selection is not consistent and may vary + between requests. + +- Tracking events for an unknown user **does not** create a new user. + +### Email-based project behavior across Iterable + +- Lists must include an `email` column. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are available for static lists. + +- When previewing a template, use `email` to populate sample data. + +- Journey triggers can be tested with `email` only. + +- Static list subscriptions are stored in the `emailListIds` field. + +> [!NOTE] +> This is the only type of project where Iterable creates a `iterableEndUserId` +> browser cookie (which will be deprecated at a future date). This may impact +> your implementation of a [subscription preference center](https://support.iterable.com/hc/articles/208463956) +> and when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285). + +### Identifying users in email-based projects + +In email-based projects, `email` takes precedence when both `email` and +`userId` are provided. For example, this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +looks up or creates a user with `email` address `user@example.com`, then sets +their `favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +### Creating new users in email-based projects + +To create a new user and assign them a unique email address in an email-based +project, make a request to one of the various endpoints that can create new +users in one of these ways: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens + +For example, you can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) with +a request body such as: + +```json +{ + "email": "user@example.com", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If your project doesn't yet have a user with an `email` address of + `user@example.com`, Iterables creates one. + +- Then, whether the user is new or already exists, Iterable sets their + `favoriteColor` to `red`. + +### Creating new users when `email` is unknown + +To create a new user for someone with an unknown email address (in an +email-based project), provide a `userId` instead of an `email`. When you do +this, Iterable creates a new user with a placeholder `email` address using the +`@placeholder.email` domain. + +To learn more about how to do this, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). + +Once a placeholder `email` is assigned to a user, you can identify this user in +subsequent API calls by their `userId`, or by the placeholder `email` given to +them by Iterable. + +### Assigning a `userId` to an existing user + +In an email-based project, you can assign a `userId` to an existing user who is +already identified by `email` by making an API call to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +To learn more about how to do this, read [Updating a User's Email Address or User ID](https://support.iterable.com/hc/articles/217502386). + +## UserID-based projects + +> [!TIP] +> UserID-based is often the most sensible type of project to choose, because +> it: +> +> - Is simple to use. You always identify users by `userId`. +> - Allows you to match Iterable `userId` values with user identifiers you use in +> other systems. +> - Allows multiple users to share the same email address, which can sometimes +> be useful. + +UserID-based projects identify users exclusively by `userId`. In these +projects, `email` is not a user identifier—it's treated like any other profile +field (such as `favoriteColor`). You can assign `email` addresses to users, but +you can't use `email` to look up or modify users in Iterable's web UI or when +making API calls. + +Key considerations for userID-based projects: + +- The `userId` field must always be present and unique for any endpoint that + uses a unique identifier, including GDPR forget and unforget endpoints +- Multiple users can have the same `email` address since email uniqueness is + not enforced +- If `userId` is passed and there is no matching user, Iterable + creates a new user when using the following endpoints: + + - [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + - [`POST /api/events/track`](https://support.iterable.com/hc/articles/204780579#post-api-events-track) + - [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + - [`POST /api/events/trackWebPushClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackwebpushclick) + - [`POST /api/events/trackPushOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackpushopen) + - [`POST /api/events/trackInAppOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappopen) + - [`POST /api/events/trackInAppClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclick) + - [`POST /api/events/trackInAppDelivery`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappdelivery) + - [`POST /api/events/trackInAppClose`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclose) + - [`POST /api/workflows/triggerWorkflow`](https://support.iterable.com/hc/articles/204780579#post-api-workflows-triggerworkflow) + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- When an API request includes the parameter `preferUserId`, it's ignored. + +- If you try to use `email` to identify a user when making an API call, the + request returns an error. You can only identify users with `userId`. + +- You aren't required to provide an `email` for every user you upload to + Iterable. Placeholder email addresses aren't necessary. + +- Iterable doesn't enforce `email` uniqueness. In other words, many users can + have the same email address. + +- Tracking events for an unknown user **creates** a new user. + +### UserID-based project behavior across Iterable + +- When creating a new list, specify a `userId` column, instead of an `email` + column. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are unavailable at this time. + +- When previewing a template, use `email` or `userID` to populate sample data. + +- Journey triggers can be tested with `email` or `userID`. + +- Iterable doesn't set a `iterableEndUserId` browser cookie. + +- To identify users when you create a [custom subscription center](https://support.iterable.com/hc/articles/208463956), pass `userId` + as a URL parameter using the following Handlebars in the [hosted unsubscribe](https://support.iterable.com/hc/articles/206382645#hosted-unsubscribe-url) URL project setting: + + :::v-pre + `{{userId}}` + ::: + +- To identify users when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285), add a URL + parameter with a Key of userId and Value of: + :::v-pre + `{{userId}}` + ::: + +- When a user unsubscribes from a link in an email (`emailUnSubscribe`, `hostedUnsubscribeClick`, + or `emailComplaint` event), Iterable attributes the unsubscribe to the user's + unique `userId`, not the email address that received the message. This is + important when you have users who share an email address. + +- Static list subscriptions are stored in the `userListIds` field. + +- When sending a proof to an unknown email address (including when sending a + proof "to myself" for the first time), Iterable creates a new user and + automatically assigns a `userID` with the value `itbl_generated_{{email}}` + (where the email address is part of the `userId`). + + :::tip TIP + To look up users by email address in a userID-based project, use the + segmentation tool and segment by the contact property `email`. + ::: + +### Identifying users in userID-based projects + +In userID-based projects, `userId` is the _only_ valid user identifier, and +uniqueness is enforced. + +For this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +Iterable looks up or creates a user with `userId` of `user1234567`, then sets +their `favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +Iterable also sets the user's `email` to `user@example.com`, as this is a field +included in the request body. However, since `email` is not a user identifier +in userID-based projects, the API does not verify its uniqueness. Multiple +users could have the same `email` address with different `userId` values. + +### Creating new users in userID-based projects + +In userID-based projects, when you make an API request that includes a `userId` +not yet used by your project, some endpoints create new users: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens +- Track events (individually or in bulk) +- Trigger workflows + +For example, call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), with a +request body such as: + +```json +{ + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with `userId` set to `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their + `favoriteColor` to `red`. + +### Assigning email in userID-based projects + +You _may_ assign an `email` to each user in a userID-based project, but it's +not a required field and isn't used to identify unique users. + +> [!NOTE] +> Since `email` is not a required field in userID-based projects, Iterable does +> not automatically assign `@placeholder.email` addresses like it does in +> email-based projects. + +To do this, call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), with a +request body such as: + +```json +{ + "userId": "user1234567", + "dataFields": { + "email": "user@example.com" + } +} +``` + +For this request: + +- If there's no user with `userId` set to `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `email` to + `user@example.com`. + + :::tip NOTE + In userID-based projects, Iterable does not enforce `email` uniqueness. This + allows you to give the same `email` to many users in the same project, which + can sometimes be useful. + ::: + +## Hybrid projects + +Hybrid projects use both `email` and `userId` as unique identifiers. Key +behaviors include: + +- Use either `userId` or `email` (but not both) to identify users in API + requests. + +- If you provide both fields, `userId` takes precedence. + +- Both `email` and `userId` must be unique, and this policy is enforced. + +- If `userId` and/or `email` are passed and there is no matching user, Iterable + creates a new one when using the following endpoints: + + - [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + - [`POST /api/events/track`](https://support.iterable.com/hc/articles/204780579#post-api-events-track) + - [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + - [`POST /api/events/trackWebPushClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackwebpushclick) + - [`POST /api/events/trackPushOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackpushopen) + - [`POST /api/events/trackInAppOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappopen) + - [`POST /api/events/trackInAppClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclick) + - [`POST /api/events/trackInAppDelivery`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappdelivery) + - [`POST /api/events/trackInAppClose`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclose) + - [`POST /api/workflows/triggerWorkflow`](https://support.iterable.com/hc/articles/204780579#post-api-workflows-triggerworkflow) + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- If you are adding a user with an invalid `email` value, it is rejected + even if it has a `userId` included in the request. + +- When an API request includes the parameter `preferUserId`, it's ignored. + +- You aren't required to provide an `email` for every user you upload to + Iterable—placeholder email addresses aren't necessary. + +- You aren't required to provide a `userId` for every user, however Iterable + doesn't create one when the value is missing. + +- To process a GDPR request to forget a user, you can use either `email` or + `userId` with the [`POST /api/users/forget`](https://support.iterable.com/hc/articles/204780579#post-api-users-forget) + endpoint. + +#### Hybrid project behavior across Iterable + +- When creating a new list, specify either a `email` or `userId` column, at + minimum, or upload a list with both columns. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are available for static lists. + +- When previewing a template, use `email` or `userID` to populate sample data. + +- Journey triggers can be tested with `email` or `userID`. + +- Iterable doesn't set a `iterableEndUserId` browser cookie. +:::v-pre +- To identify users when you create a [custom subscription center](https://support.iterable.com/hc/articles/208463956), pass `userId` + as a URL parameter using Handlebars (`{{userId}}`) in the [hosted unsubscribe](https://support.iterable.com/hc/articles/206382645#hosted-unsubscribe-url) URL project setting. + +- To identify users when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285), add a URL + parameter with a **Key** of `userId` and **Value** of `{{userId}}`. +::: +- Static list subscriptions are stored in the `userListIds` field. + +- When sending a proof to an unknown email address (including when sending a + proof "to myself" for the first time), Iterable creates a new user. + +### Identifying users in hybrid projects + +In hybrid projects, both `userId` and `email` are valid unique identifiers, but +`userId` always takes precedence when both are provided. + +For this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +Iterable finds or creates a user with `userId` of `user1234567` and sets their +`favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +Iterable also attempts to set the user's `email` to `user@example.com` (even +though it is provided outside of `dataFields`). Since hybrid projects enforce +email uniqueness, Iterable first checks if that email is already in use: + +- **If no profile exists with that `email`**: The request succeeds, creating/updating + the user with both identifiers. +- **If a profile exists with that `email`**: The request fails with HTTP status + code `409`—you can't reuse an email address that belongs to another user. + +### Creating new users in hybrid projects + +In hybrid projects, when you make an API request that references a non-existent +`userId` or `email`, some endpoints create new user profiles: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens +- Track events (individually or in bulk) +- Trigger workflows + +For example, you can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +with a request body such as: + +```json +{ + "email": "user@example.com", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with an `email` of `user@example.com`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `favoriteColor` + to `red`. + +Another example request: + +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + +```json +{ + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with a `userId` of `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `favoriteColor` + to `red`. + +> [!NOTE] +> In userID-based projects, Iterable's API ignores the `preferUserId` parameter +> used in email-based projects for some endpoints. + +## Want to learn more? + +For more information about some of the topics in this article, check out these +resources. Iterable Academy is open to everyone — you don't need +to be an Iterable customer! + +**Iterable Academy** +- [Foundations: Introduction to API](https://academy.iterable.com/introduction-to-api) + +**Support docs** +- [API Keys](https://support.iterable.com/hc/articles/360043464871) +- [API Overview and Sample Payloads](https://support.iterable.com/hc/articles/204780579) +- [Responding to GDPR Requests](https://support.iterable.com/hc/articles/360029174171) diff --git a/iterable-android/reference/european-data-center.md b/iterable-android/reference/european-data-center.md new file mode 100644 index 0000000..fb742ff --- /dev/null +++ b/iterable-android/reference/european-data-center.md @@ -0,0 +1,173 @@ +--- +slug: european-data-center +feature: integration +archetype: prerequisite +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: Iterable's European Data Center (EDC) +source_url: https://support.iterable.com/hc/articles/17572750887444 +source_repo: Iterable/iterable-docs +source_path: docs/welcome-to-iterable/about-iterable/iterables-european-data-center-edc/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: f144b8c50c37374866060543ae8bcea9cfbf7099 +fetched_at: 2026-10-22T09:59:47.547Z +summary: New Iterable customers can choose to host their Iterable projects and + data (personal and non-personal) on Iterable's European data center (EDC), + which is in Ireland. +--- +# Iterable's European Data Center (EDC) + +New Iterable customers can choose to host their Iterable projects and data +(personal and non-personal) on Iterable's European data center (EDC), which is +in Ireland.  + +Iterable's EDC is GDPR-compliant, does not send data to the US, handles data +storage and data processing, and offers feature parity with Iterable's USDC-based +offering. + +> [!NOTE] +> Whether your project is USDC-based or EDC-based depends on the terms of your +> Iterable contract and may not correspond to your company's physical location. +> For example, if your company is located in Spain (an EU country), but your +> Iterable account is hosted on Iterable's US data center, you have a USDC-based +> project. This means you should sign in to Iterable using the URL for USDC-based +> Iterable projects ([https://app.iterable.com](https://app.iterable.com)) and use the +> link tracking domain for USDC-based projects `links.iterable.com`). +> +> Most Iterable customers use Iterable's US data center. If you're not sure which +> data center your account is hosted on, check with your Iterable customer success +> manager. + +## Organizations and projects + +In Iterable, a single organization can have multiple projects, each with +separate data. All of an organization's projects must be hosted on the same data +center — all in the US, or all in the EU. + +## UserID-based and hybrid projects only + +Iterable's EDC supports userID-based and hybrid projects, but not email-based +projects. + +For more information about the types of projects Iterable provides, read +[Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## Signing in to Iterable: region-specific URLs + +To sign in to Iterable: + +- For USDC-based projects, visit . +- For EDC-based projects, visit . + +## SAML SSO: region-specific URLs + +USDC- and EDC-based projects use different single sign-on / ACS / reply URLs: + +- USDC-based projects: `https://auth.iterable.com/login/callback?connection=-saml-org-` +- EDC-based projects: `https://auth.eu.iterable.com/login/callback?connection=-saml-org-` + +For more information, read [Setting Up Single Sign-On](https://support.iterable.com/hc/articles/12227358446868). + +## Default link tracking domains: region-specific URLs + +USDC- and EDC-based projects use different default link tracking domains: + +- USDC-based projects: `links.iterable.com` +- EDC-based projects: `links.eu.iterable.com` + +When setting up tracking links, be sure to use the correct domain for your +project type. (Keep in mind that "US/EDC-based" refers to where your project's +data is stored and may not correspond to your company's physical location). + +For more information, read [Setting up Link Tracking Domains to use HTTPS](https://support.iterable.com/hc/articles/115000427446). + +## IP addresses for Iterable's EDC + +Iterable's EDC uses different IP addresses than Iterable's US data center. If +you need to allow Iterable's IP addresses in your firewall, see +[Allowing Iterable IP Addresses](https://support.iterable.com/hc/articles/45758621331732). + +## SMS providers for EDC-based Iterable projects + +To send SMS from EDC-based Iterable projects, you can use: + +- Iterable SMS. +- Your own Telnyx account. + +For EDC-based Iterable projects, Twilio is not supported. + +For more information about SMS, read [SMS Overview](https://support.iterable.com/hc/articles/211970843). + +## Email providers for EDC-based Iterable projects + +To send email from an EDC-based Iterable project, you can use: + +- Iterable's default integration with Amazon SES. +- An Iterable-managed integration with Mailgun, or your own Mailgun account. +- An Iterable-managed integration with Sparkpost, or your own Sparkpost account. + +For EDC-based Iterable projects, Sendgrid is not supported. + +To learn more about setting up email in Iterable, read [Email Setup](https://support.iterable.com/hc/articles/7130133854612). + +## Smart Ingest + +Iterable's [Smart Ingest](https://support.iterable.com/hc/articles/24354008224916) +feature supports EDC-based projects. Smart Ingest allows you to import data from +a supported data source into Iterable. During the setup process, your data +source administrator must allow the IP addresses used by Smart Ingest to +connect to your data warehouse. + +These IPs vary by region, so make sure that you're allowing the correct set of +IPs for a successful connection. To learn more, read [Smart Ingest IPs to allow](https://support.iterable.com/hc/articles/25899748801172#smart-ingest-ips-to-allow). + +## API keys + +Every Iterable API key is associated with a specific Iterable project, which +means that it's also associated with a specific data center: + +- API keys created in a USDC-based project can only be used with Iterable's USDC-based + API endpoints (base URL: `https://api.iterable.com`). +- API keys created in EDC-based project can only be used with Iterable's EDC-based + API endpoints (base URL: `https://api.eu.iterable.com`). + +For more information about API keys, read [API Keys](https://support.iterable.com/hc/articles/360043464871). + +## API documentation + +You can make API calls directly from Iterable's API docs: enter an API key, +choose an endpoint, specify request parameters / a request body, and submit the +request. + +Iterable provides two sets of API documentation: + +- API docs for USDC endpoints - . + + Requests submitted on this page are sent to Iterable's USDC-based API endpoints + (base URL: `https://api.iterable.com`). On this page, you can only use API keys + associated with USDC-based Iterable projects. + +- API docs for EDC endpoints - . + + Requests submitted on this page are sent to Iterable's EDC-based API endpoints + (base URL: `https://api.eu.iterable.com`). On this page, you can only use API + keys associated with EDC-based Iterable projects. + +Our [Getting Started with Iterable's API](https://support.iterable.com/hc/articles/41044692130196) provides +information about each endpoint, too. + +## SDK support + +Iterable's mobile and web SDKs support Iterable's EDC: + +- iOS SDK, [version 6.4.15+](https://github.com/Iterable/iterable-swift-sdk/releases/tag/6.4.15). + To learn more, see [Iterable's iOS SDK](https://support.iterable.com/hc/articles/360035018152). + +- Android SDK, [version 3.4.15+](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.15) + To learn more, see [Iterable's Android SDK](https://support.iterable.com/hc/articles/360035019712). + +- React Native SDK, [version 1.3.16+](https://github.com/Iterable/react-native-sdk/releases/tag/1.3.16) + To learn more, see [Installing Iterable's React Native SDK](https://support.iterable.com/hc/articles/360045714132). + +- Web SDK, [version 1.0.5+](https://github.com/Iterable/iterable-web-sdk/releases/tag/v1.0.5) + To learn more, see [Setting Up Iterable's Web SDK](https://support.iterable.com/hc/articles/4419628585364). \ No newline at end of file diff --git a/iterable-android/reference/handling-anonymous-users.md b/iterable-android/reference/handling-anonymous-users.md new file mode 100644 index 0000000..265b7bd --- /dev/null +++ b/iterable-android/reference/handling-anonymous-users.md @@ -0,0 +1,263 @@ +--- +slug: handling-anonymous-users +feature: user-profiles +archetype: identity +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: Handling Anonymous Users via API +source_url: https://support.iterable.com/hc/articles/208499956 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/handling-anonymous-users/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: dce1b5d51ff199a29e8eae86e4a9b84f3cfde148 +fetched_at: 2026-10-22T09:59:43.888Z +summary: There are many scenarios where you will have users that you need to + track and connect with, but don't have their email address. This happens when + you have mobile apps that don't require an email, SMS-first marketing, and + more. +--- +# Handling Anonymous Users via API + +> [!NOTE] +> This article explains how to work with anonymous users using Iterable's APIs. +> This is a manual approach — you manage placeholder IDs and profile merging +> yourself through the API, without requiring Iterable's iOS, Android, or Web SDKs. +> +> If your apps or website use Iterable's iOS, Android, or Web SDKs, consider +> [Unknown User Activation](https://support.iterable.com/hc/articles/38755339847188) +> instead. Unknown User Activation automates profile creation, local data +> replay, and profile merging. Marketers can define profile creation criteria +> in the Iterable UI without redeploying your app or website. + +There are many scenarios where you will have users that you need to track and +connect with, but don't have their email address. This happens when you have +mobile apps that don't require an email, SMS-first marketing, and more. + +You might need to track anonymously in the following scenarios: + +- You don't have a user's email address but want to track their event history + and save profile data in Iterable. +- You have the user's device token and want to send them a push notification, + but don't yet have their email address. +- You have the user's phone number and want to send them SMS, but don't yet + have their email address. + +Iterable provides two different unique identifier fields, `email` and `userId`, +and these fields apply to projects depending on the project type: + +- **Hybrid project** - uses both `email` and `userId` fields as unique + identifiers. +- **UserID-based project** - uses just the `userId` field as a unique + identifier. +- **Email-based project** - uses `email` as a unique identifier. + (Limited availability: Only certain accounts have the option to create _new_ + email-based projects - [learn more](https://support.iterable.com/hc/articles/9216719179796#project-type-availability).) + +To work with anonymous users in userID-based projects and hybrid projects, you will +simply use the `userId` field instead of the `email` field to identify users. + +If you are working in an email-based project, however, then all user profiles require a +value for the `email` field. When you don't know the email address of a new user, +but need to create a profile in Iterable, you will need to create a placeholder +email address. + +> [!NOTE] +> If you aren't sure which unique identifier your project uses, or to learn more +> about each project type and how they work, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## Placeholder emails for email-based projects + +A placeholder email contains a unique string followed by `@placeholder.email`. + +Example: `some-user+10593364321@placeholder.email` + +### Placeholder email format + +An auto-generated placeholder email is generated with the following formula: +`userId` + `+` + Java hash + `@placeholder.email`. + +When the `userId` value is longer than 52 characters, Iterable creates the +placeholder email as: SHA1 hash + `@placeholder.email`. + +For example: `4146832971d799352598ff1b5712c0b999f615d5@placeholder.email` + +### Sending campaigns to users with placeholder emails + +A placeholder email is _not_ a valid recipient address. Email campaigns to +placeholder addresses are not attempted, and send skip events aren't logged +for these addresses. + +However, if the user's profile has other sending identifiers — such as a device +token for push notifications, or a phone number for SMS — you can still send +messages to them on those channels. + +## Creating a placeholder email + +You can manually assign a placeholder email to a user, or have Iterable automatically +generate one for you when creating the user. + +### Manually creating a placeholder email + +Placeholder email addresses in Iterable must end with `@placeholder.email`. +You can create placeholder emails by using the [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +API endpoint. + +For example, if adding users through the API: + +```json +{ + "email": "example@placeholder.email", + "dataFields": { + "firstName": "Data", + "catName": "Spot" + } +} +``` + +### Automatically assigning a placeholder email + +_Email-based projects only_ + +Instead of manually assigning a placeholder value for the `email` field, you +can also ask Iterable to generate one for you when creating the user. This is +only available if you are working in an email-based project. Iterable doesn't +create placeholder emails for userID-based and hybrid projects. + +To create a user with a placeholder email, use [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +with the following parameters in the request body: + +- `userId`: The user's ID +- `preferUserId`: `true` + +For example: + +```json +{ + "userId": "some-user-id", + "preferUserId": true, + "dataFields": { + "firstName": "Mary", + "lastName": "Lee" + } +} +``` + +This creates a user with `some-user-id+10593364321@placeholder.email` +as the assigned placeholder `email`: + +```json +{ + "user": { + "email": "some-user-id+10593364321@placeholder.email", + "userId": "some-user-id", + "dataFields": { + "firstName": "Mary", + "lastName": "Lee", + "email": "some-user-id+10593364321@placeholder.email", + "signupDate": "2022-10-27 22:12:43 +00:00", + "profileUpdatedAt": "2022-10-27 22:12:39 +00:00", + "signupSource": "API", + "userId": "some-user-id", + "itblInternal": { + "documentCreatedAt": "2022-10-27 22:12:43 +00:00", + "emailDomain": "placeholder.email", + "documentUpdatedAt": "2022-10-27 22:12:43 +00:00" + } + } + } +} +``` + +## Updating a placeholder email to the user's true email + +When your user has converted from anonymous to known and they have provided +their true email address, make a call to Iterable's +[`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +endpoint. + +For example, to update an `example@placeholder.email` email to `user@example.com`, +the payload would look like this: + +```json +{ + "currentEmail": "example@placeholder.email", + "newEmail": "user@example.com" +} +``` + +This call must be made in order for the historical data and profile fields to be +transferred to the new email address for the user in Iterable. + +> [!NOTE] +> - You cannot use the [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +> endpoint if the `newEmail` value already exists in Iterable. If `newEmail` is an +> existing user profile, see [below](#best-practices-for-handling-anonymous-users) +> for information about how to handle this. +> - Updating email addresses can only be done through Iterable's API. It cannot be +> done through Segment. + +## Best practices for handling anonymous users + +When you receive or have determined the user's actual email address, use the +[`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +API endpoint to update the existing user's email address. + +- The [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + API endpoint transfers historical data and profile fields from the placeholder + email to the new email address. + +- The [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + API endpoint creates a completely separate user profile. + + Once a completely separate user profile has been created for the user's actual + email address, you can't use the [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + API endpoint to update the placeholder email address to the user's actual email + address. + +If a completely separate user profile has been created and you wish to merge the +historical data and profile fields from a placeholder email address to the +user's actual email address, you will need to complete the following: + +- For user profile fields: + + - Use the [`GET /api/users/{email}`](https://support.iterable.com/hc/articles/204780579#get-api-users-email) + API endpoint to retrieve the user profile fields associate with the placeholder + email address. + + - Use the [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + API endpoint to set the placeholder email address's properties (fetched + above) on the actual email address's profile. + + :::tip WARNING + This approach will overwrite the user profile fields for the user's actual + email address with the user profile fields from the placeholder email + address. + ::: + +- For custom events: + + - Use the [`GET /api/events/{email}`](https://support.iterable.com/hc/articles/204780579#get-api-events-email) + API endpoint to retrieve the placeholder email address's custom events. + + - To record the events of the placeholder email address onto the user's + actual address profile, use the [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + to create the custom events. + + - Make sure you edit the `createdAt` field in the API call to backfill + the events. + + :::tip NOTES + - If the `createdAt` field is left blank, Iterable will automatically + assign the current date and timestamp to that field . + - Backfilling events will trigger any journeys that are triggered + by those custom events. + - Consider adding a filter tile after the Start tile that will allow + users with backfilled events to exit the journey. This can be done + through the creation date of the custom event or by including a data + field in the event itself that notes that it is a backfilled event. + ::: + +- Call [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + when a user signs in to the app and [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice) + when they sign out. This prevents duplicate sends to a device shared amongst + multiple people. diff --git a/iterable-android/reference/project-types-and-unique-identifiers.md b/iterable-android/reference/project-types-and-unique-identifiers.md new file mode 100644 index 0000000..8c733a0 --- /dev/null +++ b/iterable-android/reference/project-types-and-unique-identifiers.md @@ -0,0 +1,148 @@ +--- +slug: project-types-and-unique-identifiers +feature: user-profiles +archetype: prerequisite +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: Project Types and Unique Identifiers +source_url: https://support.iterable.com/hc/articles/9216719179796 +source_repo: Iterable/iterable-docs +source_path: docs/setup-and-administration/projects/project-types-and-unique-identifiers/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: a37d7e60a1978d0dceb462a9b64214f6fec36935 +fetched_at: 2026-10-22T09:59:46.982Z +summary: Every Iterable project uses a specific field (`email` and/or `userId`) + to uniquely identify each user. This allows an individual record to be + referenced without confusion. This selection has a lasting impact on how your + project works and how you access data via API. +--- +# Project Types and Unique Identifiers + +Every Iterable project uses a specific field (`email` and/or `userId`) to +uniquely identify each user. This allows an individual record to be referenced +without confusion. This selection has a lasting impact on how your project works +and how you access data via API. + +Continue reading to learn more about the different project types and unique +identifiers, as well as how to determine the best setting for your project. + +> [!NOTE] +> Currently, there's no way to change the unique identifier for an existing +> project. You can only make this selection when creating a project. + +## Project types + +In Iterable, you define a _project type_ by specifying which field to use +to identify users. + +Iterable supports these project types: + +- **Hybrid projects**: Use both `email` and `userId` as unique identifiers. +- **UserID-based projects**: Use `userId` as a unique identifier. +- **Email-based projects** (_Limited availability_): Use `email` as a unique + identifier. + - Only certain accounts have the option to create new email-based projects—[learn more](#project-type-availability). + +### Comparing project types + +Let's compare how different project types handle your data when creating and +identifying users. + +| |**Hybrid projects**|**UserID-based projects**|**Email-based projects** (_Limited availability_)| +|---|---|---|---| +|**Unique identifier** |`email` and `userId`
(at least one of these fields is required)|`userId`|`email`| +|**Enforce `email` uniqueness?**|Yes|No|Yes| +|**Enforce `userId` uniqueness?**|Yes|Yes|No| +|**[How to identify users in API requests](https://support.iterable.com/hc/articles/29156459027348)**|`email` or `userId`|`userId`|`email` or `userId`| +|**Contact lookup by...**|`email` or `userId`|`userId`|`email`| +|**Static subscription list field**|`userListIds`|`userListIds`|`emailListIds`| +|**"Placeholder" email addresses assigned to anonymous users**|No|No|Yes| +|**[Signup form](https://support.iterable.com/hc/articles/207914276) support**|Yes|No|Yes| +|**GDPR forget by...**|`email` or `userId`|`userId`|`email`| +|**[EDC](https://support.iterable.com/hc/articles/17572750887444) support**|Yes|Yes|No| +|**Available for new accounts?**|Yes|Yes|[No](#project-type-availability)| + +### Choosing a project type + +Because the unique identifier project setting has implications across your data +platforms and you can’t change it later, consult with your development teams and +other interested stakeholders (such as CRM administrators) _before_ creating +your project in Iterable. + +Consider these points when choosing a project type: + +- **UserID-based projects may be best when you need to...** + - Track anonymous users (see [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956)). + - Support multiple users with the same email address, or single users with + multiple accounts. + - Avoid using email as a parameter in your API requests. + +- **Hybrid projects allow you to...** + - Maintain email uniqueness (one email per user) and keep track of anonymous + users. + - Use `userId` as a primary identifier while ensuring that every user's email + address is unique. + +> [!NOTE] +> +> **Limitations:** +> +> - Email-based projects have [limited availability](#project-type-availability). +> +> - Iterable doesn't support multiple email addresses or phone numbers for a +> single user. +> +> - Iterable does not generate values for the `userId` field. You must include +> the value when creating users. To learn more, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). +> +> The only exception where Iterable generates a `userId` value is when using +> Unknown User Activation. To learn more, read [Unknown User Activation +> Overview](https://support.iterable.com/hc/articles/38755339847188). + +#### Project type availability + +Originally, all Iterable projects identified users by email address. However, +for greater flexibility and to encourage best practices in data management +and privacy, Iterable introduced userID-based and hybrid projects. These +projects can identify users by `userId` and/or `email`, depending on your data +needs. + +All Iterable accounts can create userID-based and hybrid projects. However, +email-based projects can only be created by Iterable accounts that meet _all_ +of the following criteria: + +- Organizations that are hosted in Iterable's USDC-based data center. +- Organizations that were created before May 28, 2024. +- Organizations that have created an email-based project since May 28, 2023. + +If your Iterable account doesn't meet these criteria, and you need to identify +users by `email`, create a hybrid project. + +## Unique identifiers + +Each user in an Iterable project must have a unique identifier, which depends +on the project type: + +- `email`, in email-based projects +- `userId`, in userID-based projects +- Either field, in hybrid projects + +To learn about the `email` and `userId` fields and their data requirements, see +[User Profile Fields Used by Iterable](https://support.iterable.com/hc/articles/217744303). + +To learn about how each field is handled across Iterable in the API, see +[Creating and Identifying Users with Iterable APIs](https://support.iterable.com/hc/articles/29156459027348). + +### Checking the unique identifier for a project + +To check the unique identifier(s) for your project, you can: + +- Go to **Settings > Project Settings** and see the project type under the + **Unique Identifier** setting. + +- Go to **Audience > Contact Lookup** and see which fields you can search for. + You can only find users by unique identifiers here. + +> [!NOTE] +> Currently, there's no way to change the unique identifier for an existing +> project. You can only make this choice when creating a project. \ No newline at end of file diff --git a/iterable-android/reference/updating-a-users-email-address.md b/iterable-android/reference/updating-a-users-email-address.md new file mode 100644 index 0000000..595b952 --- /dev/null +++ b/iterable-android/reference/updating-a-users-email-address.md @@ -0,0 +1,199 @@ +--- +slug: updating-a-users-email-address +feature: user-profiles +archetype: identity +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: Updating a User's Email Address or User ID +source_url: https://support.iterable.com/hc/articles/217502386 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/updating-a-users-email-address/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: 510751fc6ae6e983ceae45146ba7e44f491ee7b2 +fetched_at: 2026-10-22T09:59:45.164Z +summary: Sometimes you'll need to change the email address or user ID for a + user—perhaps they were previously anonymous and now they have converted to a + known contact, their `email` has changed, or you are updating your project's + `userId` fields to standardize, correct, or otherwise update this value. +--- +# Updating a User's Email Address or User ID + +Sometimes you'll need to change the email address or user ID for a +user—perhaps they were previously anonymous and now they have converted to a +known contact, their `email` has changed, or you are updating your project's +`userId` fields to standardize, correct, or otherwise update this value. + +This article describes how to change these fields using Iterable's API. + +> [!WARNING] +> After updating a user profile (for example, by calling [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update)), +> wait 60 seconds before making other API calls related to the same user. +> +> For GDPR forget requests ([`POST /api/users/forget`](https://support.iterable.com/hc/articles/204780579#post-api-users-forget)), +> provide ten minutes to process. +> +> These delays can help prevent you from accidentally sending a campaign to the +> wrong email address, and other similar problems. + +## Email-based Projects + +### Updating email address in an email-based project + +Email-based projects identify users by `email`. To update a user's email +address, call [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail), +with a request body such as: + +```json +{ + "currentEmail": "current@example.com", + "newEmail": "new@example.com" +} +``` + +In the request body, specify `currentEmail` or `currentUserId`, but not both. +If you provide both, `currentEmail` takes precedence. + +### Updating user ID in an email-based project + +To add or update the `userId` field for a user in an email-based project, you +can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +In the request body: + +- For `email`, specify the existing user's email address. +- In `dataFields`, provide a `userId`. + +For example: + +```json +{ + "email": "existingUser@example.com", + "dataFields": { + "userId": "newUserId" + } +} +``` + +For this request: + +- Iterable checks to see whether your project already has a user with `email` + set to `existingUser@example.com`. It not, the user is created. +- Whether the user is new or already exists, Iterable assigns a `userId` of + `newUserId` to the user. + +To add or update a user's `userId` in an email-based project, call +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with the new `userId` value inside of `dataFields`. + +> [!WARNING] +> **Email-based projects do not enforce `userId` uniqueness.** +> +> This means you won't receive an error if you update a user to have a non-unique +> `userId` value, and a duplicate `userId` will exist in your Iterable project. +> This may cause problems when making API calls based on `userId`. +> +> If you need unique `userId` values, consider creating a userID-based project or +> a hybrid project. To learn more, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## UserID-based projects and hybrid projects + +### Updating email address in a userID-based project or hybrid project + +To update an existing user's `email` in a userID-based or hybrid project, call +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with a request body such as: + +```json +{ + "userId": "someUserId", + "dataFields": { + "email": "newEmail@example.com" + } + } +``` + +### Updating user ID in a userID-based project or hybrid project + +To update an existing user's `userId` in a userID-based or hybrid project, +call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with a request body such as: + +```json +{ + "userId": "oldUserId", + "dataFields": { + "userId": "newUserId" + } + } +``` + +## Common Errors + +There may be errors encountered when you are attempting to update a unique +identifier for a user. This happens because values are uniquely enforced and +conflicts can result in errors. + +### Unique `email` projects + +Both email-based projects and hybrid projects enforce a unique value for the +`email` field. + +When updating the `email` field, watch out for these common errors: + +- When `currentEmail` does not exist: + + ```json + { + "msg": "Cannot change user Some(oldEmail@example.com) to newEmail@example.com. User does not exist", + "code": "BadParams", + "params": null + } + ``` + +- When `newEmail` already exists: + + ```json + { + "msg": "Cannot change user Some(oldEmail@example.com) to newEmail@example.com. New email already exists", + "code": "EmailAlreadyExists", + "params": null + } + ``` + +- When `newEmail` has been GDPR forgotten: + + ```json + { + "msg": "Cannot change user oldEmail@example.com to newEmail@example.com. New email is on the forgotten list", + "code": "EmailAlreadyExists", + "params": null + } + ``` + +### Unique `userId` projects + +UserID-based projects and hybrid projects enforce unique `userId` values. Here +are some things you may sees when updating a `userId` value: + +- When the user you're trying to update doesn't actually exist, you'll receive + a `200` response, and Iterable creates the user with the new `userId`. + +- When the updated `userId` is already taken by another user: + + ```json + { + "msg": "userId already exists: newUserId", + "code": "ExternalKeyConflict", + "params": null + } + ``` + +- When the original `userId` has been GDPR forgotten: + +```json +{ + "msg": "User with userId originalUserID is forgotten", + "code": "ForgottenUserError", + "params": null +} +``` diff --git a/iterable-android/reference/user-id-validation-in-iterable.md b/iterable-android/reference/user-id-validation-in-iterable.md new file mode 100644 index 0000000..389f44b --- /dev/null +++ b/iterable-android/reference/user-id-validation-in-iterable.md @@ -0,0 +1,98 @@ +--- +slug: user-id-validation-in-iterable +feature: user-profiles +archetype: identity +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: User ID Validation in Iterable +source_url: https://support.iterable.com/hc/articles/41111923969556 +source_repo: Iterable/iterable-docs +source_path: docs/audience-management/users-and-user-profiles/user-id-validation-in-iterable/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: d2af79dc054d08c4d6149b66d9879935056b6856 +fetched_at: 2026-10-22T09:59:42.657Z +summary: "Whenever you create or update a user in Iterable, you can provide a + `userId` value. When you do this, Iterable performs the following validations + on the `userId` field:" +--- +# User ID Validation in Iterable + +Whenever you create or update a user in Iterable, you can provide a `userId` +value. When you do this, Iterable performs the following validations on the +`userId` field: + +- **Maximum length** of 128 characters. + +- **Must not contain non-printable ASCII characters**, such as + [non-breaking spaces](https://www.ascii-code.com/character/nbsp) and + [soft hyphens](https://www.ascii-code.com/character/shy). + + Non-printable characters are typically created programmatically and aren't + visible printed characters. Not to be confused with space, tab, or other + whitespace characters, or with [hard hyphen](https://www.ascii-code.com/character/-) + (also called a minus sign). + +- **Must not end with a space**. + +- Values are **case-sensitive**. + +- Iterable doesn't generate values for this field. You must include the value + when creating users. + +- Values should **not contain PII**, such as an email or phone number. + +- **Uniqueness** is: + - Enforced in userID-based and hybrid projects. + - _Not_ enforced in email-based projects. In the event the same `userId` is + associated with multiple users, there are some important considerations. To + learn more, read [Identifying users by `userId` in email-based projects](https://support.iterable.com/hc/articles/29156459027348#email-based-projects). + +## Providing a `userId` value + +Iterable does not generate values for the `userId` field. You must include +the value when creating users. + +> [!NOTE] +> The only exception is when you are using Unknown User Activation, in which case +> Iterable generates a `userId` value for you. To learn more, read [Unknown User +> Activation Overview](https://support.iterable.com/hc/articles/38755339847188). + +### User ID best practices + +Ideally, user ID values are static (unchanging), not guessable, and meaningless +outside of the context where it is used. + +Here are some good options for `userId` field values: + +- The same ID field you use to identify a user in your application—assuming + it meets these best practices. + +- The same ID field as another, primary system of record (such as your CRM, or + another integration that you rely on to store your user's data). + +- Randomly generated universally unique identifiers (UUIDs). [Learn more](https://en.wikipedia.org/wiki/Universally_unique_identifier). + +- Other database-generated unique IDs. + +Practices to avoid: + +- Anything that is considered personally identifiable information (or PII), such + as: + + - Names + - Email addresses + - Phone numbers + - Dates of birth + +- Any values that may be guessable, such as: + + - Formulas based on PII information + - Sequential values (10001, 10002, 10003 ... _n+1_) + - Usernames or "handles" + +> [!WARNING] +> +> **You can edit values in the `userId` field.** +> +> Values stored in the `userId` field are not permanent in Iterable. They can be +> overwritten or changed. Learn more at [Updating a User's Email Address or User ID](https://support.iterable.com/hc/articles/217502386). diff --git a/iterable-android/reference/user-profile-fields-used-by-iterable.md b/iterable-android/reference/user-profile-fields-used-by-iterable.md new file mode 100644 index 0000000..3df806d --- /dev/null +++ b/iterable-android/reference/user-profile-fields-used-by-iterable.md @@ -0,0 +1,517 @@ +--- +slug: user-profile-fields-used-by-iterable +feature: user-profiles +archetype: identity +sdk_min_version: 3.7.0 +sdk_artifact: iterableapi +title: User Profile Fields Used by Iterable +source_url: https://support.iterable.com/hc/articles/217744303 +source_repo: Iterable/iterable-docs +source_path: docs/audience-management/users-and-user-profiles/user-profile-fields-used-by-iterable/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: 51d92087789bba35ac0db9c47d2a9e9c3c7bdfd8 +fetched_at: 2026-10-22T09:59:43.294Z +summary: User profiles in Iterable contain various fields that Iterable uses for + specific purposes. This document describes these fields and their meanings. +--- +# User Profile Fields Used by Iterable + +User profiles in Iterable contain various fields that Iterable uses for specific +purposes. This document describes these fields and their meanings. + +> [!NOTE] +> The character limit for user profile fields is approximately 32k characters. +> All user profile fields are case-sensitive and space-sensitive. + +## Fields used as unique identifiers + +Each user in an Iterable project must have a unique identifier. Two fields are +available in Iterable to uniquely identify users: `email` and `userId`. When you +create your project, you will choose a _project type_. The project type +determines which field(s) you can use to identify your users. It also impacts +how you manage records in Iterable. + +For more information, see [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). For information about how these fields are used with +Iterable’s APIs, see [Creating and Identifying Users with Iterable APIs](https://support.iterable.com/hc/articles/29156459027348). + +### Identifying users by `email` + +Iterable uses the [`email`](#email) field as a unique identifier for users in +hybrid and email-based projects. To learn more about the `email` field and +accepted values, see [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +In hybrid and email-based projects, each user's `email` **must be unique**. + +In userID-based projects, `email` uniqueness is not enforced and there may +be multiple users with the same `email` address. + +### Identifying users by `userId` + +The [`userId`](#userid) field stores a unique value that you've assigned to +identify each user. To learn more about the `userId` field and accepted values, +see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + +In userID-based and hybrid projects, each user's `userId` **must be unique**. + +In email-based projects, unique values for `userId` are not required or +enforced. In the event the same `userId` is associated with multiple users, +there are some important considerations. To learn more, read +[Identifying users by `userId` in email-based projects](https://support.iterable.com/hc/articles/29156459027348#email-based-projects). + +## Fields managed by Iterable + +Iterable manages the fields described below. These fields are visible in the +responses to various API calls, but (unless otherwise noted) they should not +be included in calls to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +or other endpoints that update user profiles. + +### `emailListIds` + +An array of integers that represent the static lists of which a user is a +member. The values in this array change as the user is added to or removed +from lists. + +This array does not contain IDs for dynamic lists. + +This field applies to email-based projects only. For userId and hybrid +projects, Iterable uses [`userListIds`](#userlistids). + +### `itblInternal` + +A collection of various fields managed by Iterable. + +### `itblInternal.phoneCountry` + +A two-letter code that represents the country associated with the user's +[`phoneNumber`](#phonenumber), as defined by [ISO-3166](https://www.iso.org/iso-3166-country-codes.html). + +When you send an SMS campaign to a user, Iterable checks to see if the user has +`itblInternal.phoneCountry` set. If it isn't set, Iterable uses `phoneNumber` +to look up the ISO-3166 country code, and adds that country code to the user's +profile. + +Currently, this field is only used for reporting purposes, and is not used to +determine a user's language or system actions related to a user's country. + +> [!TIP] +> +> There are a couple of ways to measure SMS success by country: +> +> - [Campaign Analytics](https://support.iterable.com/hc/articles/218203223#segment-analysis) — +> Create a segment analysis using `itblInternal.phoneCountry`. +> - [Messaging Insights](https://support.iterable.com/hc/articles/360052814452) - +> In the **Audience** view, select the `itblInternal.phoneCountry` segment. + +### `itblInternal.phoneType` + +Iterable also stores the user's phone type in the `itblInternal.phoneType` field, +which is a string that represents the type of phone number the user has. + +This is an informational field that you may find useful when reviewing your user +profiles or creating segments. For example, some number types can be blocked from +receiving SMS messages, which is helpful when determining the size of your SMS-ready +audience. + +This field may have one of the following values: +- `MOBILE` - Mobile phone number +- `FIXED_LINE` - Fixed line phone number +- `FIXED_LINE_OR_MOBILE` - Fixed line or mobile phone number +- `TOLL_FREE` - Toll-free number +- `PREMIUM_RATE` - Premium rate number +- `SHARED_COST` - Shared cost number +- `VOIP` - Voice over IP number +- `PERSONAL_NUMBER` - Personal number +- `PAGER` - Pager number + +### `itblDS` + +An object containing information related to [Iterable AI](https://support.iterable.com/hc/articles/360052990091). +Its `brandAffinityLabel` field represents the contact's level of engagement +with your brand, as determined by [Brand Affinity™](https://support.iterable.com/hc/articles/360052990191). +Its `predictiveGoals` field provides user conversion scores for goals that are +created in Iterable's [Predictive Goals](https://support.iterable.com/hc/articles/5026416447636#view-a-user-s-conversion-scores). + +### `itblUserId` + +A unique identifier used internally by Iterable for hybrid and userID-based +projects. Using this field to reference a user is NOT recommended. Instead, use +[`userId`](#userid), which is a field that you can access and manage. + +### `knownLitigatorFilter` + +A string that represents the confidence level of the user's known litigator status. +The possible values are `LT1`, `LT2`, and `LT3`. The `LT1` value denotes high +confidence that the phone number has been involved in TCPA litigation, while the +value `LT2` denotes a lower confidence level. If a user is detected as both LT1 +and LT2, the field will be updated to `LT3`. + +### `profileUpdatedAt` + +The most recent time at which a user's profile was updated (via CSV upload or +API call). This field is a date, and it has the following format: +`2016-08-02 18:53:45 +00:00`. For more information, read +[Field Data Types](https://support.iterable.com/hc/articles/208183076). + +> [!NOTE] +> Previously, Iterable would process user update requests that didn't actually +> change the user's profile data. This resulted in an update to the +> `profileUpdatedAt` field even though the user profile hadn't actually changed. +> As of February 2023, `profileUpdatedAt` is no longer modified when user profile +> data has not changed. To learn more about this change, see +> [2023 Release Notes](https://support.iterable.com/hc/articles/11667879575572#february-2023). +> +> In the rare case you need the original behavior, talk to your customer success +> manager. + +### `receivedSMSDisclaimer` + +Indicates whether or not the user has received an SMS message, from the current +Iterable project, that includes SMS opt-out instructions. Receiving opt-out +instructions in a proof will not set this field to `true`. + +### `signupDate` + +The date when the user's profile was created in Iterable. This field is a date, and +it has the following format: `2016-08-02 18:53:45 +00:00`. For more information, +read [Field Data Types](https://support.iterable.com/hc/articles/208183076). + +### `signupSource` + +How the contact was created in Iterable (for example, `API` or `IMPORT`). + +### `subscribedMessageTypeIds` + +An array of integers that represent the opt-in message types to which a contact has +subscribed. Contacts will receive messages associated with opt-in message types only +if both of the following are true: + +- They have subscribed to those opt-in message types +- They have not unsubscribed from the channels associated with those types + +> [!WARNING] +> `subscribedMessageTypeIds` is available only for accounts or projects for which +> [opt-in message types](https://support.iterable.com/hc/articles/204780529) have +> been enabled. To do so, contact your Iterable customer success manager. + +### `unsubscribedChannelIds` + +An array of integers that represent the message channels from which a user has +unsubscribed. Users will not receive messages from any message type belonging to a +message channel from which they have unsubscribed. + +### `unsubscribedMessageTypeIds` + +An array of integers that represent the opt-out message types from which a user has +unsubscribed. + +Users will receive messages from opt-out message types only if both of the following +are true: + +- They have not unsubscribed from those opt-out message types +- They have not unsubscribed from the channels associated with those types + +### `userListIds` + +An array of integers that represent the static lists of which a user is a +member. The values in this array change as the user is added to or removed +from lists. + +This array does not contain IDs for dynamic lists. + +This field applies to userId and hybrid projects. For email-based projects, +Iterable uses [`emailListIds`](#emaillistids). + +## Fields used for sending messages + +When sending messages, Iterable relies on the fields described below. + +> [!WARNING] +> The specific names of these fields are important. For example, Iterable uses the +> `phoneNumber` field when sending SMS messages; it won't recognize `phone_number` +> or `PhoneNumber`. + +### `country` + +The user's country. + +**Accepted values** - Any string value. This field has a string data type and +is not validated. When you add this data to Iterable, you can enter any value +your company uses to represent a user's country. + +Alternatively, you can set up your project so that Iterable automatically looks +up the country based on the user's IP address. When Iterable stores the +`country` field, it uses the English-language name of the country, as found in +the [GeoNames database](https://www.geonames.org/countries/). + +When using SMS Smart Opt-In, Iterable uses the `country` field to assign a +number from your SMS senders that matches the user's country. For complete +detail on how `country` is used to assign a number, see +[SMS Smart Opt-In](https://support.iterable.com/hc/articles/10870034844308#how-sms-smart-opt-in-selects-a-number-for-the-user-to-reply-to). + +Iterable can automatically populate the `country` field based on the user's IP +address. This lookup is triggered whenever the `ip` field is updated on the +user profile. + +To learn more about updating a user's IP and/or geolocation fields using +inbound data, read [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `devices` + +A nested data type that contains an array of objects, each representing a +mobile device/mobile app combination for which a user can (or used to be able +to) receive push notifications (silently or otherwise). + +Each object in the `devices` array contains the following fields: + +- `applicationName` - A string that identifies the mobile app for which the user + can receive push notifications. + +- `appPackageName` - The unique application identifier associated with your app + (as specified by its developers). Different for iOS and Android. + +- `appVersion` - The version of your app running on the user's device. + +- `deviceId` - A randomly generated UUID for a user's device. Generated by Iterable + and stored in local storage on the device, and in Iterable itself. When the + user re-installs the app, a new one is created (we don't track a device across + app reinstalls). + +- `endpointEnabled` - Indicates whether or not Iterable can send push notifications + to this device. If a user prevents an app from displaying push notifications, + but after the app has already registered its device token with Iterable, the + app's code should disable the device in Iterable by calling [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice). + This sets `endpointEnabled` to `false` for this device, preventing Iterable from + sending future push campaigns to it. If the app does not disable the device, + Iterable will continue to send push campaigns to it. The user will never see those + campaigns, but they'll still be counted in the campaign's total send count. + To check if Iterable can send push notifications to a given device/app + combination, check its `endpointEnabled` field. If `true`, Iterable can send push + notifications to that device. + +- `identifierForVendor` - iOS only. An alphanumeric string that uniquely identifies a + device to the app’s vendor. Unique to that device for that app developer. + +- `iterableSdkVersion` - The release version of the SDK. Numbers will differ + by platform (iOS, Android, React Native), correspond with the release numbers + on our Github pages ([iOS](https://github.com/Iterable/iterable-swift-sdk/releases), + [Android](https://github.com/Iterable/iterable-android-sdk/tags), + [React Native](https://github.com/Iterable/react-native-sdk/releases)). + +- `localizedModel` - iOS only. the model of the device. For example: "iPhone", + "iPad", "iPod Touch". + +- `model` - The model name of the device. Apple examples: "iPhone", "iPad", "iPod Touch". + Android example - "sdk_gphone_x86_arm". + +- `notificationsEnabled` - whether or not push notifications are enabled on the + device. + +- `platform` - The platform from which the device can receive push notifications. + For example, `APNS_SANDBOX`. + +- `platformEndpoint` - The specific address Iterable uses to reach the device + by push notification. + +- `systemVersion`: The version number of the device's operating system. + +- `token` - A string Iterable uses to identify the device when sending it a push + notification. + +- `userInterfaceIdiom`: iOS only. The device's category. For example: "Phone", + "Pad", "carPlay". + +> [!TIP] +> **TIPS** +> +> - To add a device to this list (or re-enable one that is already there), call +> [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken). +> - To disable a device on the list, so that it will no longer receive messages, call +> [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice). + +### `email` + +A user's email address. + +- This field is validated. To learn more, see [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +- Iterable uses this field to send email campaigns to users. + + - An exception to this is anonymous users with `@placeholder.email`. These users + have placeholders in order to identify the user in Iterable, but their `email` + is not a valid recipient address. Email campaigns sent to `@placeholder.email` + addresses are skipped. (Send skip events are not logged for this scenario.) See + [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956) + to learn more about placeholder emails. + +- `email` is one of two possible fields that uniquely identify Iterable users, + along with [`userId`](#userid). To continue learning about the `email` field, + unique identifiers, and how they work, read + [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +### `ip` + +The user's IP address. + +Iterable can automatically update the `ip` field based on the IP address +contained in inbound email click (`emailClick`) and/or SMS click (`smsClick`) +events. This ensures that the `ip` field is refreshed regularly for your most +engaged users. To learn more, read [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `locale` + +A user's location and/or language preference. If you have multiple locale +variants of a template (all with the same `templateId`), Iterable uses this +field to determine which version to send to the user. + +This field maps to the codes you set up in the [Locales](https://support.iterable.com/hc/articles/12441621049876) +section of your Project Settings page. + +### `phoneNumber` + +A string that represents the user's phone number. Iterable uses this number when +sending SMS messages. + +Values must be formatted using [E.164](https://en.wikipedia.org/wiki/E.164). +For example: + +- US: `+14155550132` +- Germany: `+498999998000` + +When you send an SMS to the user, Iterable checks to see if the user has +[`itblInternal.phoneCountry`](#itblinternal-phonecountry) set. If it doesn't, +Iterable uses `phoneNumber` to look up the country code and add it to the user's +profile. + +Iterable doesn't send SMS campaigns to users without a `phoneNumber` set. + +> [!WARNING] +> When updating a `phoneNumber`, if you don't include a country code, Iterable +> automatically adds `+1` (the country code for USA, Canada, and parts of the +> Caribbean) to the front of the number. +> +> For example, if you try to update a user's number to `4155550132`, Iterable +> saves it in the user's profile as `+14155550132`. + +### `profile` + +:::v-pre +A hidden field housing all of a user profile data. Fields at the root level on +the user profile can be referenced via merge tags by writing either +`{{firstName}}` or `{{profile.firstName}}`. +::: +To avoid confusion, do not use `profile` as a field name in your user profiles. + +### `timeZone` + +The user's time zone, which Iterable uses for the following features: + +- [Send Time Optimization](https://support.iterable.com/hc/articles/360050923471) +- [Quiet Hours](https://support.iterable.com/hc/articles/16928857601940) +- [Sending in Different Time Zones](https://support.iterable.com/hc/articles/14761834767508#sending-in-different-time-zones) + +Values are case-sensitive and must be precisely formatted, as described by the +[IANA time zone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +Examples: + + - `America/Los_Angeles` + - `Europe/London` + - `Asia/Tokyo` + +You can set values in the `timeZone` field by uploading your own data to the +user profile using any data import method (such as API, CSV, or Smart Ingest), +or by enabling project settings so Iterable can automatically update the +`timeZone` field. + +Iterable provides project settings that capture each user's IP address, and +look up the user's time zone based on their IP address. This ensures that the +`timeZone` field is refreshed regularly for your most engaged users. + +To learn more about updating a user's IP and/or geolocation fields using +email or SMS click events, or to learn how to set up your project so that +Iterable can automatically update the `timeZone` field, see [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `userId` + +The ID of a user. This is always a value which you've either assigned or +imported from another source. To learn more about the `userId` field and +accepted values, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + +> [!NOTE] +> This is one of two possible fields that uniquely identify Iterable users, +> along with [`email`](#email). To continue learning about the `userId` field, +> unique identifiers, and how they work, see our guide +> [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +### `whatsAppPhoneNumber` + +A string that represents the user's WhatsApp phone number. Iterable uses this number when +sending WhatsApp messages. + +Values must be formatted using [E.164](https://en.wikipedia.org/wiki/E.164). +For example: +- US: `+14155550132` +- Germany: `+498999998000` + +## Automatically populating IP and geolocation fields + +If enabled for your project, Iterable can automatically populate the following +geolocation fields: `ip`, `city`, `region`, `country`, and `timeZone`. This is +helpful for campaigns that use [Quiet Hours](https://support.iterable.com/hc/articles/16928857601940) +and [send in different time zones](https://support.iterable.com/hc/articles/14761834767508#sending-in-different-time-zones), +as Iterable can use the user's IP address to infer their time zone and send +messages at the most appropriate time. IP lookup is skipped when the event's +`userAgent` contains "GoogleImageProxy" (for example, when a user opens an email +from Gmail). + +To have Iterable automatically update user fields based on their IP geolocation: + +1. Set up click tracking for your email and/or SMS campaigns: + - [Email link tracking](https://support.iterable.com/hc/articles/7130133854612#step-3-set-up-tracking-domains) + must be enabled to capture the user's IP address on email click events. + - [SMS shortlinks](https://support.iterable.com/hc/articles/4403865896724) + must be enabled to capture the user's IP address on SMS click events. + +2. Update the user's `ip` field with the [**Overwrite IP Field**](https://support.iterable.com/hc/articles/206382645#overwrite-ip-field) + project setting. This automatically captures a user's IP address each time + they click a link in an email and/or SMS campaign. + +3. Turn on the [**Overwrite Location Fields**](https://support.iterable.com/hc/articles/206382645#overwrite-location-fields) + project setting to update the `city`, `region`, `country`, and `timeZone` + fields based on the user's IP address, every time the `ip` field is updated. + +## Other reserved field names + +The following field names are reserved by Iterable, and you can't manually set +them: + +- `campaignId` +- `channelId` +- `eventName` +- `messageTypeId` +- `templateId` +- `total` + +Also avoid using `size` as a field name. You can do it, but referencing +such a field can be unreliable, since it sometimes references the +[number of items in an object or array](https://support.iterable.com/hc/articles/36531450481300#display-the-size-of-an-array). + +## Common event fields + +You'll often see the following fields on Iterable system events: + +- `messageId`: A unique identifier for a particular send of a particular campaign + to a particular user. +- `contentId`: An ID associated with the specific template revision sent to the + user for a campaign. +- `catalogLookupCount`: The number of times a catalog was referenced in the message. +- `catalogCollectionCount`: The number of times a catalog collection was referenced + in the message. + +## Want to learn more? + +For more information about some of the topics in this article, check out this +Iterable Academy course. Iterable Academy is open to everyone — you don't need +to be an Iterable customer! + +- [Foundations: Setting Up Senders](https://academy.iterable.com/setting-up-senders) \ No newline at end of file diff --git a/iterable-ios-verify/SKILL.md b/iterable-ios-verify/SKILL.md index 1751089..2c3c84a 100644 --- a/iterable-ios-verify/SKILL.md +++ b/iterable-ios-verify/SKILL.md @@ -26,18 +26,51 @@ Run the verifier first: ``` When no proof run exists, `next.kind` is `prepare_proof` and `next.question` -already carries the proof screen: the detected bundle, the prefilled device +already carries the proof screen: the detected bundle and the prefilled device (a single eligible booted Simulator is selected for you; the developer still -sees and confirms it), and one free-text answer for the identity the app is -signed in with. Running the screen's command records the run. On a Simulator +sees and confirms it). When the workspace knows a build of the app with the +Iterable SDK (one an earlier run recorded, or a product under the app's own +`-derivedDataPath`), an **App build** screen comes first and recommends binding +that exact `.app` unless the installed copy was built later; never append +`--app-path` by hand. The identity screen then offers **Use : Iterable +has this Simulator's current device token on that user** only when Iterable +holds this Simulator's current APNs token on that user. Its candidates are the +integration's `ITBL_PROOF` marker (its **Relaunch the app to read the identity +it signs in with** option relaunches the app with `-iterableProofIdentity YES`), +the SDK's own log lines, and `--identity-candidate`. If you already know the +signed-in identity from device evidence, run `ios-prepare-proof status +--identity-candidate `; never type it as the developer's answer. Typing +stays available. Running the screen's command records the run. On a Simulator its `next.command` is `ios-prove --confirm`; run it, then run `ios-verify status` again. For an integrated, signed-in app on a booted Simulator that is the whole path: four tool calls and one developer answer. +Once the push is proved, the `ITBL_PROOF` marker is removed from the app; +`inspect` and the done summary warn `proof_identity_marker_present` until it is. If the verifier says the workspace has no context at all, `ios-verify inspect` reads the project without needing `.iterable/ios/`; a missing verification workspace is not evidence that Iterable is missing from the app. +For React Native and Expo, the app root is the directory with `package.json` +(running from `ios/` resolves to it), and `inspect` and `status` report +`framework`. Prove a Release build: a Debug Simulator build does not embed +`main.jsbundle` and runs only while Metro serves it, so I5 (a recorded +artifact) or I6 (the installed app) refuses it and names the +`xcodebuild … -configuration Release` command to run instead. Relay any +`warnings` from `inspect`; they name the pitfall to read. + +I6 and `ios-prove` also read the app actually installed on the Simulator, +before any notification check. They refuse it as +`installed_app_without_sdk` (no Iterable SDK resource bundle or framework, +or, for React Native and Expo, no `RNIterableAPI` in `main.jsbundle`), +`installed_app_missing_aps_environment`, +`installed_app_differs_from_recorded_build` (not byte-identical to the +`--app-path` build), or `installed_app_older_than_source`. I5 applies the SDK +and entitlement reads to a recorded artifact before install. Each one names +the Release build command; build with signing on, never with +`CODE_SIGNING_ALLOWED=NO`, then pass that exact `.app` to +`ios-prepare-proof restart --confirm --app-path`. + Do not run these from the plugin directory. `ios-verify` never edits the project or workspace. `ios-prepare-proof` writes only ignored, mode-restricted proof metadata; it does not enter Apple, APNs, Iterable app @@ -103,7 +136,7 @@ When `next.kind` is: | `next.kind` | What you do | |---|---| -| `prepare_proof` | Show `next.question` (or run `ios-prepare-proof status` if it is null) and conduct it. The developer types the identity; the device is shown prefilled. If you built and installed the app in this session, append `--app-path ` to the start command so the proof is tied to that build. Behavioral proof is automatic; do not add a proof-mode question. | +| `prepare_proof` | Show `next.question` (or run `ios-prepare-proof status` if it is null) and conduct it, one screen at a time: the App build screen when one appears, then the identity screen. The device is shown prefilled. The developer chooses the offered identity or types one; pass any identity you read from the device as `--identity-candidate`, never as their answer. Behavioral proof is automatic; do not add a proof-mode question. | | `prove_push` | Run `ios-prove --confirm` exactly once, then re-run the verifier. It checks the device and Iterable first and sends nothing if a precondition fails, and it never sends twice for a run. On a Simulator it moves the app out of the foreground and reads the Simulator's own store and log. On a physical iPhone, first relay the summary: the developer leaves the app (Home Screen or Lock Screen) before the send. | | `readback_code` | Physical iPhone only. Show `next.question`: the developer reads the six-character code from the Iterable iOS proof notification on the phone and types it; run its command with exactly what they typed (the command pipes the code in on stdin), or the "I do not see it" option. Never guess, derive, or retype a code yourself; the tool never shows it to you. Three wrong codes close the run. If the notification shows the literal text `{{proofCode}}`, Iterable did not merge the code: report that instead of a code. | | `fix_apple_push` | Apple refused pushes for this app on this device (I13). Relay `next.summary`: it names the cause (App ID not enabled for Push on the team or a key restricted to other Bundle IDs, a Bundle ID mismatch, or a sandbox/production mismatch) with the Apple Developer pages and steps. After the developer fixes it, run `next.command` (`ios-verify check-apple`) to ask Apple again. Nothing was sent through Iterable. | @@ -131,7 +164,7 @@ When `next.kind` is: | `review_send_manifest` | Stop: the send manifest is invalid or incomplete. Do not send again until the run is reviewed or restarted. | | `launch_app` / `restore_app` | Instrumented run only. Run the exact `ios-launch` command, then re-run. | | `observe_tap` / `observe_logout` / `review_instrumentation` | Instrumented run only. The developer performs the recorded tap or logout; `review_instrumentation` reports missing journal evidence without repeating a UI action. | -| `done` | Relay `proof_mode`, environment, `verified_features`, `feature_evidence_required`, `verification_limitations`, `integration_complete`, and every green proof boundary. If `integration_complete` is false, say “core verification complete; selected feature proof remains,” not “integration complete.” A development result does not claim TestFlight or production proof. Relay the JSON's `notice` field verbatim alongside the proof. | +| `done` | Relay `proof_mode`, environment, `verified_features`, `feature_evidence_required`, `verification_limitations`, `integration_complete`, and every green proof boundary. If `integration_complete` is false, say “core verification complete; selected feature proof remains,” not “integration complete.” `existing-push-coexistence` in `verification_limitations` means the app kept its own push path beside Iterable's. Relay `next.summary`, which names that path, and offer the optional after-integration check: the developer triggers one of the app's own notifications and confirms it shows. Run it only if they ask. It is never required, and Iterable's proof completes the integration without it. A development result does not claim TestFlight or production proof. Relay the JSON's `notice` field verbatim alongside the proof. | `ios-verify inspect` may also return `review_integration` when the project does not show both the Iterable dependency and initialization; report that boundary. @@ -186,6 +219,13 @@ visible state is ambiguous, state what was observed and ask only for the one missing product action. Never ask the developer to log in when the exact user already exists remotely and the app is visibly past its identification screen. +Registration is never judged from logs. The SDK logs a successful token +registration at debug level, which its default logger drops, so a log with an +APNs token and no `registerToken` line says nothing about Iterable (iOS +pitfall 29). Gate I12 reads the device record from Iterable; act on it. After +any rebuild, re-run `ios-verify status` before asking the developer to act +again. Its last answer was about the build you replaced. + An event count above the recorded expectation is a contaminated run, not an immediate integration defect. The verifier cannot distinguish repeated manual actions from duplicate SDK tracking after both have reached Iterable. Restart diff --git a/iterable-ios/PITFALLS.md b/iterable-ios/PITFALLS.md index 3177fae..4c5a240 100644 --- a/iterable-ios/PITFALLS.md +++ b/iterable-ios/PITFALLS.md @@ -343,3 +343,24 @@ Benchmark-app policy, not a general rule: [`eval/scenarios/ios-benchmark.md`](.. - **Fix:** Use the signatures in `SKILL.md` → canonical step 4 — or better, the scene lifecycle: the iOS 26 SDK deprecates both app-delegate URL methods. Treat a "nearly matches optional requirement" warning as an error. + +## 29. No `registerToken` line in the log is read as no registration + +- **Symptom:** After sign-in, the log shows `apsd` handing the app its APNs + token and no Iterable `registerToken` line, so the agent decides the device + never registered and starts changing working code. +- **Cause:** SDK 6.7.5 logs a successful registration only at debug level + (`ITBDebug("Device registration succeeded…")`, + [InternalIterableAPI.swift#L455](https://github.com/Iterable/iterable-swift-sdk/blob/6.7.5/swift-sdk/Internal/InternalIterableAPI.swift#L455)). + The default `IterableConfig.logDelegate` is + `DefaultLogDelegate(minLogLevel: .info)`, which drops debug + ([IterableLogging.swift#L12](https://github.com/Iterable/iterable-swift-sdk/blob/6.7.5/swift-sdk/SDK/IterableLogging.swift#L12)). + The generic `registerToken succeeded` info line never runs for this call, + because `register(token:)` passes its own success handler + ([RequestProcessorUtil.swift#L82-L86](https://github.com/Iterable/iterable-swift-sdk/blob/6.7.5/swift-sdk/Internal/RequestProcessorUtil.swift#L82-L86)). + A failure is logged at error level, but as the server's reason, without the + word `registerToken`. +- **Fix:** Read registration from Iterable, not from the log. Run + `ios-verify status` and act on gate I12, which reads the device record, or + look for the device in **Audience > Contact Lookup**. Don't add a workaround + to make a log line appear. diff --git a/iterable-ios/SKILL.md b/iterable-ios/SKILL.md index fa9ac8f..dfa8a02 100644 --- a/iterable-ios/SKILL.md +++ b/iterable-ios/SKILL.md @@ -57,8 +57,12 @@ about facts you could read: 4. **Existing push.** Search for `UNUserNotificationCenter.current().delegate`, `didRegisterForRemoteNotificationsWithDeviceToken`, `FirebaseMessaging`, `OneSignal`, and other providers. If anything already handles - notifications, show the developer what you found and agree a coexistence - plan before touching it (rule 8). + notifications, show the developer what you found and ask: "Keep your app's + own notifications working alongside Iterable's, or use Iterable + notifications only?" **Keep both:** forward only Iterable payloads (rule 8). + **Iterable only:** propose, in the one diff they confirm, how the old + provider stops receiving or showing pushes. Don't delete the provider's code + unless they confirm that diff. Then ask which features they want. Use the host's question tool (Claude Code: `AskUserQuestion` with `multiSelect: true`; Cursor: `AskQuestion` with @@ -76,8 +80,92 @@ default `IterableInAppDelegate` shows them. Tell the developer they have it and that nothing appears until someone sends an in-app campaign. Only *changing* that default (custom delegate, display interval, custom rendering) is scope. -If `iterable-provision` is installed, its native iOS path can conduct this -scope conversation and record it; see Preflight. +**Ask once, here.** When provisioning will run (Preflight routes to +`iterable-provision`), ask the scope in this step, with what you found in the +app, and the identity ([below](#identity-project-type-first)) when it is +settled. Then pass the confirmed answers to the first +`ios-provision status` as `--features ` and `--identity `: + +| Step 0 answer | `--features` ids | +|---|---| +| Push notifications | `push-notifications` | +| Events + user profiles | `event-tracking,user-profiles` | +| Deep links | `deep-linking` | +| More: Mobile Inbox | `mobile-inbox` | +| More: Embedded Messaging | `embedded-messaging` | +| Changing the default in-app handling | `in-app-messages` | +| Initialization and identity only | `sdk-initialization` | + +| Step 0 identity | `--identity` | +|---|---| +| The account email, with `setEmail` | `email` | +| The stable account ID, with `setUserId` | `user-id` | +| No known user yet (Unknown User Activation) | `unknown-user` | + +Pass only what the developer confirmed, and leave out `--identity` if Step 0 +did not settle it. Status shows the passed answers on one screen. If the +developer picks **Use these answers**, they are recorded, and the final scope +screen shows them again with where they came from. Never answer the tool's +own Features, More, or Identity screens a second time. They appear only for +an answer you did not pass, or when the developer chose **Change the +answers**. + +### Identity: project type first + +Ask which kind of Iterable project the app sends to, per +`project-types-and-unique-identifiers`: + +- **Hybrid**: `email` and `userId` are both unique identifiers. +- **UserID-based**: `userId` only. +- **Email-based**: `email` only; limited availability (some older US-hosted + organizations). +- **I don't have a project yet / I don't know — help me.** + +For an existing project, also ask which identifier their backend, CRM, or other +SDKs already send to Iterable, and reuse exactly that value: another value is +another user. Decide from what a signed-in session holds, not what sign-up asks +for: an app that asks for an email only at sign-up has none to pass later. +**Help me** means inspect, then recommend: what identifier exists after *every* +sign-in path, a session restored at launch included, and what the app's other +SDKs already identify the user with. Recommend a project type and identity mode +from that, citing the docs in the table below. With no project yet, guide +creating one and warn that its type can't be changed later. + +A `userId` is the app's own stable internal account ID when it meets +`user-id-validation-in-iterable` → `### User ID best practices`, otherwise a +random UUID created once per account and stored with it (kept only on the +device, a reinstall makes a new user). Never an email, phone number, username, +or handle (`name@instance` included), nothing built from them, and nothing +sequential. That doc's 128-character limit supersedes `identifying-the-user`'s +"52 characters" and its "email recommended". + +### Decisions: read before recommending + +Before recommending an option for a decision below, open the named section and +cite it in the option ("per `user-id-validation-in-iterable`"). If no shipped +doc covers a decision, offer no recommendation and say so. Slugs are +`reference/.md`. + +| Decision | Read | +|---|---| +| New integration or upgrade | `ios-sdk` → `## Upgrading the SDK` | +| Project type | `project-types-and-unique-identifiers` → `### Choosing a project type`, `#### Project type availability` | +| Identity mode, and which app value supplies it | `project-types-and-unique-identifiers` → `### Comparing project types`; `user-id-validation-in-iterable` → `### User ID best practices`; `identifying-the-user` | +| An existing client's identifier | `creating-users-with-apis` → `## Understanding user identification in API requests`; `updating-a-users-email-address` | +| Unknown users | `configure-the-ios-sdk` → `## Step 3`; `handling-anonymous-users` | +| Data region | `ios-sdk` → `#### Step 6.2`; `european-data-center` → `## Signing in to Iterable: region-specific URLs` | +| Key type | `api-keys` → `### Selecting a key type`, `#### Mobile` | +| JWT | `api-keys` → `#### JWT authentication`; `jwt-enabled-api-keys` | +| Event creation | `api-keys` → `#### Event Creation` | +| Push integration name | `ios-sdk` → `#### Step 6.4` | +| APNs auth, environment, Bundle ID | `setting-up-ios-push-notifications` → `### Step 3`, `### Step 4` | +| Notification permission | `ios-sdk` → `#### Step 7.6` | +| Existing push | `ios-sdk` → `#### Step 7.5` | +| Deep links | `ios-sdk` → `#### Step 6.3`; `deep-links-in-push-notifications`; `ios-universal-links` | +| Profile fields | `updating-user-profiles`; `user-profile-fields-used-by-iterable` → `## Other reserved field names` | +| Events | `tracking-events-with-iterables-mobile-sdks` → `## Custom event names` | +| Logout | `handling-anonymous-users` → `## Best practices for handling anonymous users`; `tracking-events-with-iterables-mobile-sdks` → `### Multiple users` | +| Test identity | `project-types-and-unique-identifiers` → `### Checking the unique identifier for a project`; `user-profile-testing-and-troubleshooting` → `## Viewing a user profile` | ## Preflight — inputs only the developer has @@ -88,8 +176,8 @@ fake, or infer these from the codebase. | --- | --- | --- | | **Mobile API key** (client-side, not server-side) | Always | Route to `iterable-provision`. Never hard-code it, commit it, or substitute a server-side key. | | **JWT?** | Always | Ask, and assume **yes** until told otherwise: JWT is selected by default when a client-side key is created and cannot be changed afterwards (`reference/api-keys.md`). JWT needs a backend endpoint that mints tokens (rule 1). | -| **Data region** — US or EU | Always | Ask; `app.eu.iterable.com` means EU. | -| **Identity** — `setEmail` or `setUserId`, and which app value supplies it | Always | Ask. Never wire it to a guessed field (rule 7). | +| **Data region** — US or EU | Always | Ask; signing in at `app.eu.iterable.com` means EU (`european-data-center`). | +| **Identity** — `setEmail` or `setUserId`, and which app value supplies it | Always | [Project type first](#identity-project-type-first). Never wire it to a guessed field (rule 7). | | **Apple team and exact Bundle ID** | Push | Must match in Xcode, Apple Developer, and the Iterable iOS app ([pitfall 22](PITFALLS.md#22-the-bundle-id-differs-across-xcode-apple-and-iterable)). | | **APNs `.p8` key + Iterable iOS app with a push credential** | Push | Route to `iterable-provision`. Creating the APNs key is an Account Holder or Admin action in Apple Developer. | | **Push integration name** | Push, legacy integrations only | Only when the Iterable integration is not named after the Bundle ID. | @@ -134,6 +222,9 @@ For the agreed scope, finish the wiring rather than leaving snippets: visible on that user. - [ ] Push delivery is proved with `iterable-ios-verify`, or the handoff says exactly which boundary is still unproved. +- [ ] If the app kept its own push, the handoff offers the optional + after-integration check (rule 8). Iterable's proof alone completes the + integration. When you hand over, say it plainly: an AI agent wrote this code and configuration, it can be wrong in ways that still compile, and only what a real @@ -216,8 +307,9 @@ non-trivial code. 6. **Never commit the API key.** A Mobile key is meant to ship inside the app, but it belongs in an ignored file, not in source. Never put a server-side key in an app. -7. **Never guess identity.** Ask which identifier and where its value comes - from. Use one mode (`setEmail` *or* `setUserId`) consistently. +7. **Never guess identity.** Ask the project type, which identifier, and where + its value comes from ([Step 0](#identity-project-type-first)). Use one mode + (`setEmail` *or* `setUserId`) consistently. 8. **There is one notification-center delegate. If something already owns it, forward from it — never replace it.** Inside the existing `didReceive` and `didReceiveRemoteNotification`, send payloads that carry an `itbl` key to @@ -227,6 +319,21 @@ non-trivial code. OneSignal swizzle these callbacks; see [pitfall 23](PITFALLS.md#23-another-sdk-owns-or-swizzles-the-notification-callbacks). + **If the app kept its own push, offer an optional after-integration + check.** Iterable's proof alone completes the integration. When the app + already had push, `ios-verify inspect` warns `existing_push_path`, and + `done` lists `existing-push-coexistence` in `verification_limitations`, + never in `feature_evidence_required`. Once Iterable's proof passes, offer + the check: the developer triggers one of the app's own notifications (the + action that makes its backend or provider send one) and confirms it shows. + Run it only if they ask, and don't offer it if they chose Iterable only. It + has to be the developer's trigger, because `xcrun simctl push` can't + exercise the app's own path. Even with `mutable-content: 1` and no `itbl` + key, it doesn't run the Notification Service Extension (seen on the iOS 26.2 + and 27.0 Simulators with Xcode 27), and it skips the provider and APNs. A + test build with a changed Bundle ID or team may be unreachable from the + app's provider. If so, say the check can't run on this build. + --- ## Canonical integration (start here) @@ -883,6 +990,48 @@ backend or privacy workflow. replace it with a hand-written checklist or treat a green build, a successful API call, or `simctl push` as delivery. +**Never judge registration from logs.** The SDK reports a successful token +registration only at debug level, which its default log delegate drops, so no +`registerToken` line appears even when the device registered +([pitfall 29](PITFALLS.md#29-no-registertoken-line-in-the-log-is-read-as-no-registration)). +Run `ios-verify status`: gate I12 reads the device record from Iterable. Act on +its gates. **After any rebuild, re-run the verifier before asking the developer +to act again.** Its last answer was about the build you replaced. + +### The proof identity marker (temporary) + +When push is in scope, add this file and call `IterableProofMarker.log()` on +the line after `IterableAPI.initialize(...)` in `start` and after the +`setEmail` / `setUserId` call in `identify`. At proof time the verifier +relaunches the app with `-iterableProofIdentity YES`, reads the identity from +the Simulator's log, and offers it once Iterable holds this Simulator's device +token on that user, so nobody has to dig out a generated user ID. A normal +launch prints nothing; the launch argument, not `#if DEBUG`, gates it, because +a Release build can be the one proved. + +```swift +// IterableProofMarker.swift — temporary: remove once the push is proved. +import Foundation +import os +import IterableSDK + +enum IterableProofMarker { + static func log() { + guard UserDefaults.standard.bool(forKey: "iterableProofIdentity"), + let identity = IterableAPI.userId ?? IterableAPI.email else { return } + os_log("ITBL_PROOF identity=%{public}@", identity) + } +} +``` + +Once the push is proved, delete the file and both calls, and list that removal +as the last step of the handoff; `ios-verify inspect` and the verifier's done +summary warn `proof_identity_marker_present` until it is gone. If you already +know the signed-in identity from device evidence (the app's log, a debugger +read), pass it as `/bin/ios-prepare-proof status --identity-candidate +`; the tool checks it against Iterable and offers it. Never type an +identity into the proof screen on the developer's behalf. + An iOS 16+ Simulator on macOS 13+ (Apple silicon or T2 Mac) receives real APNs **sandbox** pushes; production and TestFlight need a physical iPhone. When a push is missing, find the first failed boundary — signing/token, Iterable @@ -906,7 +1055,7 @@ cite `source_url` when asked where guidance came from. | Need | Read | | --- | --- | | Installation, configuration, upgrades | `ios-sdk` (install steps; `## Upgrading the SDK` only for upgrades), `configure-the-ios-sdk`, `sdk-testing-and-troubleshooting` | -| Identity and profiles | `identifying-the-user`, `updating-user-profiles`, `user-profile-testing-and-troubleshooting` | +| Identity and profiles | `identifying-the-user`, `updating-user-profiles`, `user-profile-testing-and-troubleshooting`; the rules behind them are in [Decisions](#decisions-read-before-recommending) | | Events | `tracking-events-with-iterables-mobile-sdks`, `event-tracking-testing-and-troubleshooting` | | Push and APNs setup | `setting-up-ios-push-notifications`, `push-notification-overview`, `push-notification-testing-and-troubleshooting` | | Rich push, NSE, custom sounds | `advanced-ios-push-notifications` | @@ -915,8 +1064,6 @@ cite `source_url` when asked where guidance came from. | Mobile Inbox | `setting-up-mobile-inbox-on-ios`, `customizing-mobile-inbox-on-ios` | | Deep links and universal links | `deep-links-in-push-notifications`, `ios-universal-links` | | Embedded Messaging | `embedded-messages-with-iterables-ios-sdk` | -| Unknown User Activation | `configure-the-ios-sdk` | -| API keys and JWT (dashboard side) | `api-keys`, `jwt-enabled-api-keys` | Each slug is `reference/.md`. diff --git a/iterable-ios/reference/creating-users-with-apis.md b/iterable-ios/reference/creating-users-with-apis.md new file mode 100644 index 0000000..6640551 --- /dev/null +++ b/iterable-ios/reference/creating-users-with-apis.md @@ -0,0 +1,650 @@ +--- +slug: creating-users-with-apis +feature: user-profiles +archetype: identity +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: Creating and Identifying Users with Iterable APIs +source_url: https://support.iterable.com/hc/articles/29156459027348 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/creating-users-with-APIs/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: ad12bc20c0e81b4a3adb18555bbc5dd430e5ea45 +fetched_at: 2026-10-22T10:00:08.839Z +summary: Understanding user identification is fundamental to working with + Iterable's API effectively. The steps for creating and identifying users vary + depending on your project type and the way you identify users. This article + describes how to identify and create users with Iterable's APIs for + email-based, userID-based, and hybrid projects. +--- +# Creating and Identifying Users with Iterable APIs + +Understanding user identification is fundamental to working with Iterable's API +effectively. The steps for creating and identifying users vary depending on +your project type and the way you identify users. This article describes how to +identify and create users with Iterable's APIs for email-based, userID-based, +and hybrid projects. + +For more information about the relationship between project types and user +identification, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +> [!NOTE] +> This article covers _most_ aspects of user identification and creation, +> however, it doesn't cover how to manage unknown users (for example, users who +> don't have an `email` address). +> +> Iterable provides two different ways to manage unknown users: +> - [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956) +> - [Unknown User Activation](https://support.iterable.com/hc/articles/38755339847188) + +## Understanding user identification in API requests + +Many of Iterable's API endpoints require you to specify a particular user (for +example, to modify their user profile or fetch data about them). The way you +identify users depends on your project type. + +### Project types and user identification + +To identify a user, provide a `userId` or an `email` in your API request body. +This field typically goes at the top level of your request, outside of +`dataFields`. + +Your **project type** determines which fields you can use to identify users: + +- **UserID-based projects** identify users by `userId`, and Iterable enforces + the uniqueness of this field. + +- **Hybrid projects** identify users by `email` and/or `userId`, and Iterable + enforces the uniqueness of both. However, you don't have to provide both + fields to every user. + +- **Email-based projects** identify users by `email` or `userId`. However, + Iterable only enforces `email` uniqueness, _not_ `userId`. (Email-based projects have limited availability. To learn more, read [Project Type Availability](https://support.iterable.com/hc/articles/9216719179796#project-type-availability).) + +### Field validation and requirements + +When you send an API request that includes an `email` or `userId`, Iterable +validates these fields regardless of your project type. + +- **`email`**: Iterable checks that the field is a valid email address format + and converts the value to lowercase. Iterable does _not_ check + deliverability. To learn more, read [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +- **`userId`**: Iterable rejects requests with `userId` values that don't meet + certain requirements. To learn more, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + + Additionally, when identifying a user, the `userId` field must be + **non-empty**: + + - Blank, null, or empty values for `userId` are not accepted in requests to + identify users. + + - API requests with a `userId` lookup field that is blank, null, or empty + are rejected. Instead, if a user doesn't have a `userId` value, such as + in a hybrid project, use the `email` field to identify the user and + _don't_ include a blank `userId` field in the API request. + + - For email-based and hybrid projects, API requests that include `userId` + with a null value in `dataFields` with intention to clear the data from a + user profile are accepted. + +### Precedence rules when both fields are provided + +If you include both `email` and `userId` at the top level of an API request, +precedence rules apply based on your project type: + +- **Email-based projects**: `email` takes precedence +- **UserID-based projects**: Only `userId` is valid; `email` is treated as a + regular data field +- **Hybrid projects**: `userId` takes precedence + +### Basic API request structure + +Here's the standard structure for API requests that identify users: + +```json +{ + "userId": "user1234567", // User identifier (outside dataFields) + "dataFields": { // User profile updates go here + "favoriteColor": "red" + } +} +``` + +**Important guidelines:** + +- Place user identifiers (`userId` or `email`) at the top level of your + request, _outside_ of `dataFields`. + +- Place profile updates you'd like to make for that user _inside_ the + `dataFields` object. + +- _Do not include_ empty `userId` values to look up users in your API request. + Iterable rejects blank `userId` when provided to locate a record. However, + API requests for email-based and hybrid projects that include `userId` with a + null value nested in `dataFields`, with intention to clear the data from a + user profile, are still accepted. + +- Some requests require you to place `userId` or `email` inside another object, + rather than at the top level of your API request. For example, when calling + [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart), + you need to place `userId` or `email` inside a `user` object. + +- Additionally, some requests use slightly different field names. For example, + [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + requires `recipientEmail` and `recipientUserId`, rather than `email` and + `userId`. For endpoint-specific details, consult our [API Endpoints and Sample Payloads](https://support.iterable.com/hc/articles/204780579). + +## Email-based projects + +> [!NOTE] +> Email-based projects have limited availability. To learn more, read [Project Type Availability](https://support.iterable.com/hc/articles/9216719179796#project-type-availability). + +In email-based projects, you can pass `email` and/or `userId` in requests to +identify users. The behavior depends on which fields you provide: + +- If both fields are present, `email` is prioritized to identify the user, and + the `userId` field is updated/overwritten for most requests that use `PUT` or + `POST` methods. + +- If `email` is passed and there is no user with that email address, Iterable + creates a new user when using the following endpoints: + + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- If only `userId` is passed (and not `email`), and there is no matching user, + Iterable returns an error. To avoid this and create a user without an email, + include the parameter `preferUserId` with a value of `true`. This creates the + user with an auto-generated placeholder email address using the + `@placeholder.email` domain. For more information about placeholder email + addresses, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). + +- If only `userId` is passed in a request to an email-based project, and + multiple users share the same `userId` but have different `email` values, + Iterable updates only one of the users. This occurs because `userId` is not + enforced as unique in email-based projects. The API selects the first user + returned in the query, but this selection is not consistent and may vary + between requests. + +- Tracking events for an unknown user **does not** create a new user. + +### Email-based project behavior across Iterable + +- Lists must include an `email` column. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are available for static lists. + +- When previewing a template, use `email` to populate sample data. + +- Journey triggers can be tested with `email` only. + +- Static list subscriptions are stored in the `emailListIds` field. + +> [!NOTE] +> This is the only type of project where Iterable creates a `iterableEndUserId` +> browser cookie (which will be deprecated at a future date). This may impact +> your implementation of a [subscription preference center](https://support.iterable.com/hc/articles/208463956) +> and when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285). + +### Identifying users in email-based projects + +In email-based projects, `email` takes precedence when both `email` and +`userId` are provided. For example, this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +looks up or creates a user with `email` address `user@example.com`, then sets +their `favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +### Creating new users in email-based projects + +To create a new user and assign them a unique email address in an email-based +project, make a request to one of the various endpoints that can create new +users in one of these ways: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens + +For example, you can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) with +a request body such as: + +```json +{ + "email": "user@example.com", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If your project doesn't yet have a user with an `email` address of + `user@example.com`, Iterables creates one. + +- Then, whether the user is new or already exists, Iterable sets their + `favoriteColor` to `red`. + +### Creating new users when `email` is unknown + +To create a new user for someone with an unknown email address (in an +email-based project), provide a `userId` instead of an `email`. When you do +this, Iterable creates a new user with a placeholder `email` address using the +`@placeholder.email` domain. + +To learn more about how to do this, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). + +Once a placeholder `email` is assigned to a user, you can identify this user in +subsequent API calls by their `userId`, or by the placeholder `email` given to +them by Iterable. + +### Assigning a `userId` to an existing user + +In an email-based project, you can assign a `userId` to an existing user who is +already identified by `email` by making an API call to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +To learn more about how to do this, read [Updating a User's Email Address or User ID](https://support.iterable.com/hc/articles/217502386). + +## UserID-based projects + +> [!TIP] +> UserID-based is often the most sensible type of project to choose, because +> it: +> +> - Is simple to use. You always identify users by `userId`. +> - Allows you to match Iterable `userId` values with user identifiers you use in +> other systems. +> - Allows multiple users to share the same email address, which can sometimes +> be useful. + +UserID-based projects identify users exclusively by `userId`. In these +projects, `email` is not a user identifier—it's treated like any other profile +field (such as `favoriteColor`). You can assign `email` addresses to users, but +you can't use `email` to look up or modify users in Iterable's web UI or when +making API calls. + +Key considerations for userID-based projects: + +- The `userId` field must always be present and unique for any endpoint that + uses a unique identifier, including GDPR forget and unforget endpoints +- Multiple users can have the same `email` address since email uniqueness is + not enforced +- If `userId` is passed and there is no matching user, Iterable + creates a new user when using the following endpoints: + + - [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + - [`POST /api/events/track`](https://support.iterable.com/hc/articles/204780579#post-api-events-track) + - [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + - [`POST /api/events/trackWebPushClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackwebpushclick) + - [`POST /api/events/trackPushOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackpushopen) + - [`POST /api/events/trackInAppOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappopen) + - [`POST /api/events/trackInAppClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclick) + - [`POST /api/events/trackInAppDelivery`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappdelivery) + - [`POST /api/events/trackInAppClose`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclose) + - [`POST /api/workflows/triggerWorkflow`](https://support.iterable.com/hc/articles/204780579#post-api-workflows-triggerworkflow) + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- When an API request includes the parameter `preferUserId`, it's ignored. + +- If you try to use `email` to identify a user when making an API call, the + request returns an error. You can only identify users with `userId`. + +- You aren't required to provide an `email` for every user you upload to + Iterable. Placeholder email addresses aren't necessary. + +- Iterable doesn't enforce `email` uniqueness. In other words, many users can + have the same email address. + +- Tracking events for an unknown user **creates** a new user. + +### UserID-based project behavior across Iterable + +- When creating a new list, specify a `userId` column, instead of an `email` + column. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are unavailable at this time. + +- When previewing a template, use `email` or `userID` to populate sample data. + +- Journey triggers can be tested with `email` or `userID`. + +- Iterable doesn't set a `iterableEndUserId` browser cookie. + +- To identify users when you create a [custom subscription center](https://support.iterable.com/hc/articles/208463956), pass `userId` + as a URL parameter using the following Handlebars in the [hosted unsubscribe](https://support.iterable.com/hc/articles/206382645#hosted-unsubscribe-url) URL project setting: + + :::v-pre + `{{userId}}` + ::: + +- To identify users when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285), add a URL + parameter with a Key of userId and Value of: + :::v-pre + `{{userId}}` + ::: + +- When a user unsubscribes from a link in an email (`emailUnSubscribe`, `hostedUnsubscribeClick`, + or `emailComplaint` event), Iterable attributes the unsubscribe to the user's + unique `userId`, not the email address that received the message. This is + important when you have users who share an email address. + +- Static list subscriptions are stored in the `userListIds` field. + +- When sending a proof to an unknown email address (including when sending a + proof "to myself" for the first time), Iterable creates a new user and + automatically assigns a `userID` with the value `itbl_generated_{{email}}` + (where the email address is part of the `userId`). + + :::tip TIP + To look up users by email address in a userID-based project, use the + segmentation tool and segment by the contact property `email`. + ::: + +### Identifying users in userID-based projects + +In userID-based projects, `userId` is the _only_ valid user identifier, and +uniqueness is enforced. + +For this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +Iterable looks up or creates a user with `userId` of `user1234567`, then sets +their `favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +Iterable also sets the user's `email` to `user@example.com`, as this is a field +included in the request body. However, since `email` is not a user identifier +in userID-based projects, the API does not verify its uniqueness. Multiple +users could have the same `email` address with different `userId` values. + +### Creating new users in userID-based projects + +In userID-based projects, when you make an API request that includes a `userId` +not yet used by your project, some endpoints create new users: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens +- Track events (individually or in bulk) +- Trigger workflows + +For example, call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), with a +request body such as: + +```json +{ + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with `userId` set to `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their + `favoriteColor` to `red`. + +### Assigning email in userID-based projects + +You _may_ assign an `email` to each user in a userID-based project, but it's +not a required field and isn't used to identify unique users. + +> [!NOTE] +> Since `email` is not a required field in userID-based projects, Iterable does +> not automatically assign `@placeholder.email` addresses like it does in +> email-based projects. + +To do this, call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), with a +request body such as: + +```json +{ + "userId": "user1234567", + "dataFields": { + "email": "user@example.com" + } +} +``` + +For this request: + +- If there's no user with `userId` set to `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `email` to + `user@example.com`. + + :::tip NOTE + In userID-based projects, Iterable does not enforce `email` uniqueness. This + allows you to give the same `email` to many users in the same project, which + can sometimes be useful. + ::: + +## Hybrid projects + +Hybrid projects use both `email` and `userId` as unique identifiers. Key +behaviors include: + +- Use either `userId` or `email` (but not both) to identify users in API + requests. + +- If you provide both fields, `userId` takes precedence. + +- Both `email` and `userId` must be unique, and this policy is enforced. + +- If `userId` and/or `email` are passed and there is no matching user, Iterable + creates a new one when using the following endpoints: + + - [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + - [`POST /api/events/track`](https://support.iterable.com/hc/articles/204780579#post-api-events-track) + - [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + - [`POST /api/events/trackWebPushClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackwebpushclick) + - [`POST /api/events/trackPushOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackpushopen) + - [`POST /api/events/trackInAppOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappopen) + - [`POST /api/events/trackInAppClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclick) + - [`POST /api/events/trackInAppDelivery`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappdelivery) + - [`POST /api/events/trackInAppClose`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclose) + - [`POST /api/workflows/triggerWorkflow`](https://support.iterable.com/hc/articles/204780579#post-api-workflows-triggerworkflow) + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- If you are adding a user with an invalid `email` value, it is rejected + even if it has a `userId` included in the request. + +- When an API request includes the parameter `preferUserId`, it's ignored. + +- You aren't required to provide an `email` for every user you upload to + Iterable—placeholder email addresses aren't necessary. + +- You aren't required to provide a `userId` for every user, however Iterable + doesn't create one when the value is missing. + +- To process a GDPR request to forget a user, you can use either `email` or + `userId` with the [`POST /api/users/forget`](https://support.iterable.com/hc/articles/204780579#post-api-users-forget) + endpoint. + +#### Hybrid project behavior across Iterable + +- When creating a new list, specify either a `email` or `userId` column, at + minimum, or upload a list with both columns. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are available for static lists. + +- When previewing a template, use `email` or `userID` to populate sample data. + +- Journey triggers can be tested with `email` or `userID`. + +- Iterable doesn't set a `iterableEndUserId` browser cookie. +:::v-pre +- To identify users when you create a [custom subscription center](https://support.iterable.com/hc/articles/208463956), pass `userId` + as a URL parameter using Handlebars (`{{userId}}`) in the [hosted unsubscribe](https://support.iterable.com/hc/articles/206382645#hosted-unsubscribe-url) URL project setting. + +- To identify users when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285), add a URL + parameter with a **Key** of `userId` and **Value** of `{{userId}}`. +::: +- Static list subscriptions are stored in the `userListIds` field. + +- When sending a proof to an unknown email address (including when sending a + proof "to myself" for the first time), Iterable creates a new user. + +### Identifying users in hybrid projects + +In hybrid projects, both `userId` and `email` are valid unique identifiers, but +`userId` always takes precedence when both are provided. + +For this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +Iterable finds or creates a user with `userId` of `user1234567` and sets their +`favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +Iterable also attempts to set the user's `email` to `user@example.com` (even +though it is provided outside of `dataFields`). Since hybrid projects enforce +email uniqueness, Iterable first checks if that email is already in use: + +- **If no profile exists with that `email`**: The request succeeds, creating/updating + the user with both identifiers. +- **If a profile exists with that `email`**: The request fails with HTTP status + code `409`—you can't reuse an email address that belongs to another user. + +### Creating new users in hybrid projects + +In hybrid projects, when you make an API request that references a non-existent +`userId` or `email`, some endpoints create new user profiles: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens +- Track events (individually or in bulk) +- Trigger workflows + +For example, you can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +with a request body such as: + +```json +{ + "email": "user@example.com", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with an `email` of `user@example.com`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `favoriteColor` + to `red`. + +Another example request: + +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + +```json +{ + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with a `userId` of `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `favoriteColor` + to `red`. + +> [!NOTE] +> In userID-based projects, Iterable's API ignores the `preferUserId` parameter +> used in email-based projects for some endpoints. + +## Want to learn more? + +For more information about some of the topics in this article, check out these +resources. Iterable Academy is open to everyone — you don't need +to be an Iterable customer! + +**Iterable Academy** +- [Foundations: Introduction to API](https://academy.iterable.com/introduction-to-api) + +**Support docs** +- [API Keys](https://support.iterable.com/hc/articles/360043464871) +- [API Overview and Sample Payloads](https://support.iterable.com/hc/articles/204780579) +- [Responding to GDPR Requests](https://support.iterable.com/hc/articles/360029174171) diff --git a/iterable-ios/reference/european-data-center.md b/iterable-ios/reference/european-data-center.md new file mode 100644 index 0000000..7fe056f --- /dev/null +++ b/iterable-ios/reference/european-data-center.md @@ -0,0 +1,173 @@ +--- +slug: european-data-center +feature: integration +archetype: prerequisite +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: Iterable's European Data Center (EDC) +source_url: https://support.iterable.com/hc/articles/17572750887444 +source_repo: Iterable/iterable-docs +source_path: docs/welcome-to-iterable/about-iterable/iterables-european-data-center-edc/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: f144b8c50c37374866060543ae8bcea9cfbf7099 +fetched_at: 2026-10-22T10:00:11.764Z +summary: New Iterable customers can choose to host their Iterable projects and + data (personal and non-personal) on Iterable's European data center (EDC), + which is in Ireland. +--- +# Iterable's European Data Center (EDC) + +New Iterable customers can choose to host their Iterable projects and data +(personal and non-personal) on Iterable's European data center (EDC), which is +in Ireland.  + +Iterable's EDC is GDPR-compliant, does not send data to the US, handles data +storage and data processing, and offers feature parity with Iterable's USDC-based +offering. + +> [!NOTE] +> Whether your project is USDC-based or EDC-based depends on the terms of your +> Iterable contract and may not correspond to your company's physical location. +> For example, if your company is located in Spain (an EU country), but your +> Iterable account is hosted on Iterable's US data center, you have a USDC-based +> project. This means you should sign in to Iterable using the URL for USDC-based +> Iterable projects ([https://app.iterable.com](https://app.iterable.com)) and use the +> link tracking domain for USDC-based projects `links.iterable.com`). +> +> Most Iterable customers use Iterable's US data center. If you're not sure which +> data center your account is hosted on, check with your Iterable customer success +> manager. + +## Organizations and projects + +In Iterable, a single organization can have multiple projects, each with +separate data. All of an organization's projects must be hosted on the same data +center — all in the US, or all in the EU. + +## UserID-based and hybrid projects only + +Iterable's EDC supports userID-based and hybrid projects, but not email-based +projects. + +For more information about the types of projects Iterable provides, read +[Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## Signing in to Iterable: region-specific URLs + +To sign in to Iterable: + +- For USDC-based projects, visit . +- For EDC-based projects, visit . + +## SAML SSO: region-specific URLs + +USDC- and EDC-based projects use different single sign-on / ACS / reply URLs: + +- USDC-based projects: `https://auth.iterable.com/login/callback?connection=-saml-org-` +- EDC-based projects: `https://auth.eu.iterable.com/login/callback?connection=-saml-org-` + +For more information, read [Setting Up Single Sign-On](https://support.iterable.com/hc/articles/12227358446868). + +## Default link tracking domains: region-specific URLs + +USDC- and EDC-based projects use different default link tracking domains: + +- USDC-based projects: `links.iterable.com` +- EDC-based projects: `links.eu.iterable.com` + +When setting up tracking links, be sure to use the correct domain for your +project type. (Keep in mind that "US/EDC-based" refers to where your project's +data is stored and may not correspond to your company's physical location). + +For more information, read [Setting up Link Tracking Domains to use HTTPS](https://support.iterable.com/hc/articles/115000427446). + +## IP addresses for Iterable's EDC + +Iterable's EDC uses different IP addresses than Iterable's US data center. If +you need to allow Iterable's IP addresses in your firewall, see +[Allowing Iterable IP Addresses](https://support.iterable.com/hc/articles/45758621331732). + +## SMS providers for EDC-based Iterable projects + +To send SMS from EDC-based Iterable projects, you can use: + +- Iterable SMS. +- Your own Telnyx account. + +For EDC-based Iterable projects, Twilio is not supported. + +For more information about SMS, read [SMS Overview](https://support.iterable.com/hc/articles/211970843). + +## Email providers for EDC-based Iterable projects + +To send email from an EDC-based Iterable project, you can use: + +- Iterable's default integration with Amazon SES. +- An Iterable-managed integration with Mailgun, or your own Mailgun account. +- An Iterable-managed integration with Sparkpost, or your own Sparkpost account. + +For EDC-based Iterable projects, Sendgrid is not supported. + +To learn more about setting up email in Iterable, read [Email Setup](https://support.iterable.com/hc/articles/7130133854612). + +## Smart Ingest + +Iterable's [Smart Ingest](https://support.iterable.com/hc/articles/24354008224916) +feature supports EDC-based projects. Smart Ingest allows you to import data from +a supported data source into Iterable. During the setup process, your data +source administrator must allow the IP addresses used by Smart Ingest to +connect to your data warehouse. + +These IPs vary by region, so make sure that you're allowing the correct set of +IPs for a successful connection. To learn more, read [Smart Ingest IPs to allow](https://support.iterable.com/hc/articles/25899748801172#smart-ingest-ips-to-allow). + +## API keys + +Every Iterable API key is associated with a specific Iterable project, which +means that it's also associated with a specific data center: + +- API keys created in a USDC-based project can only be used with Iterable's USDC-based + API endpoints (base URL: `https://api.iterable.com`). +- API keys created in EDC-based project can only be used with Iterable's EDC-based + API endpoints (base URL: `https://api.eu.iterable.com`). + +For more information about API keys, read [API Keys](https://support.iterable.com/hc/articles/360043464871). + +## API documentation + +You can make API calls directly from Iterable's API docs: enter an API key, +choose an endpoint, specify request parameters / a request body, and submit the +request. + +Iterable provides two sets of API documentation: + +- API docs for USDC endpoints - . + + Requests submitted on this page are sent to Iterable's USDC-based API endpoints + (base URL: `https://api.iterable.com`). On this page, you can only use API keys + associated with USDC-based Iterable projects. + +- API docs for EDC endpoints - . + + Requests submitted on this page are sent to Iterable's EDC-based API endpoints + (base URL: `https://api.eu.iterable.com`). On this page, you can only use API + keys associated with EDC-based Iterable projects. + +Our [Getting Started with Iterable's API](https://support.iterable.com/hc/articles/41044692130196) provides +information about each endpoint, too. + +## SDK support + +Iterable's mobile and web SDKs support Iterable's EDC: + +- iOS SDK, [version 6.4.15+](https://github.com/Iterable/iterable-swift-sdk/releases/tag/6.4.15). + To learn more, see [Iterable's iOS SDK](https://support.iterable.com/hc/articles/360035018152). + +- Android SDK, [version 3.4.15+](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.15) + To learn more, see [Iterable's Android SDK](https://support.iterable.com/hc/articles/360035019712). + +- React Native SDK, [version 1.3.16+](https://github.com/Iterable/react-native-sdk/releases/tag/1.3.16) + To learn more, see [Installing Iterable's React Native SDK](https://support.iterable.com/hc/articles/360045714132). + +- Web SDK, [version 1.0.5+](https://github.com/Iterable/iterable-web-sdk/releases/tag/v1.0.5) + To learn more, see [Setting Up Iterable's Web SDK](https://support.iterable.com/hc/articles/4419628585364). \ No newline at end of file diff --git a/iterable-ios/reference/handling-anonymous-users.md b/iterable-ios/reference/handling-anonymous-users.md new file mode 100644 index 0000000..e66e691 --- /dev/null +++ b/iterable-ios/reference/handling-anonymous-users.md @@ -0,0 +1,263 @@ +--- +slug: handling-anonymous-users +feature: user-profiles +archetype: identity +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: Handling Anonymous Users via API +source_url: https://support.iterable.com/hc/articles/208499956 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/handling-anonymous-users/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: dce1b5d51ff199a29e8eae86e4a9b84f3cfde148 +fetched_at: 2026-10-22T10:00:08.188Z +summary: There are many scenarios where you will have users that you need to + track and connect with, but don't have their email address. This happens when + you have mobile apps that don't require an email, SMS-first marketing, and + more. +--- +# Handling Anonymous Users via API + +> [!NOTE] +> This article explains how to work with anonymous users using Iterable's APIs. +> This is a manual approach — you manage placeholder IDs and profile merging +> yourself through the API, without requiring Iterable's iOS, Android, or Web SDKs. +> +> If your apps or website use Iterable's iOS, Android, or Web SDKs, consider +> [Unknown User Activation](https://support.iterable.com/hc/articles/38755339847188) +> instead. Unknown User Activation automates profile creation, local data +> replay, and profile merging. Marketers can define profile creation criteria +> in the Iterable UI without redeploying your app or website. + +There are many scenarios where you will have users that you need to track and +connect with, but don't have their email address. This happens when you have +mobile apps that don't require an email, SMS-first marketing, and more. + +You might need to track anonymously in the following scenarios: + +- You don't have a user's email address but want to track their event history + and save profile data in Iterable. +- You have the user's device token and want to send them a push notification, + but don't yet have their email address. +- You have the user's phone number and want to send them SMS, but don't yet + have their email address. + +Iterable provides two different unique identifier fields, `email` and `userId`, +and these fields apply to projects depending on the project type: + +- **Hybrid project** - uses both `email` and `userId` fields as unique + identifiers. +- **UserID-based project** - uses just the `userId` field as a unique + identifier. +- **Email-based project** - uses `email` as a unique identifier. + (Limited availability: Only certain accounts have the option to create _new_ + email-based projects - [learn more](https://support.iterable.com/hc/articles/9216719179796#project-type-availability).) + +To work with anonymous users in userID-based projects and hybrid projects, you will +simply use the `userId` field instead of the `email` field to identify users. + +If you are working in an email-based project, however, then all user profiles require a +value for the `email` field. When you don't know the email address of a new user, +but need to create a profile in Iterable, you will need to create a placeholder +email address. + +> [!NOTE] +> If you aren't sure which unique identifier your project uses, or to learn more +> about each project type and how they work, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## Placeholder emails for email-based projects + +A placeholder email contains a unique string followed by `@placeholder.email`. + +Example: `some-user+10593364321@placeholder.email` + +### Placeholder email format + +An auto-generated placeholder email is generated with the following formula: +`userId` + `+` + Java hash + `@placeholder.email`. + +When the `userId` value is longer than 52 characters, Iterable creates the +placeholder email as: SHA1 hash + `@placeholder.email`. + +For example: `4146832971d799352598ff1b5712c0b999f615d5@placeholder.email` + +### Sending campaigns to users with placeholder emails + +A placeholder email is _not_ a valid recipient address. Email campaigns to +placeholder addresses are not attempted, and send skip events aren't logged +for these addresses. + +However, if the user's profile has other sending identifiers — such as a device +token for push notifications, or a phone number for SMS — you can still send +messages to them on those channels. + +## Creating a placeholder email + +You can manually assign a placeholder email to a user, or have Iterable automatically +generate one for you when creating the user. + +### Manually creating a placeholder email + +Placeholder email addresses in Iterable must end with `@placeholder.email`. +You can create placeholder emails by using the [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +API endpoint. + +For example, if adding users through the API: + +```json +{ + "email": "example@placeholder.email", + "dataFields": { + "firstName": "Data", + "catName": "Spot" + } +} +``` + +### Automatically assigning a placeholder email + +_Email-based projects only_ + +Instead of manually assigning a placeholder value for the `email` field, you +can also ask Iterable to generate one for you when creating the user. This is +only available if you are working in an email-based project. Iterable doesn't +create placeholder emails for userID-based and hybrid projects. + +To create a user with a placeholder email, use [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +with the following parameters in the request body: + +- `userId`: The user's ID +- `preferUserId`: `true` + +For example: + +```json +{ + "userId": "some-user-id", + "preferUserId": true, + "dataFields": { + "firstName": "Mary", + "lastName": "Lee" + } +} +``` + +This creates a user with `some-user-id+10593364321@placeholder.email` +as the assigned placeholder `email`: + +```json +{ + "user": { + "email": "some-user-id+10593364321@placeholder.email", + "userId": "some-user-id", + "dataFields": { + "firstName": "Mary", + "lastName": "Lee", + "email": "some-user-id+10593364321@placeholder.email", + "signupDate": "2022-10-27 22:12:43 +00:00", + "profileUpdatedAt": "2022-10-27 22:12:39 +00:00", + "signupSource": "API", + "userId": "some-user-id", + "itblInternal": { + "documentCreatedAt": "2022-10-27 22:12:43 +00:00", + "emailDomain": "placeholder.email", + "documentUpdatedAt": "2022-10-27 22:12:43 +00:00" + } + } + } +} +``` + +## Updating a placeholder email to the user's true email + +When your user has converted from anonymous to known and they have provided +their true email address, make a call to Iterable's +[`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +endpoint. + +For example, to update an `example@placeholder.email` email to `user@example.com`, +the payload would look like this: + +```json +{ + "currentEmail": "example@placeholder.email", + "newEmail": "user@example.com" +} +``` + +This call must be made in order for the historical data and profile fields to be +transferred to the new email address for the user in Iterable. + +> [!NOTE] +> - You cannot use the [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +> endpoint if the `newEmail` value already exists in Iterable. If `newEmail` is an +> existing user profile, see [below](#best-practices-for-handling-anonymous-users) +> for information about how to handle this. +> - Updating email addresses can only be done through Iterable's API. It cannot be +> done through Segment. + +## Best practices for handling anonymous users + +When you receive or have determined the user's actual email address, use the +[`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +API endpoint to update the existing user's email address. + +- The [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + API endpoint transfers historical data and profile fields from the placeholder + email to the new email address. + +- The [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + API endpoint creates a completely separate user profile. + + Once a completely separate user profile has been created for the user's actual + email address, you can't use the [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + API endpoint to update the placeholder email address to the user's actual email + address. + +If a completely separate user profile has been created and you wish to merge the +historical data and profile fields from a placeholder email address to the +user's actual email address, you will need to complete the following: + +- For user profile fields: + + - Use the [`GET /api/users/{email}`](https://support.iterable.com/hc/articles/204780579#get-api-users-email) + API endpoint to retrieve the user profile fields associate with the placeholder + email address. + + - Use the [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + API endpoint to set the placeholder email address's properties (fetched + above) on the actual email address's profile. + + :::tip WARNING + This approach will overwrite the user profile fields for the user's actual + email address with the user profile fields from the placeholder email + address. + ::: + +- For custom events: + + - Use the [`GET /api/events/{email}`](https://support.iterable.com/hc/articles/204780579#get-api-events-email) + API endpoint to retrieve the placeholder email address's custom events. + + - To record the events of the placeholder email address onto the user's + actual address profile, use the [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + to create the custom events. + + - Make sure you edit the `createdAt` field in the API call to backfill + the events. + + :::tip NOTES + - If the `createdAt` field is left blank, Iterable will automatically + assign the current date and timestamp to that field . + - Backfilling events will trigger any journeys that are triggered + by those custom events. + - Consider adding a filter tile after the Start tile that will allow + users with backfilled events to exit the journey. This can be done + through the creation date of the custom event or by including a data + field in the event itself that notes that it is a backfilled event. + ::: + +- Call [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + when a user signs in to the app and [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice) + when they sign out. This prevents duplicate sends to a device shared amongst + multiple people. diff --git a/iterable-ios/reference/project-types-and-unique-identifiers.md b/iterable-ios/reference/project-types-and-unique-identifiers.md new file mode 100644 index 0000000..725a3f0 --- /dev/null +++ b/iterable-ios/reference/project-types-and-unique-identifiers.md @@ -0,0 +1,148 @@ +--- +slug: project-types-and-unique-identifiers +feature: user-profiles +archetype: prerequisite +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: Project Types and Unique Identifiers +source_url: https://support.iterable.com/hc/articles/9216719179796 +source_repo: Iterable/iterable-docs +source_path: docs/setup-and-administration/projects/project-types-and-unique-identifiers/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: a37d7e60a1978d0dceb462a9b64214f6fec36935 +fetched_at: 2026-10-22T10:00:11.180Z +summary: Every Iterable project uses a specific field (`email` and/or `userId`) + to uniquely identify each user. This allows an individual record to be + referenced without confusion. This selection has a lasting impact on how your + project works and how you access data via API. +--- +# Project Types and Unique Identifiers + +Every Iterable project uses a specific field (`email` and/or `userId`) to +uniquely identify each user. This allows an individual record to be referenced +without confusion. This selection has a lasting impact on how your project works +and how you access data via API. + +Continue reading to learn more about the different project types and unique +identifiers, as well as how to determine the best setting for your project. + +> [!NOTE] +> Currently, there's no way to change the unique identifier for an existing +> project. You can only make this selection when creating a project. + +## Project types + +In Iterable, you define a _project type_ by specifying which field to use +to identify users. + +Iterable supports these project types: + +- **Hybrid projects**: Use both `email` and `userId` as unique identifiers. +- **UserID-based projects**: Use `userId` as a unique identifier. +- **Email-based projects** (_Limited availability_): Use `email` as a unique + identifier. + - Only certain accounts have the option to create new email-based projects—[learn more](#project-type-availability). + +### Comparing project types + +Let's compare how different project types handle your data when creating and +identifying users. + +| |**Hybrid projects**|**UserID-based projects**|**Email-based projects** (_Limited availability_)| +|---|---|---|---| +|**Unique identifier** |`email` and `userId`
(at least one of these fields is required)|`userId`|`email`| +|**Enforce `email` uniqueness?**|Yes|No|Yes| +|**Enforce `userId` uniqueness?**|Yes|Yes|No| +|**[How to identify users in API requests](https://support.iterable.com/hc/articles/29156459027348)**|`email` or `userId`|`userId`|`email` or `userId`| +|**Contact lookup by...**|`email` or `userId`|`userId`|`email`| +|**Static subscription list field**|`userListIds`|`userListIds`|`emailListIds`| +|**"Placeholder" email addresses assigned to anonymous users**|No|No|Yes| +|**[Signup form](https://support.iterable.com/hc/articles/207914276) support**|Yes|No|Yes| +|**GDPR forget by...**|`email` or `userId`|`userId`|`email`| +|**[EDC](https://support.iterable.com/hc/articles/17572750887444) support**|Yes|Yes|No| +|**Available for new accounts?**|Yes|Yes|[No](#project-type-availability)| + +### Choosing a project type + +Because the unique identifier project setting has implications across your data +platforms and you can’t change it later, consult with your development teams and +other interested stakeholders (such as CRM administrators) _before_ creating +your project in Iterable. + +Consider these points when choosing a project type: + +- **UserID-based projects may be best when you need to...** + - Track anonymous users (see [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956)). + - Support multiple users with the same email address, or single users with + multiple accounts. + - Avoid using email as a parameter in your API requests. + +- **Hybrid projects allow you to...** + - Maintain email uniqueness (one email per user) and keep track of anonymous + users. + - Use `userId` as a primary identifier while ensuring that every user's email + address is unique. + +> [!NOTE] +> +> **Limitations:** +> +> - Email-based projects have [limited availability](#project-type-availability). +> +> - Iterable doesn't support multiple email addresses or phone numbers for a +> single user. +> +> - Iterable does not generate values for the `userId` field. You must include +> the value when creating users. To learn more, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). +> +> The only exception where Iterable generates a `userId` value is when using +> Unknown User Activation. To learn more, read [Unknown User Activation +> Overview](https://support.iterable.com/hc/articles/38755339847188). + +#### Project type availability + +Originally, all Iterable projects identified users by email address. However, +for greater flexibility and to encourage best practices in data management +and privacy, Iterable introduced userID-based and hybrid projects. These +projects can identify users by `userId` and/or `email`, depending on your data +needs. + +All Iterable accounts can create userID-based and hybrid projects. However, +email-based projects can only be created by Iterable accounts that meet _all_ +of the following criteria: + +- Organizations that are hosted in Iterable's USDC-based data center. +- Organizations that were created before May 28, 2024. +- Organizations that have created an email-based project since May 28, 2023. + +If your Iterable account doesn't meet these criteria, and you need to identify +users by `email`, create a hybrid project. + +## Unique identifiers + +Each user in an Iterable project must have a unique identifier, which depends +on the project type: + +- `email`, in email-based projects +- `userId`, in userID-based projects +- Either field, in hybrid projects + +To learn about the `email` and `userId` fields and their data requirements, see +[User Profile Fields Used by Iterable](https://support.iterable.com/hc/articles/217744303). + +To learn about how each field is handled across Iterable in the API, see +[Creating and Identifying Users with Iterable APIs](https://support.iterable.com/hc/articles/29156459027348). + +### Checking the unique identifier for a project + +To check the unique identifier(s) for your project, you can: + +- Go to **Settings > Project Settings** and see the project type under the + **Unique Identifier** setting. + +- Go to **Audience > Contact Lookup** and see which fields you can search for. + You can only find users by unique identifiers here. + +> [!NOTE] +> Currently, there's no way to change the unique identifier for an existing +> project. You can only make this choice when creating a project. \ No newline at end of file diff --git a/iterable-ios/reference/updating-a-users-email-address.md b/iterable-ios/reference/updating-a-users-email-address.md new file mode 100644 index 0000000..e4041b4 --- /dev/null +++ b/iterable-ios/reference/updating-a-users-email-address.md @@ -0,0 +1,199 @@ +--- +slug: updating-a-users-email-address +feature: user-profiles +archetype: identity +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: Updating a User's Email Address or User ID +source_url: https://support.iterable.com/hc/articles/217502386 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/updating-a-users-email-address/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: 510751fc6ae6e983ceae45146ba7e44f491ee7b2 +fetched_at: 2026-10-22T10:00:09.432Z +summary: Sometimes you'll need to change the email address or user ID for a + user—perhaps they were previously anonymous and now they have converted to a + known contact, their `email` has changed, or you are updating your project's + `userId` fields to standardize, correct, or otherwise update this value. +--- +# Updating a User's Email Address or User ID + +Sometimes you'll need to change the email address or user ID for a +user—perhaps they were previously anonymous and now they have converted to a +known contact, their `email` has changed, or you are updating your project's +`userId` fields to standardize, correct, or otherwise update this value. + +This article describes how to change these fields using Iterable's API. + +> [!WARNING] +> After updating a user profile (for example, by calling [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update)), +> wait 60 seconds before making other API calls related to the same user. +> +> For GDPR forget requests ([`POST /api/users/forget`](https://support.iterable.com/hc/articles/204780579#post-api-users-forget)), +> provide ten minutes to process. +> +> These delays can help prevent you from accidentally sending a campaign to the +> wrong email address, and other similar problems. + +## Email-based Projects + +### Updating email address in an email-based project + +Email-based projects identify users by `email`. To update a user's email +address, call [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail), +with a request body such as: + +```json +{ + "currentEmail": "current@example.com", + "newEmail": "new@example.com" +} +``` + +In the request body, specify `currentEmail` or `currentUserId`, but not both. +If you provide both, `currentEmail` takes precedence. + +### Updating user ID in an email-based project + +To add or update the `userId` field for a user in an email-based project, you +can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +In the request body: + +- For `email`, specify the existing user's email address. +- In `dataFields`, provide a `userId`. + +For example: + +```json +{ + "email": "existingUser@example.com", + "dataFields": { + "userId": "newUserId" + } +} +``` + +For this request: + +- Iterable checks to see whether your project already has a user with `email` + set to `existingUser@example.com`. It not, the user is created. +- Whether the user is new or already exists, Iterable assigns a `userId` of + `newUserId` to the user. + +To add or update a user's `userId` in an email-based project, call +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with the new `userId` value inside of `dataFields`. + +> [!WARNING] +> **Email-based projects do not enforce `userId` uniqueness.** +> +> This means you won't receive an error if you update a user to have a non-unique +> `userId` value, and a duplicate `userId` will exist in your Iterable project. +> This may cause problems when making API calls based on `userId`. +> +> If you need unique `userId` values, consider creating a userID-based project or +> a hybrid project. To learn more, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## UserID-based projects and hybrid projects + +### Updating email address in a userID-based project or hybrid project + +To update an existing user's `email` in a userID-based or hybrid project, call +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with a request body such as: + +```json +{ + "userId": "someUserId", + "dataFields": { + "email": "newEmail@example.com" + } + } +``` + +### Updating user ID in a userID-based project or hybrid project + +To update an existing user's `userId` in a userID-based or hybrid project, +call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with a request body such as: + +```json +{ + "userId": "oldUserId", + "dataFields": { + "userId": "newUserId" + } + } +``` + +## Common Errors + +There may be errors encountered when you are attempting to update a unique +identifier for a user. This happens because values are uniquely enforced and +conflicts can result in errors. + +### Unique `email` projects + +Both email-based projects and hybrid projects enforce a unique value for the +`email` field. + +When updating the `email` field, watch out for these common errors: + +- When `currentEmail` does not exist: + + ```json + { + "msg": "Cannot change user Some(oldEmail@example.com) to newEmail@example.com. User does not exist", + "code": "BadParams", + "params": null + } + ``` + +- When `newEmail` already exists: + + ```json + { + "msg": "Cannot change user Some(oldEmail@example.com) to newEmail@example.com. New email already exists", + "code": "EmailAlreadyExists", + "params": null + } + ``` + +- When `newEmail` has been GDPR forgotten: + + ```json + { + "msg": "Cannot change user oldEmail@example.com to newEmail@example.com. New email is on the forgotten list", + "code": "EmailAlreadyExists", + "params": null + } + ``` + +### Unique `userId` projects + +UserID-based projects and hybrid projects enforce unique `userId` values. Here +are some things you may sees when updating a `userId` value: + +- When the user you're trying to update doesn't actually exist, you'll receive + a `200` response, and Iterable creates the user with the new `userId`. + +- When the updated `userId` is already taken by another user: + + ```json + { + "msg": "userId already exists: newUserId", + "code": "ExternalKeyConflict", + "params": null + } + ``` + +- When the original `userId` has been GDPR forgotten: + +```json +{ + "msg": "User with userId originalUserID is forgotten", + "code": "ForgottenUserError", + "params": null +} +``` diff --git a/iterable-ios/reference/user-id-validation-in-iterable.md b/iterable-ios/reference/user-id-validation-in-iterable.md new file mode 100644 index 0000000..7115253 --- /dev/null +++ b/iterable-ios/reference/user-id-validation-in-iterable.md @@ -0,0 +1,98 @@ +--- +slug: user-id-validation-in-iterable +feature: user-profiles +archetype: identity +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: User ID Validation in Iterable +source_url: https://support.iterable.com/hc/articles/41111923969556 +source_repo: Iterable/iterable-docs +source_path: docs/audience-management/users-and-user-profiles/user-id-validation-in-iterable/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: d2af79dc054d08c4d6149b66d9879935056b6856 +fetched_at: 2026-10-22T10:00:06.988Z +summary: "Whenever you create or update a user in Iterable, you can provide a + `userId` value. When you do this, Iterable performs the following validations + on the `userId` field:" +--- +# User ID Validation in Iterable + +Whenever you create or update a user in Iterable, you can provide a `userId` +value. When you do this, Iterable performs the following validations on the +`userId` field: + +- **Maximum length** of 128 characters. + +- **Must not contain non-printable ASCII characters**, such as + [non-breaking spaces](https://www.ascii-code.com/character/nbsp) and + [soft hyphens](https://www.ascii-code.com/character/shy). + + Non-printable characters are typically created programmatically and aren't + visible printed characters. Not to be confused with space, tab, or other + whitespace characters, or with [hard hyphen](https://www.ascii-code.com/character/-) + (also called a minus sign). + +- **Must not end with a space**. + +- Values are **case-sensitive**. + +- Iterable doesn't generate values for this field. You must include the value + when creating users. + +- Values should **not contain PII**, such as an email or phone number. + +- **Uniqueness** is: + - Enforced in userID-based and hybrid projects. + - _Not_ enforced in email-based projects. In the event the same `userId` is + associated with multiple users, there are some important considerations. To + learn more, read [Identifying users by `userId` in email-based projects](https://support.iterable.com/hc/articles/29156459027348#email-based-projects). + +## Providing a `userId` value + +Iterable does not generate values for the `userId` field. You must include +the value when creating users. + +> [!NOTE] +> The only exception is when you are using Unknown User Activation, in which case +> Iterable generates a `userId` value for you. To learn more, read [Unknown User +> Activation Overview](https://support.iterable.com/hc/articles/38755339847188). + +### User ID best practices + +Ideally, user ID values are static (unchanging), not guessable, and meaningless +outside of the context where it is used. + +Here are some good options for `userId` field values: + +- The same ID field you use to identify a user in your application—assuming + it meets these best practices. + +- The same ID field as another, primary system of record (such as your CRM, or + another integration that you rely on to store your user's data). + +- Randomly generated universally unique identifiers (UUIDs). [Learn more](https://en.wikipedia.org/wiki/Universally_unique_identifier). + +- Other database-generated unique IDs. + +Practices to avoid: + +- Anything that is considered personally identifiable information (or PII), such + as: + + - Names + - Email addresses + - Phone numbers + - Dates of birth + +- Any values that may be guessable, such as: + + - Formulas based on PII information + - Sequential values (10001, 10002, 10003 ... _n+1_) + - Usernames or "handles" + +> [!WARNING] +> +> **You can edit values in the `userId` field.** +> +> Values stored in the `userId` field are not permanent in Iterable. They can be +> overwritten or changed. Learn more at [Updating a User's Email Address or User ID](https://support.iterable.com/hc/articles/217502386). diff --git a/iterable-ios/reference/user-profile-fields-used-by-iterable.md b/iterable-ios/reference/user-profile-fields-used-by-iterable.md new file mode 100644 index 0000000..6fb3747 --- /dev/null +++ b/iterable-ios/reference/user-profile-fields-used-by-iterable.md @@ -0,0 +1,517 @@ +--- +slug: user-profile-fields-used-by-iterable +feature: user-profiles +archetype: identity +sdk_min_version: 6.7.5 +sdk_artifact: IterableSDK +title: User Profile Fields Used by Iterable +source_url: https://support.iterable.com/hc/articles/217744303 +source_repo: Iterable/iterable-docs +source_path: docs/audience-management/users-and-user-profiles/user-profile-fields-used-by-iterable/index.md +source_ref: 9875968f907df280d7023b1f8f45ba0293940498 +source_sha: 51d92087789bba35ac0db9c47d2a9e9c3c7bdfd8 +fetched_at: 2026-10-22T10:00:07.536Z +summary: User profiles in Iterable contain various fields that Iterable uses for + specific purposes. This document describes these fields and their meanings. +--- +# User Profile Fields Used by Iterable + +User profiles in Iterable contain various fields that Iterable uses for specific +purposes. This document describes these fields and their meanings. + +> [!NOTE] +> The character limit for user profile fields is approximately 32k characters. +> All user profile fields are case-sensitive and space-sensitive. + +## Fields used as unique identifiers + +Each user in an Iterable project must have a unique identifier. Two fields are +available in Iterable to uniquely identify users: `email` and `userId`. When you +create your project, you will choose a _project type_. The project type +determines which field(s) you can use to identify your users. It also impacts +how you manage records in Iterable. + +For more information, see [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). For information about how these fields are used with +Iterable’s APIs, see [Creating and Identifying Users with Iterable APIs](https://support.iterable.com/hc/articles/29156459027348). + +### Identifying users by `email` + +Iterable uses the [`email`](#email) field as a unique identifier for users in +hybrid and email-based projects. To learn more about the `email` field and +accepted values, see [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +In hybrid and email-based projects, each user's `email` **must be unique**. + +In userID-based projects, `email` uniqueness is not enforced and there may +be multiple users with the same `email` address. + +### Identifying users by `userId` + +The [`userId`](#userid) field stores a unique value that you've assigned to +identify each user. To learn more about the `userId` field and accepted values, +see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + +In userID-based and hybrid projects, each user's `userId` **must be unique**. + +In email-based projects, unique values for `userId` are not required or +enforced. In the event the same `userId` is associated with multiple users, +there are some important considerations. To learn more, read +[Identifying users by `userId` in email-based projects](https://support.iterable.com/hc/articles/29156459027348#email-based-projects). + +## Fields managed by Iterable + +Iterable manages the fields described below. These fields are visible in the +responses to various API calls, but (unless otherwise noted) they should not +be included in calls to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +or other endpoints that update user profiles. + +### `emailListIds` + +An array of integers that represent the static lists of which a user is a +member. The values in this array change as the user is added to or removed +from lists. + +This array does not contain IDs for dynamic lists. + +This field applies to email-based projects only. For userId and hybrid +projects, Iterable uses [`userListIds`](#userlistids). + +### `itblInternal` + +A collection of various fields managed by Iterable. + +### `itblInternal.phoneCountry` + +A two-letter code that represents the country associated with the user's +[`phoneNumber`](#phonenumber), as defined by [ISO-3166](https://www.iso.org/iso-3166-country-codes.html). + +When you send an SMS campaign to a user, Iterable checks to see if the user has +`itblInternal.phoneCountry` set. If it isn't set, Iterable uses `phoneNumber` +to look up the ISO-3166 country code, and adds that country code to the user's +profile. + +Currently, this field is only used for reporting purposes, and is not used to +determine a user's language or system actions related to a user's country. + +> [!TIP] +> +> There are a couple of ways to measure SMS success by country: +> +> - [Campaign Analytics](https://support.iterable.com/hc/articles/218203223#segment-analysis) — +> Create a segment analysis using `itblInternal.phoneCountry`. +> - [Messaging Insights](https://support.iterable.com/hc/articles/360052814452) - +> In the **Audience** view, select the `itblInternal.phoneCountry` segment. + +### `itblInternal.phoneType` + +Iterable also stores the user's phone type in the `itblInternal.phoneType` field, +which is a string that represents the type of phone number the user has. + +This is an informational field that you may find useful when reviewing your user +profiles or creating segments. For example, some number types can be blocked from +receiving SMS messages, which is helpful when determining the size of your SMS-ready +audience. + +This field may have one of the following values: +- `MOBILE` - Mobile phone number +- `FIXED_LINE` - Fixed line phone number +- `FIXED_LINE_OR_MOBILE` - Fixed line or mobile phone number +- `TOLL_FREE` - Toll-free number +- `PREMIUM_RATE` - Premium rate number +- `SHARED_COST` - Shared cost number +- `VOIP` - Voice over IP number +- `PERSONAL_NUMBER` - Personal number +- `PAGER` - Pager number + +### `itblDS` + +An object containing information related to [Iterable AI](https://support.iterable.com/hc/articles/360052990091). +Its `brandAffinityLabel` field represents the contact's level of engagement +with your brand, as determined by [Brand Affinity™](https://support.iterable.com/hc/articles/360052990191). +Its `predictiveGoals` field provides user conversion scores for goals that are +created in Iterable's [Predictive Goals](https://support.iterable.com/hc/articles/5026416447636#view-a-user-s-conversion-scores). + +### `itblUserId` + +A unique identifier used internally by Iterable for hybrid and userID-based +projects. Using this field to reference a user is NOT recommended. Instead, use +[`userId`](#userid), which is a field that you can access and manage. + +### `knownLitigatorFilter` + +A string that represents the confidence level of the user's known litigator status. +The possible values are `LT1`, `LT2`, and `LT3`. The `LT1` value denotes high +confidence that the phone number has been involved in TCPA litigation, while the +value `LT2` denotes a lower confidence level. If a user is detected as both LT1 +and LT2, the field will be updated to `LT3`. + +### `profileUpdatedAt` + +The most recent time at which a user's profile was updated (via CSV upload or +API call). This field is a date, and it has the following format: +`2016-08-02 18:53:45 +00:00`. For more information, read +[Field Data Types](https://support.iterable.com/hc/articles/208183076). + +> [!NOTE] +> Previously, Iterable would process user update requests that didn't actually +> change the user's profile data. This resulted in an update to the +> `profileUpdatedAt` field even though the user profile hadn't actually changed. +> As of February 2023, `profileUpdatedAt` is no longer modified when user profile +> data has not changed. To learn more about this change, see +> [2023 Release Notes](https://support.iterable.com/hc/articles/11667879575572#february-2023). +> +> In the rare case you need the original behavior, talk to your customer success +> manager. + +### `receivedSMSDisclaimer` + +Indicates whether or not the user has received an SMS message, from the current +Iterable project, that includes SMS opt-out instructions. Receiving opt-out +instructions in a proof will not set this field to `true`. + +### `signupDate` + +The date when the user's profile was created in Iterable. This field is a date, and +it has the following format: `2016-08-02 18:53:45 +00:00`. For more information, +read [Field Data Types](https://support.iterable.com/hc/articles/208183076). + +### `signupSource` + +How the contact was created in Iterable (for example, `API` or `IMPORT`). + +### `subscribedMessageTypeIds` + +An array of integers that represent the opt-in message types to which a contact has +subscribed. Contacts will receive messages associated with opt-in message types only +if both of the following are true: + +- They have subscribed to those opt-in message types +- They have not unsubscribed from the channels associated with those types + +> [!WARNING] +> `subscribedMessageTypeIds` is available only for accounts or projects for which +> [opt-in message types](https://support.iterable.com/hc/articles/204780529) have +> been enabled. To do so, contact your Iterable customer success manager. + +### `unsubscribedChannelIds` + +An array of integers that represent the message channels from which a user has +unsubscribed. Users will not receive messages from any message type belonging to a +message channel from which they have unsubscribed. + +### `unsubscribedMessageTypeIds` + +An array of integers that represent the opt-out message types from which a user has +unsubscribed. + +Users will receive messages from opt-out message types only if both of the following +are true: + +- They have not unsubscribed from those opt-out message types +- They have not unsubscribed from the channels associated with those types + +### `userListIds` + +An array of integers that represent the static lists of which a user is a +member. The values in this array change as the user is added to or removed +from lists. + +This array does not contain IDs for dynamic lists. + +This field applies to userId and hybrid projects. For email-based projects, +Iterable uses [`emailListIds`](#emaillistids). + +## Fields used for sending messages + +When sending messages, Iterable relies on the fields described below. + +> [!WARNING] +> The specific names of these fields are important. For example, Iterable uses the +> `phoneNumber` field when sending SMS messages; it won't recognize `phone_number` +> or `PhoneNumber`. + +### `country` + +The user's country. + +**Accepted values** - Any string value. This field has a string data type and +is not validated. When you add this data to Iterable, you can enter any value +your company uses to represent a user's country. + +Alternatively, you can set up your project so that Iterable automatically looks +up the country based on the user's IP address. When Iterable stores the +`country` field, it uses the English-language name of the country, as found in +the [GeoNames database](https://www.geonames.org/countries/). + +When using SMS Smart Opt-In, Iterable uses the `country` field to assign a +number from your SMS senders that matches the user's country. For complete +detail on how `country` is used to assign a number, see +[SMS Smart Opt-In](https://support.iterable.com/hc/articles/10870034844308#how-sms-smart-opt-in-selects-a-number-for-the-user-to-reply-to). + +Iterable can automatically populate the `country` field based on the user's IP +address. This lookup is triggered whenever the `ip` field is updated on the +user profile. + +To learn more about updating a user's IP and/or geolocation fields using +inbound data, read [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `devices` + +A nested data type that contains an array of objects, each representing a +mobile device/mobile app combination for which a user can (or used to be able +to) receive push notifications (silently or otherwise). + +Each object in the `devices` array contains the following fields: + +- `applicationName` - A string that identifies the mobile app for which the user + can receive push notifications. + +- `appPackageName` - The unique application identifier associated with your app + (as specified by its developers). Different for iOS and Android. + +- `appVersion` - The version of your app running on the user's device. + +- `deviceId` - A randomly generated UUID for a user's device. Generated by Iterable + and stored in local storage on the device, and in Iterable itself. When the + user re-installs the app, a new one is created (we don't track a device across + app reinstalls). + +- `endpointEnabled` - Indicates whether or not Iterable can send push notifications + to this device. If a user prevents an app from displaying push notifications, + but after the app has already registered its device token with Iterable, the + app's code should disable the device in Iterable by calling [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice). + This sets `endpointEnabled` to `false` for this device, preventing Iterable from + sending future push campaigns to it. If the app does not disable the device, + Iterable will continue to send push campaigns to it. The user will never see those + campaigns, but they'll still be counted in the campaign's total send count. + To check if Iterable can send push notifications to a given device/app + combination, check its `endpointEnabled` field. If `true`, Iterable can send push + notifications to that device. + +- `identifierForVendor` - iOS only. An alphanumeric string that uniquely identifies a + device to the app’s vendor. Unique to that device for that app developer. + +- `iterableSdkVersion` - The release version of the SDK. Numbers will differ + by platform (iOS, Android, React Native), correspond with the release numbers + on our Github pages ([iOS](https://github.com/Iterable/iterable-swift-sdk/releases), + [Android](https://github.com/Iterable/iterable-android-sdk/tags), + [React Native](https://github.com/Iterable/react-native-sdk/releases)). + +- `localizedModel` - iOS only. the model of the device. For example: "iPhone", + "iPad", "iPod Touch". + +- `model` - The model name of the device. Apple examples: "iPhone", "iPad", "iPod Touch". + Android example - "sdk_gphone_x86_arm". + +- `notificationsEnabled` - whether or not push notifications are enabled on the + device. + +- `platform` - The platform from which the device can receive push notifications. + For example, `APNS_SANDBOX`. + +- `platformEndpoint` - The specific address Iterable uses to reach the device + by push notification. + +- `systemVersion`: The version number of the device's operating system. + +- `token` - A string Iterable uses to identify the device when sending it a push + notification. + +- `userInterfaceIdiom`: iOS only. The device's category. For example: "Phone", + "Pad", "carPlay". + +> [!TIP] +> **TIPS** +> +> - To add a device to this list (or re-enable one that is already there), call +> [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken). +> - To disable a device on the list, so that it will no longer receive messages, call +> [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice). + +### `email` + +A user's email address. + +- This field is validated. To learn more, see [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +- Iterable uses this field to send email campaigns to users. + + - An exception to this is anonymous users with `@placeholder.email`. These users + have placeholders in order to identify the user in Iterable, but their `email` + is not a valid recipient address. Email campaigns sent to `@placeholder.email` + addresses are skipped. (Send skip events are not logged for this scenario.) See + [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956) + to learn more about placeholder emails. + +- `email` is one of two possible fields that uniquely identify Iterable users, + along with [`userId`](#userid). To continue learning about the `email` field, + unique identifiers, and how they work, read + [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +### `ip` + +The user's IP address. + +Iterable can automatically update the `ip` field based on the IP address +contained in inbound email click (`emailClick`) and/or SMS click (`smsClick`) +events. This ensures that the `ip` field is refreshed regularly for your most +engaged users. To learn more, read [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `locale` + +A user's location and/or language preference. If you have multiple locale +variants of a template (all with the same `templateId`), Iterable uses this +field to determine which version to send to the user. + +This field maps to the codes you set up in the [Locales](https://support.iterable.com/hc/articles/12441621049876) +section of your Project Settings page. + +### `phoneNumber` + +A string that represents the user's phone number. Iterable uses this number when +sending SMS messages. + +Values must be formatted using [E.164](https://en.wikipedia.org/wiki/E.164). +For example: + +- US: `+14155550132` +- Germany: `+498999998000` + +When you send an SMS to the user, Iterable checks to see if the user has +[`itblInternal.phoneCountry`](#itblinternal-phonecountry) set. If it doesn't, +Iterable uses `phoneNumber` to look up the country code and add it to the user's +profile. + +Iterable doesn't send SMS campaigns to users without a `phoneNumber` set. + +> [!WARNING] +> When updating a `phoneNumber`, if you don't include a country code, Iterable +> automatically adds `+1` (the country code for USA, Canada, and parts of the +> Caribbean) to the front of the number. +> +> For example, if you try to update a user's number to `4155550132`, Iterable +> saves it in the user's profile as `+14155550132`. + +### `profile` + +:::v-pre +A hidden field housing all of a user profile data. Fields at the root level on +the user profile can be referenced via merge tags by writing either +`{{firstName}}` or `{{profile.firstName}}`. +::: +To avoid confusion, do not use `profile` as a field name in your user profiles. + +### `timeZone` + +The user's time zone, which Iterable uses for the following features: + +- [Send Time Optimization](https://support.iterable.com/hc/articles/360050923471) +- [Quiet Hours](https://support.iterable.com/hc/articles/16928857601940) +- [Sending in Different Time Zones](https://support.iterable.com/hc/articles/14761834767508#sending-in-different-time-zones) + +Values are case-sensitive and must be precisely formatted, as described by the +[IANA time zone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +Examples: + + - `America/Los_Angeles` + - `Europe/London` + - `Asia/Tokyo` + +You can set values in the `timeZone` field by uploading your own data to the +user profile using any data import method (such as API, CSV, or Smart Ingest), +or by enabling project settings so Iterable can automatically update the +`timeZone` field. + +Iterable provides project settings that capture each user's IP address, and +look up the user's time zone based on their IP address. This ensures that the +`timeZone` field is refreshed regularly for your most engaged users. + +To learn more about updating a user's IP and/or geolocation fields using +email or SMS click events, or to learn how to set up your project so that +Iterable can automatically update the `timeZone` field, see [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `userId` + +The ID of a user. This is always a value which you've either assigned or +imported from another source. To learn more about the `userId` field and +accepted values, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + +> [!NOTE] +> This is one of two possible fields that uniquely identify Iterable users, +> along with [`email`](#email). To continue learning about the `userId` field, +> unique identifiers, and how they work, see our guide +> [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +### `whatsAppPhoneNumber` + +A string that represents the user's WhatsApp phone number. Iterable uses this number when +sending WhatsApp messages. + +Values must be formatted using [E.164](https://en.wikipedia.org/wiki/E.164). +For example: +- US: `+14155550132` +- Germany: `+498999998000` + +## Automatically populating IP and geolocation fields + +If enabled for your project, Iterable can automatically populate the following +geolocation fields: `ip`, `city`, `region`, `country`, and `timeZone`. This is +helpful for campaigns that use [Quiet Hours](https://support.iterable.com/hc/articles/16928857601940) +and [send in different time zones](https://support.iterable.com/hc/articles/14761834767508#sending-in-different-time-zones), +as Iterable can use the user's IP address to infer their time zone and send +messages at the most appropriate time. IP lookup is skipped when the event's +`userAgent` contains "GoogleImageProxy" (for example, when a user opens an email +from Gmail). + +To have Iterable automatically update user fields based on their IP geolocation: + +1. Set up click tracking for your email and/or SMS campaigns: + - [Email link tracking](https://support.iterable.com/hc/articles/7130133854612#step-3-set-up-tracking-domains) + must be enabled to capture the user's IP address on email click events. + - [SMS shortlinks](https://support.iterable.com/hc/articles/4403865896724) + must be enabled to capture the user's IP address on SMS click events. + +2. Update the user's `ip` field with the [**Overwrite IP Field**](https://support.iterable.com/hc/articles/206382645#overwrite-ip-field) + project setting. This automatically captures a user's IP address each time + they click a link in an email and/or SMS campaign. + +3. Turn on the [**Overwrite Location Fields**](https://support.iterable.com/hc/articles/206382645#overwrite-location-fields) + project setting to update the `city`, `region`, `country`, and `timeZone` + fields based on the user's IP address, every time the `ip` field is updated. + +## Other reserved field names + +The following field names are reserved by Iterable, and you can't manually set +them: + +- `campaignId` +- `channelId` +- `eventName` +- `messageTypeId` +- `templateId` +- `total` + +Also avoid using `size` as a field name. You can do it, but referencing +such a field can be unreliable, since it sometimes references the +[number of items in an object or array](https://support.iterable.com/hc/articles/36531450481300#display-the-size-of-an-array). + +## Common event fields + +You'll often see the following fields on Iterable system events: + +- `messageId`: A unique identifier for a particular send of a particular campaign + to a particular user. +- `contentId`: An ID associated with the specific template revision sent to the + user for a campaign. +- `catalogLookupCount`: The number of times a catalog was referenced in the message. +- `catalogCollectionCount`: The number of times a catalog collection was referenced + in the message. + +## Want to learn more? + +For more information about some of the topics in this article, check out this +Iterable Academy course. Iterable Academy is open to everyone — you don't need +to be an Iterable customer! + +- [Foundations: Setting Up Senders](https://academy.iterable.com/setting-up-senders) \ No newline at end of file diff --git a/iterable-provision/SKILL.md b/iterable-provision/SKILL.md index e0a3a1d..4e6aab4 100644 --- a/iterable-provision/SKILL.md +++ b/iterable-provision/SKILL.md @@ -22,9 +22,10 @@ selector. `AskUserQuestion` has no field for `context`, so in the same turn: 2. then call `AskUserQuestion` with `header`, `prompt`, and the options. Never call `AskUserQuestion` for a screen whose `context` you have not printed -in that turn. On iOS screens the links and numbered steps are in `prompt` itself, -with the question as its last line: pass `prompt` to `AskUserQuestion` whole, -line breaks included, and never shorten it to the question line. +in that turn. On every screen, Android and iOS, the links, numbered steps, +values and the list of changes being approved are in `prompt` itself, with the +question as its last line: pass `prompt` to `AskUserQuestion` whole, line +breaks included, and never shorten it to the question line. Some options run a command that prints another screen, such as an iOS `question --kind help_…` guide or "Show these steps again". Render that screen @@ -38,7 +39,9 @@ own walkthrough. Inspect the app before invoking a program: - For an Android app, or the Android target of a React Native app, follow the - Android program below. + Android program below. A React Native app with both platforms in scope does + Android first: provisioned, integrated, and proved by `iterable-android-verify` + before its iOS target starts. - For a native iOS app, read [`reference/ios.md`](reference/ios.md) and use `/bin/ios-provision`. Conduct the interactive state machine in the main session. Do not hand the @@ -71,7 +74,10 @@ Inspect the app before invoking a program: Native iOS status starts with Android's grouped scope choices—push, events plus user profiles, deep links, and a long-tail bucket—with one optional long-tail follow-up. The free-text `initialization only` answer is the same - basic init-and-identify path. Free-text commands already single-quote + basic init-and-identify path. When the iOS skill's Step 0 already confirmed + the scope and identity, pass them to the first `status` as `--features` and + `--identity`. One `carry_scope` screen then confirms them, and those choices + are never asked a second time. Free-text commands already single-quote `'{{answer}}'`; substitute the developer's text inside those quotes and write any single quote in it as `'\''`. It then carries one tool-authored selector in `next.question` at a time. Render it and run the @@ -122,7 +128,13 @@ Inspect the app before invoking a program: delete the retained APNs private key. - For an iOS target owned by a React Native or Expo integration, keep the React Native skill in charge of application code. Use the iOS provisioning - path only for the Apple and Iterable prerequisites it explicitly needs. + path only for the Apple and Iterable prerequisites it explicitly needs. Run + the Android program first: the iOS path then offers to reuse its Mobile and + Server-side keys, region, No JWT policy, and test email, plus the feature + scope the React Native skill confirmed when passed with `--features`, and + writes the Mobile key where the JavaScript app reads it + (bare React Native `.iterable/rn/iterable.local.ts`, Expo + `EXPO_PUBLIC_ITERABLE_API_KEY` in a gitignored `.env.local`). Do not run the Android gate ladder against an iOS project. Its Firebase, `adb`, package-name, and FCM conclusions do not apply to APNs. @@ -570,4 +582,5 @@ it as part of its one confirmed diff. This skill produces the file; it does not write into the developer's source tree. After the integration is written, `iterable-android-verify` proves it with a -real push. +real push. For a React Native app with iOS in scope, that green verdict is +where the iOS phase begins. diff --git a/iterable-provision/reference/ios.md b/iterable-provision/reference/ios.md index e30ca34..d98569e 100644 --- a/iterable-provision/reference/ios.md +++ b/iterable-provision/reference/ios.md @@ -80,6 +80,54 @@ behavior, Mobile Inbox, and Embedded Messages. Initialization, identification, and the default Iterable in-app message display are included automatically. Identity, region, JWT policy, and final confirmation follow. +The app's skill asks the scope once, in its Step 0: `iterable-ios` for a +native app, `iterable-react-native` for React Native or Expo. It passes the +confirmed answers to the first `status` call, and the tool does not ask them +again: + +- `--features` takes comma-separated ids, exactly the ones `choose_features` + and `choose_more_features` record: `push-notifications`, `event-tracking`, + `user-profiles`, `deep-linking`, `in-app-messages`, `mobile-inbox`, + `embedded-messaging`, or `sdk-initialization` alone for initialization only. +- `--identity` takes `email`, `user-id`, or `unknown-user`, the values + `choose_identity` records. + +Any other value exits 2 before anything is written, and `status` itself only +reads the flags. With no Android setup to import, `next` is one `carry_scope` +screen, for example "Confirmed in the iOS skill: features push-notifications, +sdk-initialization; identity user-id." + +- **Use these answers** runs `carry-scope --confirm` with exactly the answers + shown. It records them as proposed answers, with `scope_source` (features) + and `identity_source` (identity) set to `ios-step0`, or `react-native-step0` + for a React Native or Expo app. The `choose_features`, + `choose_more_features`, and `choose_identity` screens are then skipped. +- **Change the answers** records the decline, and those screens are asked as + usual. + +Region, JWT, and `confirm_scope` follow. `confirm_scope` marks each carried +line with its origin, and `record-scope` keeps both sources in `scope.json`. +A later `answer` to a carried field drops that field's source. Without the +flags nothing changes. + +When the Android program already ran for this app (its `.iterable/.env` holds +a Mobile key), fresh status first asks `import_android`. Its recommended +option runs `import-from-android --confirm`. One Mobile key and one +Server-side key serve both platforms, so it reuses them with the Android +region and No JWT (the Android flow requires Require JWT off). It never prints +them, and both keys are still validated again for iOS. The Android setup +records no feature scope, so the Step 0 flags go with the import instead of a +`carry_scope` screen. The `import_android` screen names them, and its +recommended option passes the same `--features` and `--identity` to +`import-from-android`, which records them as `carry-scope` would. A carried +identity takes precedence over the Android test email. Without the flags the +feature questions follow as usual. **Set up iOS separately** asks everything +again. In a monorepo the Android workspace sits +at the repository root, so the search walks up from the app root to the git +toplevel, takes the nearest `.iterable/.env`, and records that path as +`iterable.imported_from`. Android package auto-detection does not support +monorepos yet: there the developer types the package name. + Each question has the same core fields as the Android conducted workflow: - `context`, `header`, and `prompt` are developer-facing words; @@ -110,10 +158,11 @@ Render the question with the host's selector UI. In Claude Code, `AskUserQuestion` has no field for `context`: in the same turn, first print the `context` as a normal Markdown message, preserving every line, blank line, numbered step, and URL, then call `AskUserQuestion`. Never call it for a -screen whose `context` you have not printed. Every link and numbered step a -screen needs is in its `prompt`, steps first and the question last, so the -selector alone still says what to do; pass the whole `prompt`, line breaks -included. `context` holds the remaining notes. Map +screen whose `context` you have not printed. Every link, numbered step, value, +summary, and list of what is being approved that a screen needs is in its +`prompt`, steps first and the question last, so the selector alone still says +what to do; pass the whole `prompt`, line breaks included. `context` holds only +background notes. Map `header`, `prompt`, and each option's `label` and `description` directly into the selector. Do not add, remove, reword, or reorder options, pick a recommendation for the developer, collapse the content into one paragraph, or @@ -236,6 +285,17 @@ clipboard into `.iterable/ios/.env`, generates `.iterable/ios/IterableSecrets.xcconfig`, restricts both files to mode `0600`, and clears the clipboard. It never prints the key. +Where the key goes follows the app's framework, read from `package.json` and +`app.json` at the app root. Bare React Native gets +`.iterable/rn/iterable.local.ts` instead of the xcconfig: it exports +`ITERABLE_API_KEY` inside the self-ignoring workspace, and the app loads it +with an optional `require` in `try`/`catch`, because a static import breaks +fresh clones and CI. Expo gets `EXPO_PUBLIC_ITERABLE_API_KEY` in `.env.local`, +written only when git ignores that file; otherwise status returns +`fix_expo_gitignore` before any key is written. If the delivered file goes +missing, status returns `deliver_mobile_key`, which rewrites it from the +workspace. + Next, prove that the value is a Mobile key for the recorded region: ```text @@ -481,6 +541,8 @@ to `iterable-ios` for application wiring and proof. The generated `.iterable/ios/IterableSecrets.xcconfig` is the secure handoff to Xcode. Include it from the app's committed base configuration using the correct relative path; do not copy or print its value. +For React Native or Expo, the delivered module or `.env.local` is the handoff +instead. Keep proof levels distinct: @@ -529,8 +591,18 @@ out of the app and validated it: `ios-prepare-proof` automatically selects behavioral proof, then conducts one tool-authored question at a time for Iterable region when it is not already known, environment, identity type when it cannot be inferred, the app's -existing test identity, and the selected device. It reuses the installed app -for a standalone verification-only request and generates one targeted marker. +existing test identity, and the selected device. When the workspace knows a +build of the app — one an earlier run recorded, or a product under the app's +own `-derivedDataPath` — that carries the Iterable SDK, it asks which copy the +run tests and recommends binding that exact `.app` unless the installed copy +was built later; the installed app stays an option. Otherwise it reuses the +installed app and generates one targeted marker. On a Simulator the identity +screen offers an identity only when Iterable holds this Simulator's current +APNs token on that user; candidates come from the integration's `ITBL_PROOF` +marker (read after relaunching the app with `-iterableProofIdentity YES`), the +SDK's own "No user exists with userId|email" log lines, and +`--identity-candidate`. Otherwise the screen says where to read the identity +and the developer types it. For a new integration completed in the current flow, it also requires the exact built `.app`, so installation and proof are linked to that artifact. Its confirmation runs through the preparation actor; do not reconstruct the diff --git a/iterable-react-native/PITFALLS.md b/iterable-react-native/PITFALLS.md index 70eedb4..c66d38b 100644 --- a/iterable-react-native/PITFALLS.md +++ b/iterable-react-native/PITFALLS.md @@ -9,9 +9,10 @@ lives here and is loaded on demand. > (what's actually happening), **Fix** (what to do instead). Skim them when > the user reports any unexplained failure. -Sourced from `@iterable/react-native-sdk` **3.1.0** master: public exports in +Sourced from `@iterable/react-native-sdk` **3.2.1** (first written against +3.1.0): public exports in `src/index.tsx`, the TurboModule spec in `src/api/NativeRNIterableAPI.ts`, -CHANGELOG 3.1.0, the README native-version table, and the working sample in +CHANGELOG 3.1.0–3.2.1, the README native-version table, and the working sample in `example/` (`example/src/hooks/useIterableApp.tsx`, `example/src/components/App/App.tsx`). Expo items also come from `@iterable/expo-plugin` 1.1.0 (`plugin/src/withIterable.ts`) and @@ -25,7 +26,11 @@ iOS (bare **and** Expo): Device Hub launch and resource-bundle compile floors — not an Iterable API change. **#15** is native Android and applies to **both** workflows, but only when the app already sends push. #3's *symptom* is both workflows; its **bare** native fix (manifest + -`PermissionsAndroid`) is wrong on Expo — branch first. +`PermissionsAndroid`) is wrong on Expo — branch first. **#16** applies to every +app with deep links; **#17** is Expo only. **#18** is the install, both +workflows. **#19–#20** are the iOS Release Simulator build (bare, and Expo when +built with `xcodebuild`); **#21** is JavaScript, both workflows; **#22** is bare +iOS (on Expo, #14). --- @@ -85,7 +90,10 @@ to **both** workflows, but only when the app already sends push. #3's push through `@iterable/expo-plugin` and `reference/expo.md` (pitfalls #7–#11, #13–#14). Point `expo.android.googleServicesFile` at the developer-supplied - `google-services.json`. + `google-services.json`. The plugin declares `POST_NOTIFICATIONS` in the + manifest but never requests it, so the app still asks at runtime — + `PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS)` + from a user-facing screen, as in the bare step 2 below. - **Bare:** Copy the sample's two-part pattern, not Kotlin `ActivityResultContracts`: 1. Declare `` @@ -113,7 +121,7 @@ to **both** workflows, but only when the app already sends push. #3's | TurboModule spec (`NativeRNIterableAPI`) | `RNIterableAPI.getUnreadInboxMessagesCount`, `passAlongAuthToken` | No | | Native Android / iOS SDK | Kotlin `IterableApi.getInstance()`, Swift `IterableAPI`, `setDeviceAttribute` | Native *configuration* only (FCM, APNs, entitlements). Not JS. | - Concrete gaps still open on 3.1.0: + Concrete gaps still open on 3.2.1: - **SDK-594:** `getUnreadInboxMessagesCount` is on the TurboModule spec and implemented natively. `IterableInAppManager` exposes `getMessages` / @@ -139,10 +147,11 @@ to **both** workflows, but only when the app already sends push. #3's "latest Android SDK") into a React Native project, or tells the developer to bump `com.iterable:iterableapi` independently of `@iterable/react-native-sdk`. - **Cause:** Each RN release **pins** the native SDKs. They are not "whatever - the Android skill currently documents." On master, `android/build.gradle` - is `com.iterable:iterableapi:3.6.2`. The Android skill's pin may already - be **3.7.0**. APIs that exist only on a newer native SDK are not reachable - through this RN package. + the native skills currently document." At 3.2.1, `android/build.gradle` is + `com.iterable:iterableapi:3.10.1` and the podspec depends on + `Iterable-iOS-SDK` **6.7.5**. APIs that exist only on a newer native SDK are + not reachable through this RN package, and the native skills' pins can be + older or newer than the package's. - **Fix:** Use the RN package's version table (README "Version mapping"), not the Android skill. Confirm the installed pin in `node_modules/@iterable/react-native-sdk` (`android/build.gradle`, @@ -150,6 +159,7 @@ to **both** workflows, but only when the app already sends push. #3's | RN SDK | Android SDK | iOS SDK | |---|---|---| + | 3.2.1 / 3.2.0 | 3.10.1 | 6.7.5 | | 3.1.0 | 3.6.2 | 6.6.7 | | 3.0.x / 2.2.x | 3.6.2 | 6.6.3 | | 2.1.0 – 2.0.0 | 3.5.2 | 6.5.4 | @@ -246,7 +256,8 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. `reference/expo.md` → `## Requirements` is Expo SDK **55+**, React Native **0.83.2+**, React **19.2+**, Node **20.19.4+**, `@iterable/react-native-sdk` **3.0.0+** (this skill is validated against - **3.1.0**), `@iterable/expo-plugin` **1.1.0+**. Install with + **3.2.1**), `@iterable/expo-plugin` **1.1.0** (npm's 1.2.0 is deprecated as + published by accident). Install with `npx expo install @iterable/expo-plugin @iterable/react-native-sdk`. ## 11. Expo: fighting the plugin (New Architecture / `{fmt}`) @@ -265,12 +276,16 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. ## 12. Xcode 27: Device Hub launch and resource-bundle deployment targets +iOS only (bare **and** Expo); Android is unaffected. This is the single home of +the Xcode 27 guidance — the generated `reference/` corpus does not carry it. + - **Symptom (launch):** On Xcode 27, `npx react-native run-ios` or Expo - `run:ios` fails before or during simulator launch. `open -a Simulator`, - AppleScript `id of app "Simulator"`, or paths under + `run:ios` fails before or during simulator launch, often with a missing + `Simulator.app` / simulator launch error. `open -a Simulator`, AppleScript + `id of app "Simulator"`, or paths under `…/Xcode.app/Contents/Developer/Applications/Simulator.app` do not work. - Only `xcode-select` to the active Xcode is not enough when both Xcode 16 - and 27 are installed but the CLI still targets the old Simulator app. + Pointing `xcode-select` at Xcode 27 is not enough when the CLI still targets + the old Simulator app. - **Cause (launch):** Xcode 27 ships the simulator UI as **Device Hub** (`DeviceHub.app`, bundle id `com.apple.dt.Devices`), not `Developer/Applications/Simulator.app`. Older `@react-native-community/cli` @@ -280,42 +295,61 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. `@react-native-community/cli-platform-ios`). Iterable's RN SDK **example** app may still pin **20.1.0** until its repo updates — consumer apps need **20.2.0+** for Xcode 27. See [cli#2806](https://github.com/react-native-community/cli/pull/2806). - - **Expo:** Prefer **Expo SDK 58** (CLI that knows Device Hub). Apps still - on SDK **55** may fail at launch on Xcode 27; short-term: - `npx expo@latest run:ios` or upgrade the Expo SDK — do not hand-edit + - **Expo:** Use **Expo SDK 58**, whose CLI knows Device Hub. Apps still on + SDK **55** may fail at launch on Xcode 27; short-term, + `npx expo@latest run:ios`, or upgrade the Expo SDK — do not hand-edit `ios/` for this (pitfall #8). - - Run `xcode-select -p` and confirm it points at the Xcode you build with. + - Run `xcode-select -p` and confirm it points at the Xcode you build with + when several are installed. - **Symptom (build):** `xcodebuild` fails on Xcode 27 with deployment-target - errors on CocoaPods **resource bundle** targets (for example - `Iterable-iOS-SDK-IterableSDKResources` at **12.0** from - `Iterable-iOS-SDK`, or `*_resources` pods such as async-storage at - **13.4**). Message indicates `IPHONEOS_DEPLOYMENT_TARGET` is outside the - range the iOS 27 SDK allows (**15.0** through the current SDK version). -- **Cause (build):** The Xcode 27 iOS SDK rejects compile settings below - **15.0** on **every** target, including generated Pods resource bundles. - Podspecs can still declare older mins; `pod install` regenerates those - values. This is a **host compile-time** workaround — not a requirement to - bump Iterable's **published** iOS SDK minimum or async-storage's declared - platform in this skill ticket. -- **Fix (build):** Do **not** hand-edit files under `Pods/`. Do **not** treat - raising `Iterable-iOS-SDK` in the Podfile as the primary Expo fix. - - **Bare:** In the host `Podfile` `post_install`, walk - `installer.pods_project.targets` and, for each configuration, if - `IPHONEOS_DEPLOYMENT_TARGET` is unset or below **15.1**, set it to at - least **15.1** (and not below the app target's chosen deployment - target). **15.0** is the minimum the Xcode 27 iOS SDK accepts; **15.1** - is what Iterable's bare RN demo ships so a target at exactly **15.0** - does not slip through. Keep any existing `{fmt}` C++17 workaround - (pitfall #11) — this lift is **separate**. Re-run `pod install`. - - **Expo (CNG):** Do not commit or edit `ios/Podfile` for this. Apply the - same Pods-target lift to at least **15.1** when unset or below **15.1** - via an **Expo config plugin** at `prebuild` (or another supported mod), - not by patching `Pods/` after the fact. - `{fmt}` remains `@iterable/expo-plugin` **1.1.0+** `withIosFmtWorkaround` - (pitfall #11). - - See `reference/installing.md` → `### Xcode 27` and `reference/expo.md` - → `### Xcode 27`. + errors on CocoaPods **resource bundle** targets — for example + `Iterable-iOS-SDK-IterableSDKResources` at **12.0**, or `*_resources` pods + such as async-storage at **13.4**. The message says + `IPHONEOS_DEPLOYMENT_TARGET` is outside the range the iOS 27 SDK allows + (**15.0** through the current SDK version). +- **Cause (build):** The Xcode 27 iOS SDK rejects a deployment target below + **15.0** on **every** target, including generated Pods resource bundles, and + `pod install` regenerates those values from each podspec. `Iterable-iOS-SDK` + **6.7.5** — what `@iterable/react-native-sdk` **3.2.1** pins — still declares + iOS **12.0** in its podspec, and its resource bundle inherits that. + React Native **0.88**'s `react_native_post_install` lifts every pod's own + target to at least **15.1** (`updateOSDeploymentTarget`), but not the pods' + resource-bundle targets, so the bundle stays at 12.0 — the RN SDK 3.2.1 + README says the same. This is a host + compile-time workaround, not a reason to change Iterable's published iOS + minimum. +- **Fix (build):** Do **not** hand-edit files under `Pods/`, and don't raise + `Iterable-iOS-SDK` in the Podfile as the fix. + - **Bare:** in the host `Podfile`, after `react_native_post_install`, raise + every Pods target — resource bundles included — that is unset or below + **15.1**, then re-run `pod install`. **15.0** is the Xcode 27 floor; + **15.1** matches React Native's own minimum and Iterable's bare RN demo, so + nothing is left at exactly 15.0: + + ```ruby + post_install do |installer| + react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |build_config| + current = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1' if current.nil? || current.to_f < 15.1 + end + end + end + ``` + + Keep any existing `{fmt}` C++17 workaround (pitfall #11); this lift is + separate. + - **Expo SDK 58:** a fresh `prebuild` left no Pods target below 15.0 in our + trial, so no lift is needed. Check rather than assume: + `grep -h IPHONEOS_DEPLOYMENT_TARGET ios/Pods/Pods.xcodeproj/project.pbxproj | sort -u`. + - **Older Expo SDKs:** if that check shows a target below 15.0, lift it in an + **Expo config plugin** at `prebuild` — never by committing `ios/Podfile` or + patching `Pods/` in a continuous-native-generation workflow. The `{fmt}` fix + stays with `@iterable/expo-plugin` **1.1.0+** (`withIosFmtWorkaround`, + pitfall #11); the resource-bundle lift is a host concern until Iterable + ships a first-party plugin option. ## 13. Expo: EAS signing for `IterableExpoRichPush` @@ -339,10 +373,16 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. `requestPermissionsForPushNotifications` to **`false`**. Bundled `reference/expo.md` (and the upstream article) currently list the default as `true`. That table is wrong. The option is **iOS-only**. -- **Fix:** Trust the plugin, not the table. Set the option **explicitly** - if they want iOS permission prompting. Do not hand-edit - `reference/expo.md` to "fix" the table — the next docs refresh will - overwrite it. Report the drift against `Iterable/iterable-docs`. +- **Fix:** Trust the plugin, not the table. Nothing prompts unless the app + asks. Set `"requestPermissionsForPushNotifications": true` in the plugin + options whenever push is in scope — the recommended setting; the plugin's app + delegate then asks at launch until authorized. Don't add a permission package + for it. If the app already uses `expo-notifications`, it makes itself the + notification-center delegate and chains the one set before it; confirm + Iterable push taps still track afterwards. Android 13+ is separate + (pitfall #3). Do not hand-edit `reference/expo.md` to "fix" the table — the + next docs refresh will overwrite it. Report the drift against + `Iterable/iterable-docs`. --- @@ -395,3 +435,105 @@ Pitfall 15 applies to **both** workflows — any app that already sends push. looks exactly like "the Iterable change broke our push." Check the grant before accepting that diagnosis, and don't accept a culprit inside your own diff without evidence. + +## 16. A link bounces between the SDK and the app + +- **Symptom:** Tapping a link reopens the app or re-runs the same navigation + over and over — sometimes only offline. +- **Cause:** With a JS `urlHandler` set, the native side reports every URL as + handled and passes it to JavaScript (`handleIterableURL` returns `true` on + Android; the iOS bridge does the same). If the handler then returns anything + falsy, the SDK's `callUrlHandler` calls `Linking.openURL(url)`. A URL on the + app's own scheme or tracking domain comes back through the app's `Linking` + listener, which hands it to `Iterable.handleAppLink`, and round it goes. A + tracking-link redirect that fails hands the handler the original `/a/` URL, + so it loops offline too. On both platforms `handleAppLink`'s native check + only looks for `/a/` followed by letters or digits anywhere in the URL, so a + custom-scheme URL such as `myapp://shop/a/42` is treated as a tracking link. +- **Fix:** Make `urlHandler` return `true` for every URL on your own scheme or + domains, the tracking domain included, sending unknown paths to a fallback + screen; return `false` only for other apps' URLs. Pass a URL to + `handleAppLink` only when it is `https`, on your tracking domain, with an + `/a/` path (`isIterableTrackingLink` in `SKILL.md`), and register the + `Linking` listener only after `await Iterable.initialize(...)`. + +## 17. Expo: the plugin does not configure deep links + +- **Symptom:** Universal links and App Links open the browser, never the app, + even though the Expo plugin is installed. +- **Cause:** `reference/expo.md` says the plugin "sets up Universal Links and + configures associated domains" and "configures App Links and sets up intent + filters". In 1.1.0 its deep-link step (`plugin/src/withDeepLinks.ts`) only + sets the main activity's `android:launchMode` to `singleTask`. The article's + own examples don't help: `applinks:expo.dev` and `applinks:iterable.com` are + domains the app can't claim, and its intent-filter JSON contains a `//` + comment and a trailing comma. +- **Fix:** Add `expo.ios.associatedDomains` with `applinks:` and an `expo.android.intentFilters` entry for `https`, your tracking + domain, and `pathPrefix` `/a/` with `autoVerify: true` (the JSON in + `SKILL.md` → Deep links), then re-run prebuild. + +## 18. `npm install` returns 401 for `@iterable/*` + +- **Symptom:** `npm install @iterable/react-native-sdk` (or + `npx expo install @iterable/expo-plugin`) fails with `E401 Unauthorized` + against `npm.pkg.github.com` or another private registry, while every other + package installs. +- **Cause:** A user-level `~/.npmrc` maps the `@iterable` scope to another + registry (`@iterable:registry=https://npm.pkg.github.com/`, common on + machines that also build Iterable's own packages). A scoped mapping beats + `--registry`, so passing the public registry on the command line doesn't help. +- **Fix:** Confirm the mapping with `npm config get @iterable:registry`. Never + `cat` `~/.npmrc` or any other credential file to look — it holds the token, + and printing it puts the token in the transcript. Add a project `.npmrc` with + `@iterable:registry=https://registry.npmjs.org/`; the project file overrides + the user one. The public packages need no token. Don't edit the developer's + `~/.npmrc`. + +## 19. iOS: a stale `.app` installed for proof + +- **Symptom:** The Simulator runs an app without the latest change — no + permission prompt, an old bundle — and the verifier judges a build nobody + meant to test. +- **Cause:** `xcodebuild` without `-derivedDataPath` builds into + `~/Library/Developer/Xcode/DerivedData`; a `.app` then picked with `find` + (say, from an old `ios/build`) is whatever was built there last. +- **Fix:** Build with the exact command in `SKILL.md` → Bare iOS + (`-derivedDataPath build`), install + `ios/build/Build/Products/Release-iphonesimulator/.app`, and pass that + path to `ios-prepare-proof start --app-path`. + +## 20. iOS: code signing disabled strips `aps-environment` + +- **Symptom:** The Release Simulator build succeeds, the app runs, and no APNs + token ever registers. +- **Cause:** `CODE_SIGNING_ALLOWED=NO` (with `CODE_SIGNING_REQUIRED=NO`, + `CODE_SIGN_IDENTITY=""`) skips signing, and a Simulator build gets its + entitlements in that step — so the app has no `aps-environment`. A + `CODE_SIGN_ENTITLEMENTS=` passed to one `xcodebuild` is lost on the next build. +- **Fix:** Never pass those flags; the Simulator build signs ad hoc without a + team. Add the entitlements file to the target's `CODE_SIGN_ENTITLEMENTS` build + setting in the project. A Simulator app carries them in its executable, not + its signature, so `codesign -d --entitlements` shows nothing; check with + `otool -s __TEXT __entitlements .app/` — an empty result means none. + +## 21. `Iterable.registerForPush()` does not exist + +- **Symptom:** A `registerForPush is not a function` TypeError, or a + TypeScript error, after the agent added a call to "register for push". +- **Cause:** The JS facade has no push-registration call. Registration is + native: with `autoPushRegistration` (default `true`) the SDK asks for the + token after `Iterable.setEmail` / `setUserId`. +- **Fix:** Delete the call. Identify the user after `Iterable.initialize`; on + bare iOS, forward the token in `AppDelegate` (`IterableAPI.register(token:)`). + +## 22. Bare iOS: nothing asks for notification permission + +- **Symptom:** The token registers and Iterable sends, but nothing shows on + iOS and the app never asked to send notifications. +- **Cause:** Neither the SDK nor the 0.88 template requests authorization; + registering for a token doesn't. +- **Fix:** Request it at launch in `AppDelegate`'s + `didFinishLaunchingWithOptions`: + `UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }` + (the `SKILL.md` → Bare iOS snippet). No permission package. Expo: pitfall #14. diff --git a/iterable-react-native/SKILL.md b/iterable-react-native/SKILL.md index e1745ff..87eab8c 100644 --- a/iterable-react-native/SKILL.md +++ b/iterable-react-native/SKILL.md @@ -45,6 +45,10 @@ entitlements, `POST_NOTIFICATIONS`) is still part of a React Native integration — it lives in this skill's own `reference/` docs, not in a separate skill the developer must ask for. +**Read and search the project with your host's file tools** (in Claude Code, +Read, Grep and Glob), not shell `cat`, `grep` or `find`: each shell command can +cost the developer a permission prompt. + --- ## Step 0 — Agree on scope BEFORE writing code @@ -69,10 +73,23 @@ multi-select question tool (in Claude Code, `AskUserQuestion` with use it and offer **at most 4 options**; otherwise ask in plain text: - Push notifications (FCM / APNs) -- Event tracking + user identity +- Event tracking + user profiles (identifying the user is always included) - Deep links: iOS universal links, Android App Links, and custom URL schemes - Other (inbox, embedded) — describe in the option +Each confirmed answer maps to one entry in `iterable-provision`'s feature ids, +which the iOS phase takes as `--features` (comma-separated): + +| Step 0 answer | `--features` ids | +|---|---| +| Push notifications | `push-notifications` | +| Event tracking + user profiles | `event-tracking,user-profiles` | +| Deep links | `deep-linking` | +| Other: Mobile Inbox | `mobile-inbox` | +| Other: Embedded Messages | `embedded-messaging` | +| Other: custom in-app handling | `in-app-messages` | +| None of these (initialize and identify only) | `sdk-initialization` | + **In-app messages are absent from that list on purpose.** By default the SDK displays every in-app message it receives and tracks the interactions (`in-app-messages`), so an initialized, identified app has them whether or not @@ -84,17 +101,105 @@ rendering) is scope; having it is not. **First check whether this is an upgrade, not a new integration.** If the project already depends on `@iterable/react-native-sdk` (and, on Expo, `@iterable/expo-plugin`), ask which version they're on and what they want -out of the upgrade, then follow the upgrade row in the slug table. +out of the upgrade, then follow the upgrade row in the +[decisions table](#decisions-read-before-recommending). **And check whether the app already sends push without Iterable.** Look for `@react-native-firebase/messaging` or another push SDK in `package.json`, and for `FirebaseMessagingService` / `com.google.firebase.MESSAGING_EVENT` under -`android/`. If any is present, that channel is an integration constraint rather -than something to replace — rule 8 has the shape. Say what you found and confirm -the plan before touching it. +`android/`. If any is present, say what you found and ask: "Keep your app's own +notifications working alongside Iterable's, or use Iterable notifications +only?" **Keep both:** forward only Iterable payloads (rule 8). **Iterable +only:** propose, in the one diff they confirm, how the old provider stops +receiving or showing pushes. Don't delete the provider's code unless they +confirm that diff. Confirm the scope, *then* run Preflight, *then* build. +### Identity: project type first + +Ask which kind of Iterable project the app sends to, per +`project-types-and-unique-identifiers`: + +- **Hybrid**: `email` and `userId` are both unique identifiers. +- **UserID-based**: `userId` only. +- **Email-based**: `email` only; limited availability (some older US-hosted + organizations). +- **I don't have a project yet / I don't know — help me.** + +For an existing project, also ask which identifier their backend, CRM, or other +SDKs already send to Iterable, and reuse exactly that value: another value is +another user. Decide from what a signed-in session holds, not what sign-up asks +for: an app that asks for an email only at sign-up has none to pass later. +**Help me** means inspect, then recommend: what identifier exists after *every* +sign-in path, a session restored at launch included, and what the app's other +SDKs already identify the user with. Recommend a project type and identity mode +from that, citing the docs in the table below. With no project yet, guide +creating one and warn that its type can't be changed later. + +A `userId` is the app's own stable internal account ID when it meets +`user-id-validation-in-iterable` → `### User ID best practices`, otherwise a +random UUID created once per account and stored with it (kept only on the +device, a reinstall makes a new user). Never an email, phone number, username, +or handle, nothing built from them, and nothing sequential. That doc's +128-character limit supersedes `identifying-the-user`'s "52 characters" and its +"email recommended"; that doc's snippets are native, so the JS calls are in +`managing-user-identity`. + +### Decisions: read before recommending + +Before recommending an option for a decision below, open the named section and +cite it in the option ("per `user-id-validation-in-iterable`"). If no shipped +doc covers a decision, offer no recommendation and say so. Slugs are +`reference/.md`; a `../iterable-*/reference/` path is the sibling skill's +doc for a native step. + +| Decision | Read | +|---|---| +| New integration or upgrade | `installing` → `## Upgrading the SDK`; `migrating` | +| Project type | `project-types-and-unique-identifiers` → `### Choosing a project type`, `#### Project type availability` | +| Identity mode, and which app value supplies it | `project-types-and-unique-identifiers` → `### Comparing project types`; `user-id-validation-in-iterable` → `### User ID best practices`; `managing-user-identity` | +| An existing client's identifier | `creating-users-with-apis` → `## Understanding user identification in API requests`; `updating-a-users-email-address` | +| Unknown users | `handling-anonymous-users`. No React Native doc covers Unknown User Activation: don't recommend it | +| Data region | `installing` → `### Step 6`; `european-data-center` → `## Signing in to Iterable: region-specific URLs` | +| Key type | `api-keys` → `### Selecting a key type`, `#### Mobile` | +| JWT | `api-keys` → `#### JWT authentication`; `jwt-enabled-api-keys`; `authentication` | +| Event creation | `api-keys` → `#### Event Creation` | +| FCM type | `../iterable-android/reference/setting-up-android-push-notifications` → `#### Step 3.3` | +| Push integration name | `installing` → `#### Step 4.2`, `### Step 6` | +| APNs auth, environment, Bundle ID | `installing` → `#### Step 3.5`; `../iterable-ios/reference/setting-up-ios-push-notifications` → `### Step 3`, `### Step 4` | +| Notification permission | `../iterable-ios/reference/ios-sdk` → `#### Step 7.6`; `../iterable-android/reference/setting-up-android-push-notifications` → `## Android 13: Push notification permissions`; Expo: `expo` | +| Existing push | `../iterable-android/reference/setting-up-android-push-notifications` → `#### Handling Firebase push messages and tokens` (Android; rule 8) | +| Deep links | `deep-links-and-custom-actions` | +| Profile fields | `user-profile-data-and-subscription-preferences`; `user-profile-fields-used-by-iterable` → `## Other reserved field names` | +| Events | `tracking-events` → `## Tracking custom events` | +| Logout | `managing-user-identity` → `## Signing out` | +| Test identity | `project-types-and-unique-identifiers` → `### Checking the unique identifier for a project`; `installing` → `### Step 7` | + +### Two platforms: Android first, then iOS + +When the scope covers both platforms, finish one before starting the other: + +1. **Android:** provision with `iterable-provision`, integrate, and prove it — + `iterable-android-verify` green — before any iOS work starts. +2. **iOS:** `iterable-provision`'s iOS path offers to reuse what the Android + phase recorded — both Iterable keys (Mobile and Server-side), the region, + No JWT, and the test email. Pass the scope confirmed in Step 0 as + `--features ` from the table above, first to `ios-provision status`, + so the import screen names it, and its recommended option carries it into + `import-from-android`. The final scope screen shows it again before + anything is confirmed. Pass only what the developer confirmed; without + `--features` the scope is asked again. The path then adds the Apple/APNs + prerequisites. Then write the iOS native push code + ([Bare iOS](#bare-ios-native-push-code)) and prove it with + `iterable-ios-verify`. + +An app is integrated when the verifier passes for **every platform in scope**; +one green platform is half an integration. + +**After any rebuild, re-run that platform's verifier before asking the developer +to act again.** Its last answer was about the build you replaced. + --- ## Definition of done — finish the agreed scope, don't stub it @@ -109,7 +214,10 @@ Within the agreed scope, an integration is finished only when: UUID-generation/persistence code**. - [ ] Native configuration required by the agreed scope is done for the workflow you detected (Expo plugin vs bare `ios/` + `android/`), including - push permission on Android 13+ when push is in scope. + push permission on Android 13+ and on iOS when push is in scope. +- [ ] Each platform in scope passed its verifier: `iterable-android-verify`, + then `iterable-ios-verify` against a **Release** Simulator build (a Debug + build loads JavaScript from Metro and shows a red box without it). Only genuinely developer-supplied inputs (API key, identity model, region, JWT, `google-services.json` / APNs key) are legitimate pauses. @@ -139,12 +247,12 @@ everything". | Input | Needed when | If missing | |---|---|---| | **Workflow** — Expo vs bare | Always | Detect from the project (Step 0). If both signals exist, ask. | -| **Mobile API key** | Always | Ask where it lives; never hardcode into a tracked file. | -| **Identity model** — `setEmail` vs `setUserId`, and where the value comes from | Always | Ask. Never guess. | +| **Mobile API key** | Always | Never hardcode it into a tracked file. `iterable-provision` writes the copy the app reads: bare RN `.iterable/rn/iterable.local.ts`, Expo `EXPO_PUBLIC_ITERABLE_API_KEY` in a gitignored `.env.local` ([loading it](#loading-the-key)). | +| **Identity model** — `setEmail` vs `setUserId`, and where the value comes from | Always | [Project type first](#identity-project-type-first). Never guess. | | **JWT?** — is the mobile key JWT-protected? | Always | Ask, and **assume yes until told otherwise** (it is on by default for client-side keys and is permanent). If yes, `authHandler` is mandatory (rule 1) **and the team needs a backend endpoint that mints the tokens** — if they don't have one, raise it as a blocker instead of signing in the app. | -| **Data region** — US or EU | Always | Ask if their dashboard is `app.eu.iterable.com`. See pitfall #2. | +| **Data region** — US or EU | Always | Ask if they sign in at `app.eu.iterable.com` (`european-data-center`). See pitfall #2. | | **`google-services.json`** (Firebase) | Android push (bare **or** Expo) | **Route into `iterable-provision`**, which downloads the real one; if it isn't installed, STOP and ask. Either way you cannot generate it. Expo: path goes in `expo.android.googleServicesFile`, not a hand-edited `android/`. | -| **APNs key / capabilities** | Bare-workflow push on iOS | Ask. Follow `reference/push-notifications.md`. | +| **APNs key / capabilities** | Push on iOS | Route into `iterable-provision`'s iOS path once Android is proven. Native code: [Bare iOS](#bare-ios-native-push-code); Expo: the plugin. | | **Development build vs Expo Go** | Expo | Expo Go cannot run this SDK (pitfall #7). If they are in Expo Go, stop and move them to a development build before debugging JS. | **Never fabricate a prerequisite to make the build pass.** Do not invent an @@ -185,6 +293,8 @@ Once it reports `done`, **you own placing the file**: on bare RN copy `.iterable/artifacts/google-services.json` to `android/app/google-services.json`; on Expo put it where `expo.android.googleServicesFile` points, never by hand-editing `android/`. Show it as part of your one confirmed diff. +In a monorepo whose app sits below the repository root, provisioning does not +read the Android package name from the project yet; the developer types it. For the steps they do themselves, don't send them to support.iterable.com; open the doc that covers making it. @@ -224,10 +334,8 @@ when mirroring an existing production integration into a sandbox project, `fcm-http-v1-migration` says match the production app definition — there, the doc wins. -Two things these docs do **not** settle, so keep asking the developer: -**data region** (confirm it from their dashboard URL — `app.eu.iterable.com` -means EU; see pitfall #2) and **`google-services.json`**, which comes from their -Firebase Console and cannot be generated. +These docs do **not** settle `google-services.json`: it comes from the +developer's Firebase Console and cannot be generated. --- @@ -235,10 +343,16 @@ Firebase Console and cannot be generated. - **Latest version: always fetch it — never trust a number baked into this file.** Before writing a dependency line, resolve the current release from - npm (`@iterable/react-native-sdk`) or the SDK's - [CHANGELOG.md](https://github.com/Iterable/react-native-sdk/blob/master/CHANGELOG.md). - If the host project already pins a version, match it unless they ask to - upgrade. **Expo:** also resolve `@iterable/expo-plugin` from npm or its + npm with one call that a scoped private registry can't break: + `npm view @iterable/react-native-sdk version --@iterable:registry=https://registry.npmjs.org` + (or the SDK's + [CHANGELOG.md](https://github.com/Iterable/react-native-sdk/blob/master/CHANGELOG.md)). + If the network fails, use **3.2.1**, the version this skill's docs were + checked against, and say so. If the host project already pins a version, + match it unless they ask to upgrade. **Expo:** also resolve + `@iterable/expo-plugin` the same way + (`npm view @iterable/expo-plugin version --@iterable:registry=https://registry.npmjs.org`; + offline, **1.1.0**) or from its [CHANGELOG](https://github.com/Iterable/iterable-expo-plugin/blob/main/CHANGELOG.md). State **both** package versions and check they are compatible (pitfall #10). Do not guess Expo SDK / RN / plugin versions independently. @@ -259,12 +373,17 @@ Firebase Console and cannot be generated. Do not import `NativeRNIterableAPI`, `RNIterableAPI`, or the internal `IterableApi` class. - **Native Android/iOS SDK versions come from this RN package, not from the - Android skill.** `@iterable/react-native-sdk` **3.1.0** pins Android - **`3.6.2`** and iOS **`6.6.7`**. Do not call Kotlin/Swift APIs that exist - only on Android `3.7.0` (the Android skill's pin) or any newer native SDK - than this package ships. Confirm in the RN README version table or - `node_modules/@iterable/react-native-sdk` (`android/build.gradle`, podspec) - for the version they have (pitfall #5). + native skills.** `@iterable/react-native-sdk` **3.2.1** pins Android + **`3.10.1`** and iOS **`6.7.5`**; **3.1.0** pinned `3.6.2` / `6.6.7`. Use only + native APIs the installed package ships, whatever the native skills pin + (`iterable-android` is at 3.7.0, `iterable-ios` at 6.7.5). Confirm in the RN + README version table or `node_modules/@iterable/react-native-sdk` + (`android/build.gradle`, podspec) for the version they have (pitfall #5). +- **Skip 3.2.0.** Its Android module did not compile against its own native pin + (fixed in 3.2.1). 3.2.x needs React Native **0.77+** and React **18.3.1+**, + changes no JavaScript APIs, and turns off unknown-user activation on iOS, + which 3.1.0's native pin left on by default. On Expo, `@iterable/expo-plugin` stays at + **1.1.0**: npm's 1.2.0 is deprecated as published by accident. --- @@ -301,7 +420,11 @@ Expo). Rule 8 applies whenever the app already sends push of its own. This is native Android setup performed as part of the React Native integration — read `reference/push-notifications.md` (bare) or `reference/expo.md` (Expo). Do not skip it, and do not wait for the - developer to open the Android skill. + developer to open the Android skill. The bare 0.88 template's manifest + declares only `INTERNET`, and it doesn't apply the + `com.google.gms.google-services` Gradle plugin — so the app builds without + `google-services.json` and simply never gets an FCM token. A green build is + not proof. 4. **Only call the public JS facade in `src/index.tsx`.** Import from `@iterable/react-native-sdk` (`Iterable`, `IterableConfig`, in-app / inbox / @@ -310,32 +433,40 @@ Expo). Rule 8 applies whenever the app already sends push of its own. `IterableApi.getInstance()`, `IterableConfig.Builder()`, or Swift `IterableAPI` into JS. Device attributes and unread-inbox count exist natively but are not on that facade (pitfall #4, SDK-593, SDK-594). This - package at 3.1.0 ships Android `3.6.2` / iOS `6.6.7` — do not pull Android - `3.7.0` APIs from the Android skill (pitfall #5). + package at 3.2.1 ships Android `3.10.1` / iOS `6.7.5` — native APIs newer + than those are not reachable (pitfall #5). 5. **Never hardcode the API key into a tracked file.** Read it from a gitignored env file or the project's existing secrets pattern. An empty fallback is the only acceptable default. -6. **Never assume how the app identifies a user — ask.** Do not wire - identity to the first email-shaped field you find. +6. **Never assume how the app identifies a user — ask** the project type + first ([Step 0](#identity-project-type-first)). Do not wire identity to the + first email-shaped field you find. 7. **Expo: both packages, plugin owns native configuration.** Install with - `npx expo install @iterable/expo-plugin @iterable/react-native-sdk`. - Never treat Expo Go as a valid runtime (pitfall #7). Do not hand-edit + `npx expo install @iterable/expo-plugin@ @iterable/react-native-sdk@` + ([Quick facts](#quick-facts-always-relevant)). Never treat Expo Go as a valid runtime (pitfall #7). Do not hand-edit `ios/` or `android/` as the primary path (`prebuild --clean` wipes them — pitfall #8). If they already configure native code by hand, do **not** add the plugin (pitfall #9). When stating versions, give both packages - (pitfall #10). Trust plugin source over the support-doc options table - (`requestPermissionsForPushNotifications` defaults to `false` — pitfall - #14). On Xcode **27**, use CLI **20.2.0+** (bare) or Expo SDK **58** - for Device Hub launch, and host Podfile / config-plugin fixes for - resource-bundle deployment targets (pitfall **#12**) — not hand-edited + (pitfall #10). Trust plugin source over the support-doc options table: + `requestPermissionsForPushNotifications` defaults to `false`, so nothing + prompts — set it to `true` when push is in scope (pitfall #14). Run a + development build (`expo-dev-client`): Expo Go cannot load the + `RNIterableAPI` native module. `ios/` and `android/` are + gitignored and regenerated, so a config or plugin change needs a fresh + prebuild. On Xcode **27**, use CLI **20.2.0+** (bare) or Expo SDK **58** + for Device Hub launch; bare apps also need a `Podfile` lift of Pods + resource-bundle deployment targets, while an Expo SDK 58 prebuild needed none + in our trial — check, don't assume (pitfall **#12**). Never hand-edit `Pods/`. 8. **If the app already sends push, forward to Iterable — never replace what's - there.** Check before scoping push work: grep `android/` for - `FirebaseMessagingService` and `com.google.firebase.MESSAGING_EVENT`, and + there.** The one exception is a Step 0 "Iterable only" answer, and only in + the diff the developer confirmed. Check before scoping push work: grep + `android/` for `FirebaseMessagingService` and + `com.google.firebase.MESSAGING_EVENT`, and `package.json` for `@react-native-firebase/messaging`, `react-native-onesignal`, or another push SDK. RN apps commonly already have a transactional channel, and it usually matters more to the business than the marketing push being @@ -352,6 +483,15 @@ Expo). Rule 8 applies whenever the app already sends push of its own. manifest rather than reasoning it out. iOS coexistence is a different mechanism — don't assume the Android shape transfers. +9. **Deep links: `urlHandler` answers `true` for every URL the app owns, and + only tracking links reach `Iterable.handleAppLink`.** A falsy return makes + the SDK's JavaScript call `Linking.openURL(url)`; for a URL on your own + scheme or tracking domain that comes straight back through your `Linking` + listener — a loop (pitfall #16). Pass a URL to `handleAppLink` only when it + is `https`, on your tracking domain, with an `/a/` path, and wire `Linking` + only after `await Iterable.initialize(...)`. On Expo the plugin does **not** + configure deep links (pitfall #17). See [Deep links](#deep-links). + --- ## Canonical minimum integration (start here) @@ -360,33 +500,245 @@ Adapt this; don't bolt the pieces together from scratch. Read `reference/installing.md` (bare) or `reference/expo.md` (Expo) before writing native project files. -**Expo — install both packages first** (then the JS below): +### Install — Android first + +With the versions resolved in [Quick facts](#quick-facts-always-relevant): + +**Bare:** + +```bash +npm install @iterable/react-native-sdk@ +``` + +**Expo** — through `npx expo install`, which the default Expo template's `AGENTS.md` +also requires: ```bash -npx expo install @iterable/expo-plugin @iterable/react-native-sdk +npx expo install @iterable/expo-plugin@ @iterable/react-native-sdk@ ``` -Add `["@iterable/expo-plugin", {}]` to `expo.plugins` in `app.json` / -`app.config.*`. Do not skip this, and do not treat the JS snippet as the -whole Expo integration. +- **`E401` from `npm.pkg.github.com`:** the developer's npm config maps the + `@iterable` scope to a private registry, and `--registry=` doesn't override a + scope. Check with `npm config get @iterable:registry` — **never print + `~/.npmrc` or any other credential file**, it holds their token — then add + pitfall #18's project `.npmrc` and install again. +- **Android first.** After the install, work only in JavaScript and `android/`: + no `pod install`, no edit under `ios/`, no `AppDelegate` change until + `iterable-android-verify` is green. +- **New Architecture: leave it on, there is nothing to configure.** React Native + 0.82+ runs only the New Architecture: its CocoaPods scripts treat it as always + enabled and warn on `ENV['RCT_NEW_ARCH_ENABLED'] = '0'`, and its Gradle plugin + warns on `newArchEnabled=false`, then both run it anyway. SDK 3.2.1 is a + codegen TurboModule (`RNIterableAPISpec`) and builds its New Architecture path + under 0.88. So skip `reference/installing.md` → `## Configuring React Native's + New Architecture`, and keep the template's `newArchEnabled=true` in + `android/gradle.properties`: the SDK's Android module compiles its New + Architecture sources only when that property reads `true`. + +On Expo, add the plugin to `expo.plugins` in `app.json` / `app.config.*` — +with push in scope, as +`["@iterable/expo-plugin", { "requestPermissionsForPushNotifications": true }]` +(pitfall #14); otherwise `["@iterable/expo-plugin", {}]`. Do not skip this, and +do not treat the JS snippet as the whole Expo integration. + +### Initialize and identify ```javascript +import { Linking } from 'react-native'; import { Iterable, IterableConfig, IterableDataRegion } from '@iterable/react-native-sdk'; -export async function initializeIterable(apiKey, { userId, dataRegion } = {}) { +// The Iterable link-tracking domain: Settings > Domains in Iterable. +const LINK_TRACKING_DOMAIN = 'links.example.com'; + +export function isIterableTrackingLink(url) { + const match = /^https:\/\/([^/?#]+)(\/[^?#]*)?/.exec(url ?? ''); + return !!match && match[1] === LINK_TRACKING_DOMAIN && /^\/a\/[A-Za-z0-9]+/.test(match[2] ?? ''); +} + +// routeUrl(url) is your app's router: true for every URL on your own scheme or +// domains (unknown paths go to a fallback screen), false only for other apps' URLs. +export async function initializeIterable(apiKey, { userId, routeUrl }) { const config = new IterableConfig(); // config.dataRegion = IterableDataRegion.EU; // EU projects (pitfall #2) // config.authHandler = () => fetchJwtForCurrentUser(); // JWT keys (pitfall #1) + config.urlHandler = (url, context) => routeUrl(url); // rule 9 await Iterable.initialize(apiKey, config); // native init returned, not in-app ready (pitfall #6) if (userId) { Iterable.setUserId(userId); // or setEmail(...) — pick ONE (rule 6) } + + // After initialize: on Android, handleAppLink before init returns false and the link is lost. + const open = (url) => { + if (!url) return; + if (isIterableTrackingLink(url)) Iterable.handleAppLink(url); + else routeUrl(url); + }; + open(await Linking.getInitialURL()); + const subscription = Linking.addEventListener('url', ({ url }) => open(url)); + return () => subscription.remove(); } ``` **You are not done until `initialize` is actually called** from the host app's startup path. +### Loading the key + +The key file is gitignored, so a fresh clone or a CI build has no key; the app +must still build and simply skip `initialize`. + +```javascript +// Bare React Native: an optional require, which Metro tolerates when the file +// is missing. A static import breaks every build without the file. +export function readIterableApiKey() { + try { + return require('../.iterable/rn/iterable.local').ITERABLE_API_KEY ?? ''; + } catch { + return ''; + } +} +``` + +```javascript +// Expo: inlined from .env.local at build time; write it exactly like this +// (dot notation), or Expo doesn't inline it. +export const ITERABLE_API_KEY = process.env.EXPO_PUBLIC_ITERABLE_API_KEY ?? ''; +``` + +Adjust the relative path to `.iterable/` from the importing file. On Expo, +`@iterable/expo-plugin` 1.1.0 has **no** `apiKey` option — one in `app.json` is +ignored — and a changed `.env.local` needs a restarted bundler or a rebuild. + +### Bare iOS: native push code + +`Iterable.initialize` sets up the native SDK, but the APNs callbacks are native +and belong in the app. The 0.88 template is scene-based: `AppDelegate.swift` +keeps the push callbacks, `SceneDelegate.swift` keeps the URL handling. + +```swift +// AppDelegate.swift — add to the template's class. +import IterableSDK +import UIKit +import UserNotifications + +@main +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + IterableAPI.register(token: deviceToken) + } + + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + IterableAppIntegration.application(application, + didReceiveRemoteNotification: userInfo, + fetchCompletionHandler: completionHandler) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .list, .sound, .badge]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + IterableAppIntegration.userNotificationCenter(center, + didReceive: response, + withCompletionHandler: completionHandler) + } +} +``` + +In Objective-C the calls are `[IterableAPI registerToken:deviceToken]`, +`[IterableAppIntegration application:didReceiveRemoteNotification:fetchCompletionHandler:]`, +and `[IterableAppIntegration userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:]`. + +- **Permission is asked at launch, natively** — the `requestAuthorization` line + above; neither the SDK nor the template asks (pitfall #22). Don't add a + permission package for it. +- **The app already has iOS push?** If the developer keeps it (Step 0), forward + from its callbacks as `iterable-ios` rule 8 says. `ios-verify` then lists + `existing-push-coexistence` in `verification_limitations`. After Iterable's + proof passes, offer the optional check that the app's own notifications still + show, and run it only if they ask. It never blocks the integration. +- **Token registration is the SDK's, after identity.** There is no + `Iterable.registerForPush()` or any other JavaScript registration call + (pitfall #21). With the default `autoPushRegistration`, the SDK asks for the + token after `Iterable.setEmail` / `setUserId`, and `register(token:)` ignores + any token that arrives before an identity. +- **Project:** the `aps-environment` entitlement (Push Notifications + capability), `UIBackgroundModes` → `remote-notification` (silent push, which + also delivers in-app messages), and, for images or buttons, a Notification + Service Extension target with the `Iterable-iOS-AppExtensions` pod. The + `iterable-ios` skill has the exact files (canonical step 1, Push + notifications). Add the entitlements file to the app target's + `CODE_SIGN_ENTITLEMENTS` build setting, for Debug and Release, in the + project — not as a `CODE_SIGN_ENTITLEMENTS=` argument to one `xcodebuild`. +- **Universal links:** keep the template's `SceneDelegate` forwarding + `scene(_:continue:)` and `scene(_:openURLContexts:)` to `RCTLinkingManager`, + and passing `connectionOptions` to `startReactNative` — that is how a link + that launched the app reaches `Linking.getInitialURL()`. The SDK example's + `AppDelegate` `application(_:continue:restorationHandler:)` never fires in a + scene-based app; React Native ignores the app-delegate linking methods once + `UIApplicationSceneManifest` is declared. +- **Proof artifact:** the Release configuration for the Simulator. It embeds + `main.jsbundle`; Debug loads JavaScript from Metro and shows a red box + without it. Build it with exactly this, from the app root: + + ```bash + cd ios && xcodebuild -workspace .xcworkspace -scheme -configuration Release -sdk iphonesimulator -derivedDataPath build build + ``` + + The app is `ios/build/Build/Products/Release-iphonesimulator/.app`. + Install that path with `xcrun simctl install `, and record it + with `/bin/ios-prepare-proof start --confirm --app-path `, or + `restart --confirm --app-path ` when a run already exists. Never install a `.app` found with `find` or from another DerivedData + folder — it can be a stale build (pitfall #19). Never add + `CODE_SIGNING_ALLOWED=NO`, `CODE_SIGNING_REQUIRED=NO` or `CODE_SIGN_IDENTITY=""`: + an unsigned app carries no `aps-environment` and never gets a token + (pitfall #20). +- **Proof identity marker (temporary):** when push is in scope, add this to + `AppDelegate.swift` and call it as the last line of + `application(_:didFinishLaunchingWithOptions:)`, before `return true`. At + proof time the verifier relaunches the app with `-iterableProofIdentity YES` + and reads the identity from the Simulator's log, as in `iterable-ios` + (*The proof identity marker*). It reads the native SDK, not JavaScript: the + identity is stored natively once `Iterable.initialize` and `setEmail` / + `setUserId` have run, and the app delegate starts before the JavaScript + bundle does, so it checks once a second until the SDK holds an identity. The + launch argument gates it, because the proved build is Release. + + ```swift + // AppDelegate.swift — temporary: remove once the push is proved. + import os + + private func logIterableProofIdentity(attempt: Int = 0) { + guard UserDefaults.standard.bool(forKey: "iterableProofIdentity") else { return } + if let identity = IterableAPI.userId ?? IterableAPI.email { + os_log("ITBL_PROOF identity=%{public}@", identity) + } else if attempt < 30 { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.logIterableProofIdentity(attempt: attempt + 1) + } + } + } + ``` + + Remove it once the push is proved, as the last step of the handoff; + `ios-verify inspect` warns `proof_identity_marker_present` while it remains. + Native push / APNs / FCM / `POST_NOTIFICATIONS` steps are in the installing and push (or expo) reference docs — do them as part of this skill when push is in scope. If a React Native article defers to Android-native detail that @@ -398,6 +750,66 @@ Notification Service Extension) you may open initialization into an RN app: this package initializes the native SDK, and the iOS SDK version it ships (above) wins over the iOS skill's pin. +### Deep links + +Only when deep links are in scope. + +- **What `urlHandler`'s answer does.** With a JS `urlHandler` set, the native + side always reports the URL as handled and hands it to JavaScript; on Android + the SDK waits `config.androidWakeDelayMs` (default **1000 ms**) before calling + it, so a cold-start link routes a second later. If the handler returns + anything falsy, the SDK's JavaScript calls `Linking.openURL(url)` — for your + own scheme or domains, straight back into your app (rule 9, pitfall #16). +- **A tracking link is resolved asynchronously.** `handleAppLink` records the + click and follows the redirect; `urlHandler` then receives the destination — + or the original `/a/` URL when the request fails (offline), which it must also + answer `true` for. +- **Keep tracking links out of your navigation library.** React Navigation's + `linking` prop takes `filter: (url) => !isIterableTrackingLink(url)`. With + Expo Router, export `redirectSystemPath` from `app/+native-intent.tsx` and + return `null` for a tracking link — no redirection occurs — letting the + listener above hand it to Iterable. +- **Native entry points.** Bare iOS forwards links to `RCTLinkingManager` + (`reference/deep-links-and-custom-actions.md` → Step 1). That snippet starts + with `#import `, which is Objective-C and does not + compile in a Swift `AppDelegate` — use `import React`. Its `urlHandler` + example calls `this.navigate(match[1])` without ever binding `match`; capture + the result of `url.match(...)` first. +- **Expo:** the plugin (1.1.0) only sets the main activity's + `android:launchMode` to `singleTask`. It adds no intent filters and no + associated domains, whatever `reference/expo.md` says — add them yourself in + `app.json` (pitfall #17): + + ```json + { + "expo": { + "ios": { "associatedDomains": ["applinks:links.example.com"] }, + "android": { + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [{ "scheme": "https", "host": "links.example.com", "pathPrefix": "/a/" }], + "category": ["BROWSABLE", "DEFAULT"] + } + ] + } + } + } + ``` + + Use your own tracking domain. The `reference/expo.md` examples list + `applinks:expo.dev` and `applinks:iterable.com` — domains the app cannot + claim — and its intent-filter JSON has a `//` comment and a trailing comma, + which `app.json` rejects. +- **Iterable prerequisites** for either platform: a custom HTTPS link-tracking + domain (not `links.iterable.com`), the `apple-app-site-association` and + `assetlinks.json` uploads under **Settings > Domains** → the tracking domain → + **New Destination Path**, and on Android 12+ a verified App Link — an + unverified one opens in the browser. The native skills have the detail: + `iterable-ios` (Deep links and universal links) and `iterable-android` (Deep + links and App Links). + --- ## How to use this skill (fetching task-specific docs) @@ -419,14 +831,13 @@ source — it is already on disk.** | If the user is asking about… | Slug | | ---------------------------- | ---- | | What the RN SDK covers, architecture, JS vs native | `overview` | -| First-time setup, npm install, iOS + Android native project setup, `Iterable.initialize` | `installing` (bare; skip `## Upgrading the SDK` unless they are upgrading). Expo: `expo` | -| Upgrading from an older RN SDK version | `installing` → `## Upgrading the SDK`, or `migrating` | +| First-time setup, npm install, iOS + Android native project setup, `Iterable.initialize` | `installing` (bare; skip `## Upgrading the SDK` unless they are upgrading, and on React Native 0.82+ skip `## Configuring React Native's New Architecture` — see [Install](#install--android-first)). Expo: `expo` | | Expo / `expo prebuild` / config plugin | `expo` | | JWT, `authHandler`, JWT-enabled API keys | `authentication` | -| `setEmail`, `setUserId`, login / logout | `managing-user-identity` | +| `setEmail`, `setUserId`, login / logout | `managing-user-identity`; the rules behind it are in [Decisions](#decisions-read-before-recommending) | | `updateUser`, profile fields, subscription preferences | `user-profile-data-and-subscription-preferences` | | FCM / APNs push, notification permission, device registration | `push-notifications` | -| The app **already** sends push (`@react-native-firebase/messaging`, another vendor SDK, a hand-written `FirebaseMessagingService`) | **No slug covers this** — the forwarding contract lives on the native Android side. Use rule 8 and [`PITFALLS.md`](PITFALLS.md) #15, and verify against the merged manifest. | +| The app **already** sends push (`@react-native-firebase/messaging`, another vendor SDK, a hand-written `FirebaseMessagingService`) | No RN slug: the forwarding contract is `../iterable-android/reference/setting-up-android-push-notifications.md` → `#### Handling Firebase push messages and tokens`. Use rule 8 and [`PITFALLS.md`](PITFALLS.md) #15, and verify against the merged manifest. | | In-app messages | `in-app-messages` | | Mobile inbox | `mobile-inbox` | | Deep links, custom actions, `urlHandler` | `deep-links-and-custom-actions` | @@ -440,8 +851,6 @@ See [If they don't have an input yet](#if-they-dont-have-an-input-yet). | If the user is asking about… | Slug | | ---------------------------- | ---- | -| Creating a mobile API key, key types, client-side key security, key scope | `api-keys` | -| Turning on JWT for a key, the shared secret, token claims and expiry | `jwt-enabled-api-keys` | | Creating a Firebase service account, FCM JSON private key, defining a mobile app in Iterable, configuring the push integration, sandbox projects | `fcm-http-v1-migration` (read `## Step 1`, `### Step 2.4`–`### Step 2.7`; Android/FCM only) | | Creating or finding a placement ID in the dashboard | `embedded-message-placements` | | Embedded message subscription channels and message types | `embedded-message-subscription-channels` | diff --git a/iterable-react-native/reference/api-keys.md b/iterable-react-native/reference/api-keys.md index b19d58a..d09e057 100644 --- a/iterable-react-native/reference/api-keys.md +++ b/iterable-react-native/reference/api-keys.md @@ -2,7 +2,7 @@ slug: api-keys feature: integration archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: API Keys source_url: https://support.iterable.com/hc/articles/360043464871 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/api/api-keys/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: a471f729824c851571d0c97c020dfc9c68d0dcbc -fetched_at: 2026-09-17T15:31:58.821Z +fetched_at: 2026-10-19T10:21:48.016Z summary: "[Iterable's API](https://support.iterable.com/hc/articles/204780579) can be used to interact with a project's users, templates, campaigns, journeys, and more. To authenticate with the API, you must use an API key." diff --git a/iterable-react-native/reference/authentication.md b/iterable-react-native/reference/authentication.md index 6a52a9e..dc00a56 100644 --- a/iterable-react-native/reference/authentication.md +++ b/iterable-react-native/reference/authentication.md @@ -2,15 +2,15 @@ slug: authentication feature: jwt-authentication archetype: identity -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: JWT Authentication with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/43901001355412 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/authentication/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 6b2543add6f9cabb69d410636adf764ec46a78bb -fetched_at: 2026-08-28T14:44:26.816Z +fetched_at: 2026-10-19T10:21:42.037Z summary: JSON Web Token (JWT) authentication adds an extra layer of security to your mobile app by requiring a cryptographically signed token for each API request. This helps prevent unauthorized access to your Iterable project and diff --git a/iterable-react-native/reference/creating-users-with-apis.md b/iterable-react-native/reference/creating-users-with-apis.md new file mode 100644 index 0000000..9f75da3 --- /dev/null +++ b/iterable-react-native/reference/creating-users-with-apis.md @@ -0,0 +1,650 @@ +--- +slug: creating-users-with-apis +feature: user-profiles +archetype: identity +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: Creating and Identifying Users with Iterable APIs +source_url: https://support.iterable.com/hc/articles/29156459027348 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/creating-users-with-APIs/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: ad12bc20c0e81b4a3adb18555bbc5dd430e5ea45 +fetched_at: 2026-10-22T10:00:22.562Z +summary: Understanding user identification is fundamental to working with + Iterable's API effectively. The steps for creating and identifying users vary + depending on your project type and the way you identify users. This article + describes how to identify and create users with Iterable's APIs for + email-based, userID-based, and hybrid projects. +--- +# Creating and Identifying Users with Iterable APIs + +Understanding user identification is fundamental to working with Iterable's API +effectively. The steps for creating and identifying users vary depending on +your project type and the way you identify users. This article describes how to +identify and create users with Iterable's APIs for email-based, userID-based, +and hybrid projects. + +For more information about the relationship between project types and user +identification, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +> [!NOTE] +> This article covers _most_ aspects of user identification and creation, +> however, it doesn't cover how to manage unknown users (for example, users who +> don't have an `email` address). +> +> Iterable provides two different ways to manage unknown users: +> - [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956) +> - [Unknown User Activation](https://support.iterable.com/hc/articles/38755339847188) + +## Understanding user identification in API requests + +Many of Iterable's API endpoints require you to specify a particular user (for +example, to modify their user profile or fetch data about them). The way you +identify users depends on your project type. + +### Project types and user identification + +To identify a user, provide a `userId` or an `email` in your API request body. +This field typically goes at the top level of your request, outside of +`dataFields`. + +Your **project type** determines which fields you can use to identify users: + +- **UserID-based projects** identify users by `userId`, and Iterable enforces + the uniqueness of this field. + +- **Hybrid projects** identify users by `email` and/or `userId`, and Iterable + enforces the uniqueness of both. However, you don't have to provide both + fields to every user. + +- **Email-based projects** identify users by `email` or `userId`. However, + Iterable only enforces `email` uniqueness, _not_ `userId`. (Email-based projects have limited availability. To learn more, read [Project Type Availability](https://support.iterable.com/hc/articles/9216719179796#project-type-availability).) + +### Field validation and requirements + +When you send an API request that includes an `email` or `userId`, Iterable +validates these fields regardless of your project type. + +- **`email`**: Iterable checks that the field is a valid email address format + and converts the value to lowercase. Iterable does _not_ check + deliverability. To learn more, read [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +- **`userId`**: Iterable rejects requests with `userId` values that don't meet + certain requirements. To learn more, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + + Additionally, when identifying a user, the `userId` field must be + **non-empty**: + + - Blank, null, or empty values for `userId` are not accepted in requests to + identify users. + + - API requests with a `userId` lookup field that is blank, null, or empty + are rejected. Instead, if a user doesn't have a `userId` value, such as + in a hybrid project, use the `email` field to identify the user and + _don't_ include a blank `userId` field in the API request. + + - For email-based and hybrid projects, API requests that include `userId` + with a null value in `dataFields` with intention to clear the data from a + user profile are accepted. + +### Precedence rules when both fields are provided + +If you include both `email` and `userId` at the top level of an API request, +precedence rules apply based on your project type: + +- **Email-based projects**: `email` takes precedence +- **UserID-based projects**: Only `userId` is valid; `email` is treated as a + regular data field +- **Hybrid projects**: `userId` takes precedence + +### Basic API request structure + +Here's the standard structure for API requests that identify users: + +```json +{ + "userId": "user1234567", // User identifier (outside dataFields) + "dataFields": { // User profile updates go here + "favoriteColor": "red" + } +} +``` + +**Important guidelines:** + +- Place user identifiers (`userId` or `email`) at the top level of your + request, _outside_ of `dataFields`. + +- Place profile updates you'd like to make for that user _inside_ the + `dataFields` object. + +- _Do not include_ empty `userId` values to look up users in your API request. + Iterable rejects blank `userId` when provided to locate a record. However, + API requests for email-based and hybrid projects that include `userId` with a + null value nested in `dataFields`, with intention to clear the data from a + user profile, are still accepted. + +- Some requests require you to place `userId` or `email` inside another object, + rather than at the top level of your API request. For example, when calling + [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart), + you need to place `userId` or `email` inside a `user` object. + +- Additionally, some requests use slightly different field names. For example, + [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + requires `recipientEmail` and `recipientUserId`, rather than `email` and + `userId`. For endpoint-specific details, consult our [API Endpoints and Sample Payloads](https://support.iterable.com/hc/articles/204780579). + +## Email-based projects + +> [!NOTE] +> Email-based projects have limited availability. To learn more, read [Project Type Availability](https://support.iterable.com/hc/articles/9216719179796#project-type-availability). + +In email-based projects, you can pass `email` and/or `userId` in requests to +identify users. The behavior depends on which fields you provide: + +- If both fields are present, `email` is prioritized to identify the user, and + the `userId` field is updated/overwritten for most requests that use `PUT` or + `POST` methods. + +- If `email` is passed and there is no user with that email address, Iterable + creates a new user when using the following endpoints: + + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- If only `userId` is passed (and not `email`), and there is no matching user, + Iterable returns an error. To avoid this and create a user without an email, + include the parameter `preferUserId` with a value of `true`. This creates the + user with an auto-generated placeholder email address using the + `@placeholder.email` domain. For more information about placeholder email + addresses, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). + +- If only `userId` is passed in a request to an email-based project, and + multiple users share the same `userId` but have different `email` values, + Iterable updates only one of the users. This occurs because `userId` is not + enforced as unique in email-based projects. The API selects the first user + returned in the query, but this selection is not consistent and may vary + between requests. + +- Tracking events for an unknown user **does not** create a new user. + +### Email-based project behavior across Iterable + +- Lists must include an `email` column. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are available for static lists. + +- When previewing a template, use `email` to populate sample data. + +- Journey triggers can be tested with `email` only. + +- Static list subscriptions are stored in the `emailListIds` field. + +> [!NOTE] +> This is the only type of project where Iterable creates a `iterableEndUserId` +> browser cookie (which will be deprecated at a future date). This may impact +> your implementation of a [subscription preference center](https://support.iterable.com/hc/articles/208463956) +> and when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285). + +### Identifying users in email-based projects + +In email-based projects, `email` takes precedence when both `email` and +`userId` are provided. For example, this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +looks up or creates a user with `email` address `user@example.com`, then sets +their `favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +### Creating new users in email-based projects + +To create a new user and assign them a unique email address in an email-based +project, make a request to one of the various endpoints that can create new +users in one of these ways: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens + +For example, you can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) with +a request body such as: + +```json +{ + "email": "user@example.com", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If your project doesn't yet have a user with an `email` address of + `user@example.com`, Iterables creates one. + +- Then, whether the user is new or already exists, Iterable sets their + `favoriteColor` to `red`. + +### Creating new users when `email` is unknown + +To create a new user for someone with an unknown email address (in an +email-based project), provide a `userId` instead of an `email`. When you do +this, Iterable creates a new user with a placeholder `email` address using the +`@placeholder.email` domain. + +To learn more about how to do this, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). + +Once a placeholder `email` is assigned to a user, you can identify this user in +subsequent API calls by their `userId`, or by the placeholder `email` given to +them by Iterable. + +### Assigning a `userId` to an existing user + +In an email-based project, you can assign a `userId` to an existing user who is +already identified by `email` by making an API call to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +To learn more about how to do this, read [Updating a User's Email Address or User ID](https://support.iterable.com/hc/articles/217502386). + +## UserID-based projects + +> [!TIP] +> UserID-based is often the most sensible type of project to choose, because +> it: +> +> - Is simple to use. You always identify users by `userId`. +> - Allows you to match Iterable `userId` values with user identifiers you use in +> other systems. +> - Allows multiple users to share the same email address, which can sometimes +> be useful. + +UserID-based projects identify users exclusively by `userId`. In these +projects, `email` is not a user identifier—it's treated like any other profile +field (such as `favoriteColor`). You can assign `email` addresses to users, but +you can't use `email` to look up or modify users in Iterable's web UI or when +making API calls. + +Key considerations for userID-based projects: + +- The `userId` field must always be present and unique for any endpoint that + uses a unique identifier, including GDPR forget and unforget endpoints +- Multiple users can have the same `email` address since email uniqueness is + not enforced +- If `userId` is passed and there is no matching user, Iterable + creates a new user when using the following endpoints: + + - [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + - [`POST /api/events/track`](https://support.iterable.com/hc/articles/204780579#post-api-events-track) + - [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + - [`POST /api/events/trackWebPushClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackwebpushclick) + - [`POST /api/events/trackPushOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackpushopen) + - [`POST /api/events/trackInAppOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappopen) + - [`POST /api/events/trackInAppClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclick) + - [`POST /api/events/trackInAppDelivery`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappdelivery) + - [`POST /api/events/trackInAppClose`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclose) + - [`POST /api/workflows/triggerWorkflow`](https://support.iterable.com/hc/articles/204780579#post-api-workflows-triggerworkflow) + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- When an API request includes the parameter `preferUserId`, it's ignored. + +- If you try to use `email` to identify a user when making an API call, the + request returns an error. You can only identify users with `userId`. + +- You aren't required to provide an `email` for every user you upload to + Iterable. Placeholder email addresses aren't necessary. + +- Iterable doesn't enforce `email` uniqueness. In other words, many users can + have the same email address. + +- Tracking events for an unknown user **creates** a new user. + +### UserID-based project behavior across Iterable + +- When creating a new list, specify a `userId` column, instead of an `email` + column. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are unavailable at this time. + +- When previewing a template, use `email` or `userID` to populate sample data. + +- Journey triggers can be tested with `email` or `userID`. + +- Iterable doesn't set a `iterableEndUserId` browser cookie. + +- To identify users when you create a [custom subscription center](https://support.iterable.com/hc/articles/208463956), pass `userId` + as a URL parameter using the following Handlebars in the [hosted unsubscribe](https://support.iterable.com/hc/articles/206382645#hosted-unsubscribe-url) URL project setting: + + :::v-pre + `{{userId}}` + ::: + +- To identify users when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285), add a URL + parameter with a Key of userId and Value of: + :::v-pre + `{{userId}}` + ::: + +- When a user unsubscribes from a link in an email (`emailUnSubscribe`, `hostedUnsubscribeClick`, + or `emailComplaint` event), Iterable attributes the unsubscribe to the user's + unique `userId`, not the email address that received the message. This is + important when you have users who share an email address. + +- Static list subscriptions are stored in the `userListIds` field. + +- When sending a proof to an unknown email address (including when sending a + proof "to myself" for the first time), Iterable creates a new user and + automatically assigns a `userID` with the value `itbl_generated_{{email}}` + (where the email address is part of the `userId`). + + :::tip TIP + To look up users by email address in a userID-based project, use the + segmentation tool and segment by the contact property `email`. + ::: + +### Identifying users in userID-based projects + +In userID-based projects, `userId` is the _only_ valid user identifier, and +uniqueness is enforced. + +For this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +Iterable looks up or creates a user with `userId` of `user1234567`, then sets +their `favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +Iterable also sets the user's `email` to `user@example.com`, as this is a field +included in the request body. However, since `email` is not a user identifier +in userID-based projects, the API does not verify its uniqueness. Multiple +users could have the same `email` address with different `userId` values. + +### Creating new users in userID-based projects + +In userID-based projects, when you make an API request that includes a `userId` +not yet used by your project, some endpoints create new users: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens +- Track events (individually or in bulk) +- Trigger workflows + +For example, call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), with a +request body such as: + +```json +{ + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with `userId` set to `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their + `favoriteColor` to `red`. + +### Assigning email in userID-based projects + +You _may_ assign an `email` to each user in a userID-based project, but it's +not a required field and isn't used to identify unique users. + +> [!NOTE] +> Since `email` is not a required field in userID-based projects, Iterable does +> not automatically assign `@placeholder.email` addresses like it does in +> email-based projects. + +To do this, call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), with a +request body such as: + +```json +{ + "userId": "user1234567", + "dataFields": { + "email": "user@example.com" + } +} +``` + +For this request: + +- If there's no user with `userId` set to `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `email` to + `user@example.com`. + + :::tip NOTE + In userID-based projects, Iterable does not enforce `email` uniqueness. This + allows you to give the same `email` to many users in the same project, which + can sometimes be useful. + ::: + +## Hybrid projects + +Hybrid projects use both `email` and `userId` as unique identifiers. Key +behaviors include: + +- Use either `userId` or `email` (but not both) to identify users in API + requests. + +- If you provide both fields, `userId` takes precedence. + +- Both `email` and `userId` must be unique, and this policy is enforced. + +- If `userId` and/or `email` are passed and there is no matching user, Iterable + creates a new one when using the following endpoints: + + - [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + - [`POST /api/events/track`](https://support.iterable.com/hc/articles/204780579#post-api-events-track) + - [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + - [`POST /api/events/trackWebPushClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackwebpushclick) + - [`POST /api/events/trackPushOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackpushopen) + - [`POST /api/events/trackInAppOpen`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappopen) + - [`POST /api/events/trackInAppClick`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclick) + - [`POST /api/events/trackInAppDelivery`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappdelivery) + - [`POST /api/events/trackInAppClose`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackinappclose) + - [`POST /api/workflows/triggerWorkflow`](https://support.iterable.com/hc/articles/204780579#post-api-workflows-triggerworkflow) + - [`POST /api/email/target`](https://support.iterable.com/hc/articles/204780579#post-api-email-target) + - [`POST /api/inApp/target`](https://support.iterable.com/hc/articles/204780579#post-api-inapp-target) + - [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + - [`POST /api/users/bulkUpdate`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdate) + - [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions) + - [`POST /api/users/bulkUpdateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-bulkupdatesubscriptions) + - [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + - [`POST /api/users/registerBrowserToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerbrowsertoken) + - [`POST /api/lists/subscribe`](https://support.iterable.com/hc/articles/204780579#post-api-lists-subscribe) + - [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase) + - [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/user/{userEmail}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-user-useremail) + - [`PATCH /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#patch-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`DELETE /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}/byUserId/{userId}`](https://support.iterable.com/hc/articles/204780579#delete-api-subscriptions-subscriptiongroup-subscriptiongroupid-byuserid-userid) + - [`PUT /api/subscriptions/{subscriptionGroup}/{subscriptionGroupId}?action=subscribe`](https://support.iterable.com/hc/articles/204780579#put-api-subscriptions-subscriptiongroup-subscriptiongroupid) + +- If you are adding a user with an invalid `email` value, it is rejected + even if it has a `userId` included in the request. + +- When an API request includes the parameter `preferUserId`, it's ignored. + +- You aren't required to provide an `email` for every user you upload to + Iterable—placeholder email addresses aren't necessary. + +- You aren't required to provide a `userId` for every user, however Iterable + doesn't create one when the value is missing. + +- To process a GDPR request to forget a user, you can use either `email` or + `userId` with the [`POST /api/users/forget`](https://support.iterable.com/hc/articles/204780579#post-api-users-forget) + endpoint. + +#### Hybrid project behavior across Iterable + +- When creating a new list, specify either a `email` or `userId` column, at + minimum, or upload a list with both columns. + +- [Hosted Signup Forms](https://support.iterable.com/hc/articles/207914276) + are available for static lists. + +- When previewing a template, use `email` or `userID` to populate sample data. + +- Journey triggers can be tested with `email` or `userID`. + +- Iterable doesn't set a `iterableEndUserId` browser cookie. +:::v-pre +- To identify users when you create a [custom subscription center](https://support.iterable.com/hc/articles/208463956), pass `userId` + as a URL parameter using Handlebars (`{{userId}}`) in the [hosted unsubscribe](https://support.iterable.com/hc/articles/206382645#hosted-unsubscribe-url) URL project setting. + +- To identify users when [tracking conversions, purchases, and revenue](https://support.iterable.com/hc/articles/205480285), add a URL + parameter with a **Key** of `userId` and **Value** of `{{userId}}`. +::: +- Static list subscriptions are stored in the `userListIds` field. + +- When sending a proof to an unknown email address (including when sending a + proof "to myself" for the first time), Iterable creates a new user. + +### Identifying users in hybrid projects + +In hybrid projects, both `userId` and `email` are valid unique identifiers, but +`userId` always takes precedence when both are provided. + +For this request to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +Iterable finds or creates a user with `userId` of `user1234567` and sets their +`favoriteColor` to `red`: + +```json +{ + "email": "user@example.com", + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +Iterable also attempts to set the user's `email` to `user@example.com` (even +though it is provided outside of `dataFields`). Since hybrid projects enforce +email uniqueness, Iterable first checks if that email is already in use: + +- **If no profile exists with that `email`**: The request succeeds, creating/updating + the user with both identifiers. +- **If a profile exists with that `email`**: The request fails with HTTP status + code `409`—you can't reuse an email address that belongs to another user. + +### Creating new users in hybrid projects + +In hybrid projects, when you make an API request that references a non-existent +`userId` or `email`, some endpoints create new user profiles: + +- Update user data (individually or in bulk) +- Target users with email and in-app campaigns +- Update subscription information +- Update the shopping cart and track purchases +- Register device and browser tokens +- Track events (individually or in bulk) +- Trigger workflows + +For example, you can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +with a request body such as: + +```json +{ + "email": "user@example.com", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with an `email` of `user@example.com`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `favoriteColor` + to `red`. + +Another example request: + +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + +```json +{ + "userId": "user1234567", + "dataFields": { + "favoriteColor": "red" + } +} +``` + +For this request: + +- If there's no user with a `userId` of `user1234567`, Iterable creates one. +- Whether the user is new or already exists, Iterable sets their `favoriteColor` + to `red`. + +> [!NOTE] +> In userID-based projects, Iterable's API ignores the `preferUserId` parameter +> used in email-based projects for some endpoints. + +## Want to learn more? + +For more information about some of the topics in this article, check out these +resources. Iterable Academy is open to everyone — you don't need +to be an Iterable customer! + +**Iterable Academy** +- [Foundations: Introduction to API](https://academy.iterable.com/introduction-to-api) + +**Support docs** +- [API Keys](https://support.iterable.com/hc/articles/360043464871) +- [API Overview and Sample Payloads](https://support.iterable.com/hc/articles/204780579) +- [Responding to GDPR Requests](https://support.iterable.com/hc/articles/360029174171) diff --git a/iterable-react-native/reference/deep-links-and-custom-actions.md b/iterable-react-native/reference/deep-links-and-custom-actions.md index fb77db6..fbeab99 100644 --- a/iterable-react-native/reference/deep-links-and-custom-actions.md +++ b/iterable-react-native/reference/deep-links-and-custom-actions.md @@ -2,15 +2,15 @@ slug: deep-links-and-custom-actions feature: deep-linking archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Deep Links and Custom Actions with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360046134911 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/deep-links-and-custom-actions/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: ee16919f6cfe9bcf16e04aee34b06b8f41be523e -fetched_at: 2026-08-28T14:44:26.844Z +fetched_at: 2026-10-19T10:21:44.848Z summary: It's often useful to link directly from your marketing campaigns to specific content in your mobile apps. To make this possible, Iterable supports _deep links_ and _custom actions_. Deep links can open a particular screen in diff --git a/iterable-react-native/reference/embedded-message-placements.md b/iterable-react-native/reference/embedded-message-placements.md index 3df8f76..3fb4db5 100644 --- a/iterable-react-native/reference/embedded-message-placements.md +++ b/iterable-react-native/reference/embedded-message-placements.md @@ -2,7 +2,7 @@ slug: embedded-message-placements feature: embedded-messaging archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Defining Placements for Embedded Messages source_url: https://support.iterable.com/hc/articles/23060983437076 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/sending-messages-by-channel/embedded-messaging/placements-for-embedded-messages/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 9d3f509c00fb5c26601026bd89837ba68cfcad9c -fetched_at: 2026-09-17T15:32:00.898Z +fetched_at: 2026-10-19T10:21:49.719Z summary: Before you can create embedded message templates and campaigns, you'll need to decide where in your apps to display them. Places in your app or website that display embedded messages are called _placements_, and you'll diff --git a/iterable-react-native/reference/embedded-message-subscription-channels.md b/iterable-react-native/reference/embedded-message-subscription-channels.md index 7dbcf8a..e190965 100644 --- a/iterable-react-native/reference/embedded-message-subscription-channels.md +++ b/iterable-react-native/reference/embedded-message-subscription-channels.md @@ -2,7 +2,7 @@ slug: embedded-message-subscription-channels feature: embedded-messaging archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Adding an Embedded Message Subscription Channel and Type to Your Iterable Project @@ -11,7 +11,7 @@ source_repo: Iterable/iterable-docs source_path: docs/sending-messages-by-channel/embedded-messaging/embedded-messaging-subscription-channels-and-types/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: b4ece97017a56433619bad04118c3532f518cca3 -fetched_at: 2026-09-17T15:32:01.452Z +fetched_at: 2026-10-19T10:21:50.181Z summary: Before creating embedded messages templates and campaigns in Iterable, you'll need to add an embedded messaging subscription channel with an opt-out message type to your Iterable project. diff --git a/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md b/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md index bc4dd65..81d8127 100644 --- a/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md +++ b/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md @@ -2,15 +2,15 @@ slug: embedded-messages-with-iterables-react-native-sdk feature: embedded-messaging archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Embedded Messages with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/49144129810324 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/embedded-messaging/embedded-messages-with-iterables-react-native-sdk/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 88948a289f4214d375316e4cd8ab8dca1af22120 -fetched_at: 2026-08-28T14:44:26.871Z +fetched_at: 2026-10-19T10:21:47.491Z summary: This article describes the steps you'll need to follow to use Iterable's React Native SDK to display embedded messages in your mobile app. It describes only the steps necessary to use embedded messaging. To learn diff --git a/iterable-react-native/reference/european-data-center.md b/iterable-react-native/reference/european-data-center.md new file mode 100644 index 0000000..ee0a49d --- /dev/null +++ b/iterable-react-native/reference/european-data-center.md @@ -0,0 +1,173 @@ +--- +slug: european-data-center +feature: integration +archetype: prerequisite +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: Iterable's European Data Center (EDC) +source_url: https://support.iterable.com/hc/articles/17572750887444 +source_repo: Iterable/iterable-docs +source_path: docs/welcome-to-iterable/about-iterable/iterables-european-data-center-edc/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: f144b8c50c37374866060543ae8bcea9cfbf7099 +fetched_at: 2026-10-22T10:00:27.099Z +summary: New Iterable customers can choose to host their Iterable projects and + data (personal and non-personal) on Iterable's European data center (EDC), + which is in Ireland. +--- +# Iterable's European Data Center (EDC) + +New Iterable customers can choose to host their Iterable projects and data +(personal and non-personal) on Iterable's European data center (EDC), which is +in Ireland.  + +Iterable's EDC is GDPR-compliant, does not send data to the US, handles data +storage and data processing, and offers feature parity with Iterable's USDC-based +offering. + +> [!NOTE] +> Whether your project is USDC-based or EDC-based depends on the terms of your +> Iterable contract and may not correspond to your company's physical location. +> For example, if your company is located in Spain (an EU country), but your +> Iterable account is hosted on Iterable's US data center, you have a USDC-based +> project. This means you should sign in to Iterable using the URL for USDC-based +> Iterable projects ([https://app.iterable.com](https://app.iterable.com)) and use the +> link tracking domain for USDC-based projects `links.iterable.com`). +> +> Most Iterable customers use Iterable's US data center. If you're not sure which +> data center your account is hosted on, check with your Iterable customer success +> manager. + +## Organizations and projects + +In Iterable, a single organization can have multiple projects, each with +separate data. All of an organization's projects must be hosted on the same data +center — all in the US, or all in the EU. + +## UserID-based and hybrid projects only + +Iterable's EDC supports userID-based and hybrid projects, but not email-based +projects. + +For more information about the types of projects Iterable provides, read +[Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## Signing in to Iterable: region-specific URLs + +To sign in to Iterable: + +- For USDC-based projects, visit . +- For EDC-based projects, visit . + +## SAML SSO: region-specific URLs + +USDC- and EDC-based projects use different single sign-on / ACS / reply URLs: + +- USDC-based projects: `https://auth.iterable.com/login/callback?connection=-saml-org-` +- EDC-based projects: `https://auth.eu.iterable.com/login/callback?connection=-saml-org-` + +For more information, read [Setting Up Single Sign-On](https://support.iterable.com/hc/articles/12227358446868). + +## Default link tracking domains: region-specific URLs + +USDC- and EDC-based projects use different default link tracking domains: + +- USDC-based projects: `links.iterable.com` +- EDC-based projects: `links.eu.iterable.com` + +When setting up tracking links, be sure to use the correct domain for your +project type. (Keep in mind that "US/EDC-based" refers to where your project's +data is stored and may not correspond to your company's physical location). + +For more information, read [Setting up Link Tracking Domains to use HTTPS](https://support.iterable.com/hc/articles/115000427446). + +## IP addresses for Iterable's EDC + +Iterable's EDC uses different IP addresses than Iterable's US data center. If +you need to allow Iterable's IP addresses in your firewall, see +[Allowing Iterable IP Addresses](https://support.iterable.com/hc/articles/45758621331732). + +## SMS providers for EDC-based Iterable projects + +To send SMS from EDC-based Iterable projects, you can use: + +- Iterable SMS. +- Your own Telnyx account. + +For EDC-based Iterable projects, Twilio is not supported. + +For more information about SMS, read [SMS Overview](https://support.iterable.com/hc/articles/211970843). + +## Email providers for EDC-based Iterable projects + +To send email from an EDC-based Iterable project, you can use: + +- Iterable's default integration with Amazon SES. +- An Iterable-managed integration with Mailgun, or your own Mailgun account. +- An Iterable-managed integration with Sparkpost, or your own Sparkpost account. + +For EDC-based Iterable projects, Sendgrid is not supported. + +To learn more about setting up email in Iterable, read [Email Setup](https://support.iterable.com/hc/articles/7130133854612). + +## Smart Ingest + +Iterable's [Smart Ingest](https://support.iterable.com/hc/articles/24354008224916) +feature supports EDC-based projects. Smart Ingest allows you to import data from +a supported data source into Iterable. During the setup process, your data +source administrator must allow the IP addresses used by Smart Ingest to +connect to your data warehouse. + +These IPs vary by region, so make sure that you're allowing the correct set of +IPs for a successful connection. To learn more, read [Smart Ingest IPs to allow](https://support.iterable.com/hc/articles/25899748801172#smart-ingest-ips-to-allow). + +## API keys + +Every Iterable API key is associated with a specific Iterable project, which +means that it's also associated with a specific data center: + +- API keys created in a USDC-based project can only be used with Iterable's USDC-based + API endpoints (base URL: `https://api.iterable.com`). +- API keys created in EDC-based project can only be used with Iterable's EDC-based + API endpoints (base URL: `https://api.eu.iterable.com`). + +For more information about API keys, read [API Keys](https://support.iterable.com/hc/articles/360043464871). + +## API documentation + +You can make API calls directly from Iterable's API docs: enter an API key, +choose an endpoint, specify request parameters / a request body, and submit the +request. + +Iterable provides two sets of API documentation: + +- API docs for USDC endpoints - . + + Requests submitted on this page are sent to Iterable's USDC-based API endpoints + (base URL: `https://api.iterable.com`). On this page, you can only use API keys + associated with USDC-based Iterable projects. + +- API docs for EDC endpoints - . + + Requests submitted on this page are sent to Iterable's EDC-based API endpoints + (base URL: `https://api.eu.iterable.com`). On this page, you can only use API + keys associated with EDC-based Iterable projects. + +Our [Getting Started with Iterable's API](https://support.iterable.com/hc/articles/41044692130196) provides +information about each endpoint, too. + +## SDK support + +Iterable's mobile and web SDKs support Iterable's EDC: + +- iOS SDK, [version 6.4.15+](https://github.com/Iterable/iterable-swift-sdk/releases/tag/6.4.15). + To learn more, see [Iterable's iOS SDK](https://support.iterable.com/hc/articles/360035018152). + +- Android SDK, [version 3.4.15+](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.15) + To learn more, see [Iterable's Android SDK](https://support.iterable.com/hc/articles/360035019712). + +- React Native SDK, [version 1.3.16+](https://github.com/Iterable/react-native-sdk/releases/tag/1.3.16) + To learn more, see [Installing Iterable's React Native SDK](https://support.iterable.com/hc/articles/360045714132). + +- Web SDK, [version 1.0.5+](https://github.com/Iterable/iterable-web-sdk/releases/tag/v1.0.5) + To learn more, see [Setting Up Iterable's Web SDK](https://support.iterable.com/hc/articles/4419628585364). \ No newline at end of file diff --git a/iterable-react-native/reference/expo.md b/iterable-react-native/reference/expo.md index 72c4a7b..85ae930 100644 --- a/iterable-react-native/reference/expo.md +++ b/iterable-react-native/reference/expo.md @@ -2,15 +2,15 @@ slug: expo feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Expo Plugin for Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/37200225506196 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/expo/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 258b9e115662257942fb8f6bb7ab57d8cc4d68eb -fetched_at: 2026-08-28T14:44:26.860Z +fetched_at: 2026-10-19T10:21:46.411Z summary: "Iterable's React Native SDK offers an Expo plugin that extends configuration management, making it easier to manage dependencies and configurations. Use the Expo plugin to minimize the potential for @@ -118,31 +118,6 @@ workaround during `expo prebuild` that compiles the `{fmt}` C++ library pod in C++17 mode, for compatibility with [Xcode 26.4](https://github.com/expo/expo/issues/44229) or later and React Native 0.83.2 or later. No additional configuration is required. -### Xcode 27 - -These notes are **iOS-only**. See [`PITFALLS.md`](../PITFALLS.md) pitfall **#12** -for the combined launch + build story. The `{fmt}` workaround above is -**separate** from the deployment-target lift below. - -**Launch (Device Hub).** On Xcode 27, Expo SDK **58** (and its CLI) targets -**Device Hub** instead of the removed `Simulator.app` under -`Developer/Applications/`. Apps still on **Expo SDK 55** may fail at -`run:ios` on Xcode 27 (often with a missing **Simulator.app** / simulator -launch error) until they upgrade or use a short-term -`npx expo@latest run:ios`. Do not fix this by hand-editing `ios/Podfile` or -`Pods/` in a continuous-native-generation workflow (see [Native code](#native-code)). - -**Build (resource bundles).** The same Xcode 27 **15.0** compile floor applies -to every Pods target, including `Iterable-iOS-SDK-IterableSDKResources` and -other `*_resources` bundles. In **CNG** workflows, apply a **config plugin** -at `prebuild` that lifts generated Pods deployment targets to at least -**15.1** when unset or below **15.1** (same lift as Iterable's bare -`Podfile` workaround; **15.0** is the SDK minimum) — do not commit -`ios/Podfile` changes as the source of truth for this. `@iterable/expo-plugin` -**1.1.0+** handles `{fmt}` via `withIosFmtWorkaround`; the resource-bundle -lift is a **host** concern until Iterable documents a first-party plugin -option. - ### Expo Go Your Expo app needs to be run as a [development build](https://docs.expo.dev/develop/development-builds/introduction/) diff --git a/iterable-react-native/reference/fcm-http-v1-migration.md b/iterable-react-native/reference/fcm-http-v1-migration.md index 7249690..338347c 100644 --- a/iterable-react-native/reference/fcm-http-v1-migration.md +++ b/iterable-react-native/reference/fcm-http-v1-migration.md @@ -2,7 +2,7 @@ slug: fcm-http-v1-migration feature: push-notifications archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: "Mobile Push Notifications: Migrating to the FCM HTTP v1 API" source_url: https://support.iterable.com/hc/articles/26143681644564 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/sending-messages-by-channel/push-notification/mobile-push-notifications-migrating-to-the-fcm-http-v1-api/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 72ab970eba676b48e10caa249676e03f51799a8e -fetched_at: 2026-09-17T15:32:00.093Z +fetched_at: 2026-10-19T10:21:49.121Z summary: If any of your Iterable projects use Firebase Cloud Messaging (FCM) to send mobile or web push notifications, migrate them to the FCM HTTP v1 API **before July 22, 2024** (Google has changed the deadline from June 20 to July diff --git a/iterable-react-native/reference/handling-anonymous-users.md b/iterable-react-native/reference/handling-anonymous-users.md new file mode 100644 index 0000000..47ba7b4 --- /dev/null +++ b/iterable-react-native/reference/handling-anonymous-users.md @@ -0,0 +1,263 @@ +--- +slug: handling-anonymous-users +feature: user-profiles +archetype: identity +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: Handling Anonymous Users via API +source_url: https://support.iterable.com/hc/articles/208499956 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/handling-anonymous-users/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: dce1b5d51ff199a29e8eae86e4a9b84f3cfde148 +fetched_at: 2026-10-22T10:00:21.942Z +summary: There are many scenarios where you will have users that you need to + track and connect with, but don't have their email address. This happens when + you have mobile apps that don't require an email, SMS-first marketing, and + more. +--- +# Handling Anonymous Users via API + +> [!NOTE] +> This article explains how to work with anonymous users using Iterable's APIs. +> This is a manual approach — you manage placeholder IDs and profile merging +> yourself through the API, without requiring Iterable's iOS, Android, or Web SDKs. +> +> If your apps or website use Iterable's iOS, Android, or Web SDKs, consider +> [Unknown User Activation](https://support.iterable.com/hc/articles/38755339847188) +> instead. Unknown User Activation automates profile creation, local data +> replay, and profile merging. Marketers can define profile creation criteria +> in the Iterable UI without redeploying your app or website. + +There are many scenarios where you will have users that you need to track and +connect with, but don't have their email address. This happens when you have +mobile apps that don't require an email, SMS-first marketing, and more. + +You might need to track anonymously in the following scenarios: + +- You don't have a user's email address but want to track their event history + and save profile data in Iterable. +- You have the user's device token and want to send them a push notification, + but don't yet have their email address. +- You have the user's phone number and want to send them SMS, but don't yet + have their email address. + +Iterable provides two different unique identifier fields, `email` and `userId`, +and these fields apply to projects depending on the project type: + +- **Hybrid project** - uses both `email` and `userId` fields as unique + identifiers. +- **UserID-based project** - uses just the `userId` field as a unique + identifier. +- **Email-based project** - uses `email` as a unique identifier. + (Limited availability: Only certain accounts have the option to create _new_ + email-based projects - [learn more](https://support.iterable.com/hc/articles/9216719179796#project-type-availability).) + +To work with anonymous users in userID-based projects and hybrid projects, you will +simply use the `userId` field instead of the `email` field to identify users. + +If you are working in an email-based project, however, then all user profiles require a +value for the `email` field. When you don't know the email address of a new user, +but need to create a profile in Iterable, you will need to create a placeholder +email address. + +> [!NOTE] +> If you aren't sure which unique identifier your project uses, or to learn more +> about each project type and how they work, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## Placeholder emails for email-based projects + +A placeholder email contains a unique string followed by `@placeholder.email`. + +Example: `some-user+10593364321@placeholder.email` + +### Placeholder email format + +An auto-generated placeholder email is generated with the following formula: +`userId` + `+` + Java hash + `@placeholder.email`. + +When the `userId` value is longer than 52 characters, Iterable creates the +placeholder email as: SHA1 hash + `@placeholder.email`. + +For example: `4146832971d799352598ff1b5712c0b999f615d5@placeholder.email` + +### Sending campaigns to users with placeholder emails + +A placeholder email is _not_ a valid recipient address. Email campaigns to +placeholder addresses are not attempted, and send skip events aren't logged +for these addresses. + +However, if the user's profile has other sending identifiers — such as a device +token for push notifications, or a phone number for SMS — you can still send +messages to them on those channels. + +## Creating a placeholder email + +You can manually assign a placeholder email to a user, or have Iterable automatically +generate one for you when creating the user. + +### Manually creating a placeholder email + +Placeholder email addresses in Iterable must end with `@placeholder.email`. +You can create placeholder emails by using the [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +API endpoint. + +For example, if adding users through the API: + +```json +{ + "email": "example@placeholder.email", + "dataFields": { + "firstName": "Data", + "catName": "Spot" + } +} +``` + +### Automatically assigning a placeholder email + +_Email-based projects only_ + +Instead of manually assigning a placeholder value for the `email` field, you +can also ask Iterable to generate one for you when creating the user. This is +only available if you are working in an email-based project. Iterable doesn't +create placeholder emails for userID-based and hybrid projects. + +To create a user with a placeholder email, use [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +with the following parameters in the request body: + +- `userId`: The user's ID +- `preferUserId`: `true` + +For example: + +```json +{ + "userId": "some-user-id", + "preferUserId": true, + "dataFields": { + "firstName": "Mary", + "lastName": "Lee" + } +} +``` + +This creates a user with `some-user-id+10593364321@placeholder.email` +as the assigned placeholder `email`: + +```json +{ + "user": { + "email": "some-user-id+10593364321@placeholder.email", + "userId": "some-user-id", + "dataFields": { + "firstName": "Mary", + "lastName": "Lee", + "email": "some-user-id+10593364321@placeholder.email", + "signupDate": "2022-10-27 22:12:43 +00:00", + "profileUpdatedAt": "2022-10-27 22:12:39 +00:00", + "signupSource": "API", + "userId": "some-user-id", + "itblInternal": { + "documentCreatedAt": "2022-10-27 22:12:43 +00:00", + "emailDomain": "placeholder.email", + "documentUpdatedAt": "2022-10-27 22:12:43 +00:00" + } + } + } +} +``` + +## Updating a placeholder email to the user's true email + +When your user has converted from anonymous to known and they have provided +their true email address, make a call to Iterable's +[`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +endpoint. + +For example, to update an `example@placeholder.email` email to `user@example.com`, +the payload would look like this: + +```json +{ + "currentEmail": "example@placeholder.email", + "newEmail": "user@example.com" +} +``` + +This call must be made in order for the historical data and profile fields to be +transferred to the new email address for the user in Iterable. + +> [!NOTE] +> - You cannot use the [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +> endpoint if the `newEmail` value already exists in Iterable. If `newEmail` is an +> existing user profile, see [below](#best-practices-for-handling-anonymous-users) +> for information about how to handle this. +> - Updating email addresses can only be done through Iterable's API. It cannot be +> done through Segment. + +## Best practices for handling anonymous users + +When you receive or have determined the user's actual email address, use the +[`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) +API endpoint to update the existing user's email address. + +- The [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + API endpoint transfers historical data and profile fields from the placeholder + email to the new email address. + +- The [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + API endpoint creates a completely separate user profile. + + Once a completely separate user profile has been created for the user's actual + email address, you can't use the [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail) + API endpoint to update the placeholder email address to the user's actual email + address. + +If a completely separate user profile has been created and you wish to merge the +historical data and profile fields from a placeholder email address to the +user's actual email address, you will need to complete the following: + +- For user profile fields: + + - Use the [`GET /api/users/{email}`](https://support.iterable.com/hc/articles/204780579#get-api-users-email) + API endpoint to retrieve the user profile fields associate with the placeholder + email address. + + - Use the [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) + API endpoint to set the placeholder email address's properties (fetched + above) on the actual email address's profile. + + :::tip WARNING + This approach will overwrite the user profile fields for the user's actual + email address with the user profile fields from the placeholder email + address. + ::: + +- For custom events: + + - Use the [`GET /api/events/{email}`](https://support.iterable.com/hc/articles/204780579#get-api-events-email) + API endpoint to retrieve the placeholder email address's custom events. + + - To record the events of the placeholder email address onto the user's + actual address profile, use the [`POST /api/events/trackBulk`](https://support.iterable.com/hc/articles/204780579#post-api-events-trackbulk) + to create the custom events. + + - Make sure you edit the `createdAt` field in the API call to backfill + the events. + + :::tip NOTES + - If the `createdAt` field is left blank, Iterable will automatically + assign the current date and timestamp to that field . + - Backfilling events will trigger any journeys that are triggered + by those custom events. + - Consider adding a filter tile after the Start tile that will allow + users with backfilled events to exit the journey. This can be done + through the creation date of the custom event or by including a data + field in the event itself that notes that it is a backfilled event. + ::: + +- Call [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken) + when a user signs in to the app and [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice) + when they sign out. This prevents duplicate sends to a device shared amongst + multiple people. diff --git a/iterable-react-native/reference/identifying-the-user.md b/iterable-react-native/reference/identifying-the-user.md new file mode 100644 index 0000000..60ec566 --- /dev/null +++ b/iterable-react-native/reference/identifying-the-user.md @@ -0,0 +1,242 @@ +--- +slug: identifying-the-user +feature: user-profiles +archetype: identity +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: Identifying the User +source_url: https://support.iterable.com/hc/articles/360035402531 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/managing-user-profiles/identifying-the-user/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: ced31ca29ce63d634a0c4691277a114ed3f0ceb9 +fetched_at: 2026-10-22T10:00:20.028Z +summary: "The Iterable SDK can identify users by email or user ID. To identify a + user, you'll need to do two things: specify an email address or user ID, and + then call `updateUser` to send that value to Iterable." +--- +# Identifying the User + +The Iterable SDK can identify users by email or user ID. + +## Overview + +To identify a user, you'll need to do two things: specify an email address or +user ID, and then call `updateUser` to send that value to Iterable. + +## Identifying the user by email + +Email is typically used as the key identify within Iterable because it tracks +across devices to aggregate data between a user's phones, tablet, web-site +activity or even IoT (Internet-of-Things) device. + +Iterable also allows for multi-dimensional nested data types, meaning you can +organize your data based on relevant key values. + +Once you have an email address or user ID for your app's current user, set +`IterableAPI.email` or `IterableAPI.userId`. For example: + +> [!WARNING] +> - Don't specify both `email` and `userId` in the same call, as they will be +> treated as different users by the SDK. Only use one type of identifier, email +> or user ID, to identify the user. +> - Your app will not be able to receive push notifications until you set one +> of these values + +Add this line of code as soon as you know the user's email: + +_Swift_ + +```swift +IterableAPI.email = "user@example.com" +``` + +_Objective-C_ + +```objectivec +IterableAPI.email = @"user@example.com"; +``` + +_Java_ + +```java +IterableApi.getInstance().setEmail("user@example.com"); +``` + +> [!NOTE] +> Please see [User Profile Fields Used by Iterable](https://support.iterable.com/hc/articles/217744303) +> to ensure you don't add data that is specific to set fields in Iterable. + +## Identifying the user by user ID + +Iterable can also identify user by user ID. However, all things being equal, it +is recommended to use email as the key identifier. + +_Swift_ + +```swift +IterableAPI.userId = "user123" +``` + +_Objective-C_ + +```objectivec +IterableAPI.userId = @"user123"; +``` + +_Java_ + +```java +IterableApi.getInstance().setUserId("user123"); +``` + +You can add the `userId` identifier at any point after the `IterableConfig()` +call. + +> [!NOTE] +> - When creating a user by `userId` in an email-based project, Iterable automatically +> assigns the user a `@placeholder.email` email address (a user identifier, not +> a way to message the user). For example, if you create a user with a `userId` +> of `user123`, their user profile will also receive an `email` such as +> `user123+147178873@placeholder.email`. To learn more, read [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956). +> - A user ID can be up to 52 characters long. + +## Identifying the device of the user + +For Iterable to send push notifications to an iOS device, it must know the +unique token assigned to that device by Apple or Android. + +Iterable uses silent push notifications to tell iOS apps when to fetch new +in-app messages from the server. Because of this, your app must register for +remote notifications with Apple even if you do not plan to send it any push +notifications. + +### Auto push registration + +`IterableConfig.autoPushRegistration` determines whether or not the SDK will: + +- Automatically register for a device token when the SDK is given a new + email address or user ID. Disable the device token for the previous user when + a new user logs in. + +If `IterableConfig.autoPushRegistration` is **true** (the default value): + +- Setting `IterableAPI.email` or `IterableAPI.userId` causes the SDK to + automatically call the `registerForRemoteNotifications()` method on + UIApplication and pass the resulting device token to the + `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` method on + the app delegate. + +If `IterableConfig.autoPushRegistration` is **false**: + +- After setting `IterableAPI.email` or `IterableAPI.userId`, you must + manually call the `registerForRemoteNotifications()` method on + `UIApplication`. This will fetch the device token from Apple and pass it to + the `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` method + on the app delegate. + +### Send the device token to Iterable + +> [!NOTE] +> - Iterable users the device token to send push notifications and in-app +> messages. +> - Users do not need to opt in to Apple push notifications for Iterable to get +> the device token. + +To send the device token to Iterable and save it on the current user's +profile, call `IterableAPI.register(token:)` from the +`application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` method on +`UIApplicationDelegate`. For example: + +_Swift_ +```swift +func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + IterableAPI.register(token: deviceToken) +} +``` + +_Objective-C_ +```objectivec +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { + [IterableAPI registerToken:deviceToken]; +} +``` + +_Java_ +```java +// Iterable SDK automatically registers the push token with Iterable +// whenever setEmail or setUserId is called. + +// If you want to trigger token registration manually, first disable automatic +// registration by calling setAutoPushRegistration(false) on IterableConfig.Builder when initializing the SDK. + +// Then call registerForPush whenever you want to register the token: + +// Only use this line if you want to register push manually. +// IterableApi.getInstance().registerForPush(); +``` + +Once you register for push, you should see the device on the user's Iterable profile. +Access a user's profile in Iterable by navigating to **Audiences > Contact Lookup**. + +## Updating email and user ID + +Use the following code to update both an `email` and a `userId`: + +_Swift_ + +```swift +IterableAPI.updateEmail("", + onSuccess: { _ in + IterableAPI.updateUser( + [JsonKey.userId: ""], + mergeNestedObjects: false, + onSuccess: { _ in + // this needs to be set after both calls have finished successfully + IterableAPI.userId = "" + }, onFailure: nil) +}, onFailure: nil) +``` + +_Objective-C_ + +```objectivec +[IterableAPI updateEmail:@"" + onSuccess:^(NSDictionary * _Nullable data) { + [IterableAPI updateUser:@{@"userId": @""} + mergeNestedObjects:NO + onSuccess:^(NSDictionary * _Nullable data) { + // this needs to be set after both calls have finished successfully + IterableAPI.userId = @""; + } onFailure:nil]; +} onFailure:nil]; +``` + +_Java_ + +```java +IterableApi.getInstance().updateEmail("newEmail@somewhere.com", new IterableHelper.SuccessHandler() { + @Override + public void onSuccess(JSONObject data) { + JSONObject userIDobj = new JSONObject(); + try { + userIDobj.put("userId", "newUserId"); + } catch (JSONException e) { + e.printStackTrace(); + } + IterableApi.getInstance().updateUser(userIDobj); + } +}, new IterableHelper.FailureHandler() { + @Override + public void onFailure(String reason, JSONObject data) { + Log.e(TAG, reason) + } +}); +``` + +## Next steps + +To troubleshoot user identification, see [Testing and Troubleshooting User Profiles](https://support.iterable.com/hc/articles/360035079512). + +If you have already identified the user for your use case, see [Updating User Profiles](https://support.iterable.com/hc/articles/360035402611) +and [User Update Recommendations](https://support.iterable.com/hc/articles/360035031532). diff --git a/iterable-react-native/reference/in-app-messages.md b/iterable-react-native/reference/in-app-messages.md index d3081f8..bbc93eb 100644 --- a/iterable-react-native/reference/in-app-messages.md +++ b/iterable-react-native/reference/in-app-messages.md @@ -2,15 +2,15 @@ slug: in-app-messages feature: in-app-messages archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: In-App Messages with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360045714172 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/in-app-messages/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: f16e23544e5cb8db400eda069e97955e7458e89e -fetched_at: 2026-08-28T14:44:26.833Z +fetched_at: 2026-10-19T10:21:43.538Z summary: Iterable's React Native SDK makes it possible to display in-app messages, customize them, and track events related to their usage. --- diff --git a/iterable-react-native/reference/installing.md b/iterable-react-native/reference/installing.md index a7640ef..f7a6c25 100644 --- a/iterable-react-native/reference/installing.md +++ b/iterable-react-native/reference/installing.md @@ -2,7 +2,7 @@ slug: installing feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Installing Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360045714132 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/installing/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 25468f8175b9dd3c3c2ef4eb8115ab9c13f2bbe3 -fetched_at: 2026-09-11T13:22:44.397Z +fetched_at: 2026-10-19T10:21:41.457Z summary: This article describes how to install Iterable's React Native SDK in your React Native apps, and how to configure your iOS and Android apps to support Iterable features like push notifications and in-app messages. @@ -42,46 +42,12 @@ Iterable's React Native SDK depends on the following: **iOS** -- Xcode 15+ (see [Xcode 27](#xcode-27) when using Apple's Xcode 27 beta or - release) +- Xcode 15+ - Swift 5+ - [Deployment target 13.4+](https://help.apple.com/xcode/mac/current/#/deve69552ee5) - for Iterable's documented floor on older Xcode versions. **Xcode 27** adds - stricter compile rules for CocoaPods resource bundles — see - [Xcode 27](#xcode-27); that is not the same as Iterable raising its - published iOS SDK minimum (the dependency may still declare **12.0** in - its podspec until an Iterable iOS SDK release changes it). - [Iterable's iOS SDK](https://github.com/Iterable/iterable-swift-sdk) (the instructions below will install this SDK for you) -### Xcode 27 - -These notes are **iOS-only** (Android unchanged). Full symptom/cause/fix -detail is in [`PITFALLS.md`](../PITFALLS.md) pitfall **#12**. - -**Launch (Device Hub).** Xcode 27 replaces the standalone Simulator app with -**Device Hub** (`DeviceHub.app`, bundle id `com.apple.dt.Devices`). Older -React Native CLI still looks for -`Xcode.app/Contents/Developer/Applications/Simulator.app`, so -`npx react-native run-ios` can fail even when `xcode-select` points at Xcode -27. Use `@react-native-community/cli` **20.2.0 or later** (and aligned -`@react-native-community/cli-platform-ios`). Iterable's public RN SDK **example** -may still document **20.1.0** until that repo updates — consumer apps on -Xcode 27 need **20.2.0+**. Confirm `xcode-select -p` matches the Xcode you -build with when multiple versions are installed. - -**Build (resource bundles).** The iOS 27 SDK requires -`IPHONEOS_DEPLOYMENT_TARGET` in the **15.0**–current-SDK range on **every** -Xcode target, including CocoaPods **resource bundle** targets generated from -dependency podspecs (for example `Iterable-iOS-SDK-IterableSDKResources` at -**12.0**, or third-party `*_resources` pods). A host **`post_install`** hook -in the app `Podfile` must raise stale Pods targets to at least **15.1** when -they are missing or below **15.1** — then re-run `pod install`. **15.0** is -the Xcode 27 SDK compile floor; **15.1** matches Iterable's bare RN demo so -targets are not left at exactly **15.0**. Do **not** edit files -under `Pods/` by hand. This is a compile-time host workaround; it does not -mean you should bump `Iterable-iOS-SDK` in the Podfile as the primary fix. - **Android** - [`minSdkVersion` 23+, `compileSdkVersion` 34+](https://medium.com/androiddevelopers/picking-your-compilesdkversion-minsdkversion-targetsdkversion-a098a0341ebd) diff --git a/iterable-react-native/reference/jwt-enabled-api-keys.md b/iterable-react-native/reference/jwt-enabled-api-keys.md index f34a88e..e9a6c51 100644 --- a/iterable-react-native/reference/jwt-enabled-api-keys.md +++ b/iterable-react-native/reference/jwt-enabled-api-keys.md @@ -2,7 +2,7 @@ slug: jwt-enabled-api-keys feature: jwt-authentication archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: JWT-Enabled API Keys source_url: https://support.iterable.com/hc/articles/360050801231 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/api/jwt-enabled-api-keys/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 27e742891fb5f2f842cb0ecfbe403aa9ee5bbbab -fetched_at: 2026-09-17T15:31:59.465Z +fetched_at: 2026-10-19T10:21:48.559Z summary: Iterable's API supports authentication with JSON Web Token (JWT)-enabled API keys. --- diff --git a/iterable-react-native/reference/managing-user-identity.md b/iterable-react-native/reference/managing-user-identity.md index 8f92b18..f79e724 100644 --- a/iterable-react-native/reference/managing-user-identity.md +++ b/iterable-react-native/reference/managing-user-identity.md @@ -2,15 +2,15 @@ slug: managing-user-identity feature: user-profiles archetype: identity -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Managing User Identity with Iterable's React Native SDK source_url: https://iterable.zendesk.com/hc/articles/360045714152 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/managing-user-identity/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: d9c6d023a1405845d55aef9e6315f6e1dbecd905 -fetched_at: 2026-08-28T14:44:26.822Z +fetched_at: 2026-10-19T10:21:42.510Z summary: As people use your mobile apps, you'll likely want to save data and events to their Iterable user profiles. Your marketing team can then use this data to send relevant, personalized messages. diff --git a/iterable-react-native/reference/migrating.md b/iterable-react-native/reference/migrating.md index bf99a44..b7b3abe 100644 --- a/iterable-react-native/reference/migrating.md +++ b/iterable-react-native/reference/migrating.md @@ -2,15 +2,15 @@ slug: migrating feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Migrating to Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360046134931 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/migrating/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: f9c8c2739e94d3d97237bc172f197bc899a2d1c1 -fetched_at: 2026-08-28T14:44:26.866Z +fetched_at: 2026-10-19T10:21:46.913Z summary: This guide describes how to integrate Iterable's React Native SDK into an existing React Native application that interacts with Iterable. --- diff --git a/iterable-react-native/reference/mobile-inbox.md b/iterable-react-native/reference/mobile-inbox.md index b82195a..176869b 100644 --- a/iterable-react-native/reference/mobile-inbox.md +++ b/iterable-react-native/reference/mobile-inbox.md @@ -2,15 +2,15 @@ slug: mobile-inbox feature: mobile-inbox archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Using a Mobile Inbox with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/5422108307604 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/mobile-inbox/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 0088f17792b5278225b686005e654262de6a4eab -fetched_at: 2026-08-28T14:44:26.838Z +fetched_at: 2026-10-19T10:21:44.084Z summary: A mobile inbox displays a list of saved in-app messages that users can revisit at their convenience. This way, they can access messages right when they're relevant—for example, maybe pulling up a coupon while shopping. diff --git a/iterable-react-native/reference/overview.md b/iterable-react-native/reference/overview.md index ef38c56..f062cdf 100644 --- a/iterable-react-native/reference/overview.md +++ b/iterable-react-native/reference/overview.md @@ -2,15 +2,15 @@ slug: overview feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Overview of Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360045714072 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/overview/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: e8d2268f9774c59a5afee18ad77a5b20aba0a284 -fetched_at: 2026-08-28T14:44:26.800Z +fetched_at: 2026-10-19T10:21:40.833Z summary: Iterable's [React Native SDK](https://github.com/iterable/react-native-sdk) helps you integrate Iterable into iOS and Android applications built with [React diff --git a/iterable-react-native/reference/project-types-and-unique-identifiers.md b/iterable-react-native/reference/project-types-and-unique-identifiers.md new file mode 100644 index 0000000..5d2c386 --- /dev/null +++ b/iterable-react-native/reference/project-types-and-unique-identifiers.md @@ -0,0 +1,148 @@ +--- +slug: project-types-and-unique-identifiers +feature: user-profiles +archetype: prerequisite +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: Project Types and Unique Identifiers +source_url: https://support.iterable.com/hc/articles/9216719179796 +source_repo: Iterable/iterable-docs +source_path: docs/setup-and-administration/projects/project-types-and-unique-identifiers/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: a37d7e60a1978d0dceb462a9b64214f6fec36935 +fetched_at: 2026-10-22T10:00:26.559Z +summary: Every Iterable project uses a specific field (`email` and/or `userId`) + to uniquely identify each user. This allows an individual record to be + referenced without confusion. This selection has a lasting impact on how your + project works and how you access data via API. +--- +# Project Types and Unique Identifiers + +Every Iterable project uses a specific field (`email` and/or `userId`) to +uniquely identify each user. This allows an individual record to be referenced +without confusion. This selection has a lasting impact on how your project works +and how you access data via API. + +Continue reading to learn more about the different project types and unique +identifiers, as well as how to determine the best setting for your project. + +> [!NOTE] +> Currently, there's no way to change the unique identifier for an existing +> project. You can only make this selection when creating a project. + +## Project types + +In Iterable, you define a _project type_ by specifying which field to use +to identify users. + +Iterable supports these project types: + +- **Hybrid projects**: Use both `email` and `userId` as unique identifiers. +- **UserID-based projects**: Use `userId` as a unique identifier. +- **Email-based projects** (_Limited availability_): Use `email` as a unique + identifier. + - Only certain accounts have the option to create new email-based projects—[learn more](#project-type-availability). + +### Comparing project types + +Let's compare how different project types handle your data when creating and +identifying users. + +| |**Hybrid projects**|**UserID-based projects**|**Email-based projects** (_Limited availability_)| +|---|---|---|---| +|**Unique identifier** |`email` and `userId`
(at least one of these fields is required)|`userId`|`email`| +|**Enforce `email` uniqueness?**|Yes|No|Yes| +|**Enforce `userId` uniqueness?**|Yes|Yes|No| +|**[How to identify users in API requests](https://support.iterable.com/hc/articles/29156459027348)**|`email` or `userId`|`userId`|`email` or `userId`| +|**Contact lookup by...**|`email` or `userId`|`userId`|`email`| +|**Static subscription list field**|`userListIds`|`userListIds`|`emailListIds`| +|**"Placeholder" email addresses assigned to anonymous users**|No|No|Yes| +|**[Signup form](https://support.iterable.com/hc/articles/207914276) support**|Yes|No|Yes| +|**GDPR forget by...**|`email` or `userId`|`userId`|`email`| +|**[EDC](https://support.iterable.com/hc/articles/17572750887444) support**|Yes|Yes|No| +|**Available for new accounts?**|Yes|Yes|[No](#project-type-availability)| + +### Choosing a project type + +Because the unique identifier project setting has implications across your data +platforms and you can’t change it later, consult with your development teams and +other interested stakeholders (such as CRM administrators) _before_ creating +your project in Iterable. + +Consider these points when choosing a project type: + +- **UserID-based projects may be best when you need to...** + - Track anonymous users (see [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956)). + - Support multiple users with the same email address, or single users with + multiple accounts. + - Avoid using email as a parameter in your API requests. + +- **Hybrid projects allow you to...** + - Maintain email uniqueness (one email per user) and keep track of anonymous + users. + - Use `userId` as a primary identifier while ensuring that every user's email + address is unique. + +> [!NOTE] +> +> **Limitations:** +> +> - Email-based projects have [limited availability](#project-type-availability). +> +> - Iterable doesn't support multiple email addresses or phone numbers for a +> single user. +> +> - Iterable does not generate values for the `userId` field. You must include +> the value when creating users. To learn more, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). +> +> The only exception where Iterable generates a `userId` value is when using +> Unknown User Activation. To learn more, read [Unknown User Activation +> Overview](https://support.iterable.com/hc/articles/38755339847188). + +#### Project type availability + +Originally, all Iterable projects identified users by email address. However, +for greater flexibility and to encourage best practices in data management +and privacy, Iterable introduced userID-based and hybrid projects. These +projects can identify users by `userId` and/or `email`, depending on your data +needs. + +All Iterable accounts can create userID-based and hybrid projects. However, +email-based projects can only be created by Iterable accounts that meet _all_ +of the following criteria: + +- Organizations that are hosted in Iterable's USDC-based data center. +- Organizations that were created before May 28, 2024. +- Organizations that have created an email-based project since May 28, 2023. + +If your Iterable account doesn't meet these criteria, and you need to identify +users by `email`, create a hybrid project. + +## Unique identifiers + +Each user in an Iterable project must have a unique identifier, which depends +on the project type: + +- `email`, in email-based projects +- `userId`, in userID-based projects +- Either field, in hybrid projects + +To learn about the `email` and `userId` fields and their data requirements, see +[User Profile Fields Used by Iterable](https://support.iterable.com/hc/articles/217744303). + +To learn about how each field is handled across Iterable in the API, see +[Creating and Identifying Users with Iterable APIs](https://support.iterable.com/hc/articles/29156459027348). + +### Checking the unique identifier for a project + +To check the unique identifier(s) for your project, you can: + +- Go to **Settings > Project Settings** and see the project type under the + **Unique Identifier** setting. + +- Go to **Audience > Contact Lookup** and see which fields you can search for. + You can only find users by unique identifiers here. + +> [!NOTE] +> Currently, there's no way to change the unique identifier for an existing +> project. You can only make this choice when creating a project. \ No newline at end of file diff --git a/iterable-react-native/reference/push-notifications.md b/iterable-react-native/reference/push-notifications.md index 664830d..d78bd68 100644 --- a/iterable-react-native/reference/push-notifications.md +++ b/iterable-react-native/reference/push-notifications.md @@ -2,15 +2,15 @@ slug: push-notifications feature: push-notifications archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Push Notifications with Iterable's React Native SDK source_url: https://iterable.zendesk.com/hc/articles/360046134871 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/push-notifications/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: cd2ea10bb40d789aed557a58d5946ab34fb682e5 -fetched_at: 2026-08-28T14:44:26.827Z +fetched_at: 2026-10-19T10:21:43.013Z summary: Iterable's React Native SDK provides methods to track push notification-related events and to access a push notification's payload, as described below. diff --git a/iterable-react-native/reference/tracking-events.md b/iterable-react-native/reference/tracking-events.md index 51d53bf..5932d66 100644 --- a/iterable-react-native/reference/tracking-events.md +++ b/iterable-react-native/reference/tracking-events.md @@ -2,15 +2,15 @@ slug: tracking-events feature: event-tracking archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Tracking Events and Purchases with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360046134891 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/tracking-events/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 381795f3d7eabb6b0cf97de20ead603d22ff4cf9 -fetched_at: 2026-08-28T14:44:26.850Z +fetched_at: 2026-10-19T10:21:45.382Z summary: When users take particular actions in your mobile apps—open push notifications, complete their profiles, close in-app messages, etc.—Iterable's React Native SDK can save corresponding events, of custom or pre-defined diff --git a/iterable-react-native/reference/updating-a-users-email-address.md b/iterable-react-native/reference/updating-a-users-email-address.md new file mode 100644 index 0000000..ebc29be --- /dev/null +++ b/iterable-react-native/reference/updating-a-users-email-address.md @@ -0,0 +1,199 @@ +--- +slug: updating-a-users-email-address +feature: user-profiles +archetype: identity +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: Updating a User's Email Address or User ID +source_url: https://support.iterable.com/hc/articles/217502386 +source_repo: Iterable/iterable-docs +source_path: docs/developer-and-api-docs/api/updating-a-users-email-address/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: 510751fc6ae6e983ceae45146ba7e44f491ee7b2 +fetched_at: 2026-10-22T10:00:23.018Z +summary: Sometimes you'll need to change the email address or user ID for a + user—perhaps they were previously anonymous and now they have converted to a + known contact, their `email` has changed, or you are updating your project's + `userId` fields to standardize, correct, or otherwise update this value. +--- +# Updating a User's Email Address or User ID + +Sometimes you'll need to change the email address or user ID for a +user—perhaps they were previously anonymous and now they have converted to a +known contact, their `email` has changed, or you are updating your project's +`userId` fields to standardize, correct, or otherwise update this value. + +This article describes how to change these fields using Iterable's API. + +> [!WARNING] +> After updating a user profile (for example, by calling [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update)), +> wait 60 seconds before making other API calls related to the same user. +> +> For GDPR forget requests ([`POST /api/users/forget`](https://support.iterable.com/hc/articles/204780579#post-api-users-forget)), +> provide ten minutes to process. +> +> These delays can help prevent you from accidentally sending a campaign to the +> wrong email address, and other similar problems. + +## Email-based Projects + +### Updating email address in an email-based project + +Email-based projects identify users by `email`. To update a user's email +address, call [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail), +with a request body such as: + +```json +{ + "currentEmail": "current@example.com", + "newEmail": "new@example.com" +} +``` + +In the request body, specify `currentEmail` or `currentUserId`, but not both. +If you provide both, `currentEmail` takes precedence. + +### Updating user ID in an email-based project + +To add or update the `userId` field for a user in an email-based project, you +can call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +In the request body: + +- For `email`, specify the existing user's email address. +- In `dataFields`, provide a `userId`. + +For example: + +```json +{ + "email": "existingUser@example.com", + "dataFields": { + "userId": "newUserId" + } +} +``` + +For this request: + +- Iterable checks to see whether your project already has a user with `email` + set to `existingUser@example.com`. It not, the user is created. +- Whether the user is new or already exists, Iterable assigns a `userId` of + `newUserId` to the user. + +To add or update a user's `userId` in an email-based project, call +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with the new `userId` value inside of `dataFields`. + +> [!WARNING] +> **Email-based projects do not enforce `userId` uniqueness.** +> +> This means you won't receive an error if you update a user to have a non-unique +> `userId` value, and a duplicate `userId` will exist in your Iterable project. +> This may cause problems when making API calls based on `userId`. +> +> If you need unique `userId` values, consider creating a userID-based project or +> a hybrid project. To learn more, read [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +## UserID-based projects and hybrid projects + +### Updating email address in a userID-based project or hybrid project + +To update an existing user's `email` in a userID-based or hybrid project, call +[`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with a request body such as: + +```json +{ + "userId": "someUserId", + "dataFields": { + "email": "newEmail@example.com" + } + } +``` + +### Updating user ID in a userID-based project or hybrid project + +To update an existing user's `userId` in a userID-based or hybrid project, +call [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update), +with a request body such as: + +```json +{ + "userId": "oldUserId", + "dataFields": { + "userId": "newUserId" + } + } +``` + +## Common Errors + +There may be errors encountered when you are attempting to update a unique +identifier for a user. This happens because values are uniquely enforced and +conflicts can result in errors. + +### Unique `email` projects + +Both email-based projects and hybrid projects enforce a unique value for the +`email` field. + +When updating the `email` field, watch out for these common errors: + +- When `currentEmail` does not exist: + + ```json + { + "msg": "Cannot change user Some(oldEmail@example.com) to newEmail@example.com. User does not exist", + "code": "BadParams", + "params": null + } + ``` + +- When `newEmail` already exists: + + ```json + { + "msg": "Cannot change user Some(oldEmail@example.com) to newEmail@example.com. New email already exists", + "code": "EmailAlreadyExists", + "params": null + } + ``` + +- When `newEmail` has been GDPR forgotten: + + ```json + { + "msg": "Cannot change user oldEmail@example.com to newEmail@example.com. New email is on the forgotten list", + "code": "EmailAlreadyExists", + "params": null + } + ``` + +### Unique `userId` projects + +UserID-based projects and hybrid projects enforce unique `userId` values. Here +are some things you may sees when updating a `userId` value: + +- When the user you're trying to update doesn't actually exist, you'll receive + a `200` response, and Iterable creates the user with the new `userId`. + +- When the updated `userId` is already taken by another user: + + ```json + { + "msg": "userId already exists: newUserId", + "code": "ExternalKeyConflict", + "params": null + } + ``` + +- When the original `userId` has been GDPR forgotten: + +```json +{ + "msg": "User with userId originalUserID is forgotten", + "code": "ForgottenUserError", + "params": null +} +``` diff --git a/iterable-react-native/reference/user-id-validation-in-iterable.md b/iterable-react-native/reference/user-id-validation-in-iterable.md new file mode 100644 index 0000000..971b495 --- /dev/null +++ b/iterable-react-native/reference/user-id-validation-in-iterable.md @@ -0,0 +1,98 @@ +--- +slug: user-id-validation-in-iterable +feature: user-profiles +archetype: identity +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: User ID Validation in Iterable +source_url: https://support.iterable.com/hc/articles/41111923969556 +source_repo: Iterable/iterable-docs +source_path: docs/audience-management/users-and-user-profiles/user-id-validation-in-iterable/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: d2af79dc054d08c4d6149b66d9879935056b6856 +fetched_at: 2026-10-22T10:00:20.636Z +summary: "Whenever you create or update a user in Iterable, you can provide a + `userId` value. When you do this, Iterable performs the following validations + on the `userId` field:" +--- +# User ID Validation in Iterable + +Whenever you create or update a user in Iterable, you can provide a `userId` +value. When you do this, Iterable performs the following validations on the +`userId` field: + +- **Maximum length** of 128 characters. + +- **Must not contain non-printable ASCII characters**, such as + [non-breaking spaces](https://www.ascii-code.com/character/nbsp) and + [soft hyphens](https://www.ascii-code.com/character/shy). + + Non-printable characters are typically created programmatically and aren't + visible printed characters. Not to be confused with space, tab, or other + whitespace characters, or with [hard hyphen](https://www.ascii-code.com/character/-) + (also called a minus sign). + +- **Must not end with a space**. + +- Values are **case-sensitive**. + +- Iterable doesn't generate values for this field. You must include the value + when creating users. + +- Values should **not contain PII**, such as an email or phone number. + +- **Uniqueness** is: + - Enforced in userID-based and hybrid projects. + - _Not_ enforced in email-based projects. In the event the same `userId` is + associated with multiple users, there are some important considerations. To + learn more, read [Identifying users by `userId` in email-based projects](https://support.iterable.com/hc/articles/29156459027348#email-based-projects). + +## Providing a `userId` value + +Iterable does not generate values for the `userId` field. You must include +the value when creating users. + +> [!NOTE] +> The only exception is when you are using Unknown User Activation, in which case +> Iterable generates a `userId` value for you. To learn more, read [Unknown User +> Activation Overview](https://support.iterable.com/hc/articles/38755339847188). + +### User ID best practices + +Ideally, user ID values are static (unchanging), not guessable, and meaningless +outside of the context where it is used. + +Here are some good options for `userId` field values: + +- The same ID field you use to identify a user in your application—assuming + it meets these best practices. + +- The same ID field as another, primary system of record (such as your CRM, or + another integration that you rely on to store your user's data). + +- Randomly generated universally unique identifiers (UUIDs). [Learn more](https://en.wikipedia.org/wiki/Universally_unique_identifier). + +- Other database-generated unique IDs. + +Practices to avoid: + +- Anything that is considered personally identifiable information (or PII), such + as: + + - Names + - Email addresses + - Phone numbers + - Dates of birth + +- Any values that may be guessable, such as: + + - Formulas based on PII information + - Sequential values (10001, 10002, 10003 ... _n+1_) + - Usernames or "handles" + +> [!WARNING] +> +> **You can edit values in the `userId` field.** +> +> Values stored in the `userId` field are not permanent in Iterable. They can be +> overwritten or changed. Learn more at [Updating a User's Email Address or User ID](https://support.iterable.com/hc/articles/217502386). diff --git a/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md b/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md index 85580ab..fdb4fb7 100644 --- a/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md +++ b/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md @@ -2,16 +2,16 @@ slug: user-profile-data-and-subscription-preferences feature: user-profiles archetype: identity -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Managing User Profile Data and Subscription Preferences with Iterable's React Native SDK source_url: https://iterable.zendesk.com/hc/articles/360046134851 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/user-profile-data-and-subscription-preferences/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 1fa754a89e67cfe38db325cc0e0e2cf2ecd6880f -fetched_at: 2026-08-28T14:44:26.855Z +fetched_at: 2026-10-19T10:21:45.847Z summary: As you learn new things about your mobile app's users, you may want to save data to their Iterable profiles. Your marketing team can then use this information to send them personalized messages. diff --git a/iterable-react-native/reference/user-profile-fields-used-by-iterable.md b/iterable-react-native/reference/user-profile-fields-used-by-iterable.md new file mode 100644 index 0000000..0d5412b --- /dev/null +++ b/iterable-react-native/reference/user-profile-fields-used-by-iterable.md @@ -0,0 +1,517 @@ +--- +slug: user-profile-fields-used-by-iterable +feature: user-profiles +archetype: identity +sdk_min_version: 3.2.1 +sdk_artifact: "@iterable/react-native-sdk" +title: User Profile Fields Used by Iterable +source_url: https://support.iterable.com/hc/articles/217744303 +source_repo: Iterable/iterable-docs +source_path: docs/audience-management/users-and-user-profiles/user-profile-fields-used-by-iterable/index.md +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 +source_sha: 51d92087789bba35ac0db9c47d2a9e9c3c7bdfd8 +fetched_at: 2026-10-22T10:00:21.283Z +summary: User profiles in Iterable contain various fields that Iterable uses for + specific purposes. This document describes these fields and their meanings. +--- +# User Profile Fields Used by Iterable + +User profiles in Iterable contain various fields that Iterable uses for specific +purposes. This document describes these fields and their meanings. + +> [!NOTE] +> The character limit for user profile fields is approximately 32k characters. +> All user profile fields are case-sensitive and space-sensitive. + +## Fields used as unique identifiers + +Each user in an Iterable project must have a unique identifier. Two fields are +available in Iterable to uniquely identify users: `email` and `userId`. When you +create your project, you will choose a _project type_. The project type +determines which field(s) you can use to identify your users. It also impacts +how you manage records in Iterable. + +For more information, see [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). For information about how these fields are used with +Iterable’s APIs, see [Creating and Identifying Users with Iterable APIs](https://support.iterable.com/hc/articles/29156459027348). + +### Identifying users by `email` + +Iterable uses the [`email`](#email) field as a unique identifier for users in +hybrid and email-based projects. To learn more about the `email` field and +accepted values, see [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +In hybrid and email-based projects, each user's `email` **must be unique**. + +In userID-based projects, `email` uniqueness is not enforced and there may +be multiple users with the same `email` address. + +### Identifying users by `userId` + +The [`userId`](#userid) field stores a unique value that you've assigned to +identify each user. To learn more about the `userId` field and accepted values, +see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + +In userID-based and hybrid projects, each user's `userId` **must be unique**. + +In email-based projects, unique values for `userId` are not required or +enforced. In the event the same `userId` is associated with multiple users, +there are some important considerations. To learn more, read +[Identifying users by `userId` in email-based projects](https://support.iterable.com/hc/articles/29156459027348#email-based-projects). + +## Fields managed by Iterable + +Iterable manages the fields described below. These fields are visible in the +responses to various API calls, but (unless otherwise noted) they should not +be included in calls to [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update) +or other endpoints that update user profiles. + +### `emailListIds` + +An array of integers that represent the static lists of which a user is a +member. The values in this array change as the user is added to or removed +from lists. + +This array does not contain IDs for dynamic lists. + +This field applies to email-based projects only. For userId and hybrid +projects, Iterable uses [`userListIds`](#userlistids). + +### `itblInternal` + +A collection of various fields managed by Iterable. + +### `itblInternal.phoneCountry` + +A two-letter code that represents the country associated with the user's +[`phoneNumber`](#phonenumber), as defined by [ISO-3166](https://www.iso.org/iso-3166-country-codes.html). + +When you send an SMS campaign to a user, Iterable checks to see if the user has +`itblInternal.phoneCountry` set. If it isn't set, Iterable uses `phoneNumber` +to look up the ISO-3166 country code, and adds that country code to the user's +profile. + +Currently, this field is only used for reporting purposes, and is not used to +determine a user's language or system actions related to a user's country. + +> [!TIP] +> +> There are a couple of ways to measure SMS success by country: +> +> - [Campaign Analytics](https://support.iterable.com/hc/articles/218203223#segment-analysis) — +> Create a segment analysis using `itblInternal.phoneCountry`. +> - [Messaging Insights](https://support.iterable.com/hc/articles/360052814452) - +> In the **Audience** view, select the `itblInternal.phoneCountry` segment. + +### `itblInternal.phoneType` + +Iterable also stores the user's phone type in the `itblInternal.phoneType` field, +which is a string that represents the type of phone number the user has. + +This is an informational field that you may find useful when reviewing your user +profiles or creating segments. For example, some number types can be blocked from +receiving SMS messages, which is helpful when determining the size of your SMS-ready +audience. + +This field may have one of the following values: +- `MOBILE` - Mobile phone number +- `FIXED_LINE` - Fixed line phone number +- `FIXED_LINE_OR_MOBILE` - Fixed line or mobile phone number +- `TOLL_FREE` - Toll-free number +- `PREMIUM_RATE` - Premium rate number +- `SHARED_COST` - Shared cost number +- `VOIP` - Voice over IP number +- `PERSONAL_NUMBER` - Personal number +- `PAGER` - Pager number + +### `itblDS` + +An object containing information related to [Iterable AI](https://support.iterable.com/hc/articles/360052990091). +Its `brandAffinityLabel` field represents the contact's level of engagement +with your brand, as determined by [Brand Affinity™](https://support.iterable.com/hc/articles/360052990191). +Its `predictiveGoals` field provides user conversion scores for goals that are +created in Iterable's [Predictive Goals](https://support.iterable.com/hc/articles/5026416447636#view-a-user-s-conversion-scores). + +### `itblUserId` + +A unique identifier used internally by Iterable for hybrid and userID-based +projects. Using this field to reference a user is NOT recommended. Instead, use +[`userId`](#userid), which is a field that you can access and manage. + +### `knownLitigatorFilter` + +A string that represents the confidence level of the user's known litigator status. +The possible values are `LT1`, `LT2`, and `LT3`. The `LT1` value denotes high +confidence that the phone number has been involved in TCPA litigation, while the +value `LT2` denotes a lower confidence level. If a user is detected as both LT1 +and LT2, the field will be updated to `LT3`. + +### `profileUpdatedAt` + +The most recent time at which a user's profile was updated (via CSV upload or +API call). This field is a date, and it has the following format: +`2016-08-02 18:53:45 +00:00`. For more information, read +[Field Data Types](https://support.iterable.com/hc/articles/208183076). + +> [!NOTE] +> Previously, Iterable would process user update requests that didn't actually +> change the user's profile data. This resulted in an update to the +> `profileUpdatedAt` field even though the user profile hadn't actually changed. +> As of February 2023, `profileUpdatedAt` is no longer modified when user profile +> data has not changed. To learn more about this change, see +> [2023 Release Notes](https://support.iterable.com/hc/articles/11667879575572#february-2023). +> +> In the rare case you need the original behavior, talk to your customer success +> manager. + +### `receivedSMSDisclaimer` + +Indicates whether or not the user has received an SMS message, from the current +Iterable project, that includes SMS opt-out instructions. Receiving opt-out +instructions in a proof will not set this field to `true`. + +### `signupDate` + +The date when the user's profile was created in Iterable. This field is a date, and +it has the following format: `2016-08-02 18:53:45 +00:00`. For more information, +read [Field Data Types](https://support.iterable.com/hc/articles/208183076). + +### `signupSource` + +How the contact was created in Iterable (for example, `API` or `IMPORT`). + +### `subscribedMessageTypeIds` + +An array of integers that represent the opt-in message types to which a contact has +subscribed. Contacts will receive messages associated with opt-in message types only +if both of the following are true: + +- They have subscribed to those opt-in message types +- They have not unsubscribed from the channels associated with those types + +> [!WARNING] +> `subscribedMessageTypeIds` is available only for accounts or projects for which +> [opt-in message types](https://support.iterable.com/hc/articles/204780529) have +> been enabled. To do so, contact your Iterable customer success manager. + +### `unsubscribedChannelIds` + +An array of integers that represent the message channels from which a user has +unsubscribed. Users will not receive messages from any message type belonging to a +message channel from which they have unsubscribed. + +### `unsubscribedMessageTypeIds` + +An array of integers that represent the opt-out message types from which a user has +unsubscribed. + +Users will receive messages from opt-out message types only if both of the following +are true: + +- They have not unsubscribed from those opt-out message types +- They have not unsubscribed from the channels associated with those types + +### `userListIds` + +An array of integers that represent the static lists of which a user is a +member. The values in this array change as the user is added to or removed +from lists. + +This array does not contain IDs for dynamic lists. + +This field applies to userId and hybrid projects. For email-based projects, +Iterable uses [`emailListIds`](#emaillistids). + +## Fields used for sending messages + +When sending messages, Iterable relies on the fields described below. + +> [!WARNING] +> The specific names of these fields are important. For example, Iterable uses the +> `phoneNumber` field when sending SMS messages; it won't recognize `phone_number` +> or `PhoneNumber`. + +### `country` + +The user's country. + +**Accepted values** - Any string value. This field has a string data type and +is not validated. When you add this data to Iterable, you can enter any value +your company uses to represent a user's country. + +Alternatively, you can set up your project so that Iterable automatically looks +up the country based on the user's IP address. When Iterable stores the +`country` field, it uses the English-language name of the country, as found in +the [GeoNames database](https://www.geonames.org/countries/). + +When using SMS Smart Opt-In, Iterable uses the `country` field to assign a +number from your SMS senders that matches the user's country. For complete +detail on how `country` is used to assign a number, see +[SMS Smart Opt-In](https://support.iterable.com/hc/articles/10870034844308#how-sms-smart-opt-in-selects-a-number-for-the-user-to-reply-to). + +Iterable can automatically populate the `country` field based on the user's IP +address. This lookup is triggered whenever the `ip` field is updated on the +user profile. + +To learn more about updating a user's IP and/or geolocation fields using +inbound data, read [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `devices` + +A nested data type that contains an array of objects, each representing a +mobile device/mobile app combination for which a user can (or used to be able +to) receive push notifications (silently or otherwise). + +Each object in the `devices` array contains the following fields: + +- `applicationName` - A string that identifies the mobile app for which the user + can receive push notifications. + +- `appPackageName` - The unique application identifier associated with your app + (as specified by its developers). Different for iOS and Android. + +- `appVersion` - The version of your app running on the user's device. + +- `deviceId` - A randomly generated UUID for a user's device. Generated by Iterable + and stored in local storage on the device, and in Iterable itself. When the + user re-installs the app, a new one is created (we don't track a device across + app reinstalls). + +- `endpointEnabled` - Indicates whether or not Iterable can send push notifications + to this device. If a user prevents an app from displaying push notifications, + but after the app has already registered its device token with Iterable, the + app's code should disable the device in Iterable by calling [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice). + This sets `endpointEnabled` to `false` for this device, preventing Iterable from + sending future push campaigns to it. If the app does not disable the device, + Iterable will continue to send push campaigns to it. The user will never see those + campaigns, but they'll still be counted in the campaign's total send count. + To check if Iterable can send push notifications to a given device/app + combination, check its `endpointEnabled` field. If `true`, Iterable can send push + notifications to that device. + +- `identifierForVendor` - iOS only. An alphanumeric string that uniquely identifies a + device to the app’s vendor. Unique to that device for that app developer. + +- `iterableSdkVersion` - The release version of the SDK. Numbers will differ + by platform (iOS, Android, React Native), correspond with the release numbers + on our Github pages ([iOS](https://github.com/Iterable/iterable-swift-sdk/releases), + [Android](https://github.com/Iterable/iterable-android-sdk/tags), + [React Native](https://github.com/Iterable/react-native-sdk/releases)). + +- `localizedModel` - iOS only. the model of the device. For example: "iPhone", + "iPad", "iPod Touch". + +- `model` - The model name of the device. Apple examples: "iPhone", "iPad", "iPod Touch". + Android example - "sdk_gphone_x86_arm". + +- `notificationsEnabled` - whether or not push notifications are enabled on the + device. + +- `platform` - The platform from which the device can receive push notifications. + For example, `APNS_SANDBOX`. + +- `platformEndpoint` - The specific address Iterable uses to reach the device + by push notification. + +- `systemVersion`: The version number of the device's operating system. + +- `token` - A string Iterable uses to identify the device when sending it a push + notification. + +- `userInterfaceIdiom`: iOS only. The device's category. For example: "Phone", + "Pad", "carPlay". + +> [!TIP] +> **TIPS** +> +> - To add a device to this list (or re-enable one that is already there), call +> [`POST /api/users/registerDeviceToken`](https://support.iterable.com/hc/articles/204780579#post-api-users-registerdevicetoken). +> - To disable a device on the list, so that it will no longer receive messages, call +> [`POST /api/users/disableDevice`](https://support.iterable.com/hc/articles/204780579#post-api-users-disabledevice). + +### `email` + +A user's email address. + +- This field is validated. To learn more, see [Email Validation in Iterable](https://support.iterable.com/hc/articles/209082806). + +- Iterable uses this field to send email campaigns to users. + + - An exception to this is anonymous users with `@placeholder.email`. These users + have placeholders in order to identify the user in Iterable, but their `email` + is not a valid recipient address. Email campaigns sent to `@placeholder.email` + addresses are skipped. (Send skip events are not logged for this scenario.) See + [Handling Anonymous Users](https://support.iterable.com/hc/articles/208499956) + to learn more about placeholder emails. + +- `email` is one of two possible fields that uniquely identify Iterable users, + along with [`userId`](#userid). To continue learning about the `email` field, + unique identifiers, and how they work, read + [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +### `ip` + +The user's IP address. + +Iterable can automatically update the `ip` field based on the IP address +contained in inbound email click (`emailClick`) and/or SMS click (`smsClick`) +events. This ensures that the `ip` field is refreshed regularly for your most +engaged users. To learn more, read [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `locale` + +A user's location and/or language preference. If you have multiple locale +variants of a template (all with the same `templateId`), Iterable uses this +field to determine which version to send to the user. + +This field maps to the codes you set up in the [Locales](https://support.iterable.com/hc/articles/12441621049876) +section of your Project Settings page. + +### `phoneNumber` + +A string that represents the user's phone number. Iterable uses this number when +sending SMS messages. + +Values must be formatted using [E.164](https://en.wikipedia.org/wiki/E.164). +For example: + +- US: `+14155550132` +- Germany: `+498999998000` + +When you send an SMS to the user, Iterable checks to see if the user has +[`itblInternal.phoneCountry`](#itblinternal-phonecountry) set. If it doesn't, +Iterable uses `phoneNumber` to look up the country code and add it to the user's +profile. + +Iterable doesn't send SMS campaigns to users without a `phoneNumber` set. + +> [!WARNING] +> When updating a `phoneNumber`, if you don't include a country code, Iterable +> automatically adds `+1` (the country code for USA, Canada, and parts of the +> Caribbean) to the front of the number. +> +> For example, if you try to update a user's number to `4155550132`, Iterable +> saves it in the user's profile as `+14155550132`. + +### `profile` + +:::v-pre +A hidden field housing all of a user profile data. Fields at the root level on +the user profile can be referenced via merge tags by writing either +`{{firstName}}` or `{{profile.firstName}}`. +::: +To avoid confusion, do not use `profile` as a field name in your user profiles. + +### `timeZone` + +The user's time zone, which Iterable uses for the following features: + +- [Send Time Optimization](https://support.iterable.com/hc/articles/360050923471) +- [Quiet Hours](https://support.iterable.com/hc/articles/16928857601940) +- [Sending in Different Time Zones](https://support.iterable.com/hc/articles/14761834767508#sending-in-different-time-zones) + +Values are case-sensitive and must be precisely formatted, as described by the +[IANA time zone database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +Examples: + + - `America/Los_Angeles` + - `Europe/London` + - `Asia/Tokyo` + +You can set values in the `timeZone` field by uploading your own data to the +user profile using any data import method (such as API, CSV, or Smart Ingest), +or by enabling project settings so Iterable can automatically update the +`timeZone` field. + +Iterable provides project settings that capture each user's IP address, and +look up the user's time zone based on their IP address. This ensures that the +`timeZone` field is refreshed regularly for your most engaged users. + +To learn more about updating a user's IP and/or geolocation fields using +email or SMS click events, or to learn how to set up your project so that +Iterable can automatically update the `timeZone` field, see [Automatically populating IP and geolocation fields](#automatically-populating-ip-and-geolocation-fields) below. + +### `userId` + +The ID of a user. This is always a value which you've either assigned or +imported from another source. To learn more about the `userId` field and +accepted values, see [UserID Validation in Iterable](https://support.iterable.com/hc/articles/41111923969556). + +> [!NOTE] +> This is one of two possible fields that uniquely identify Iterable users, +> along with [`email`](#email). To continue learning about the `userId` field, +> unique identifiers, and how they work, see our guide +> [Project Types and Unique Identifiers](https://support.iterable.com/hc/articles/9216719179796). + +### `whatsAppPhoneNumber` + +A string that represents the user's WhatsApp phone number. Iterable uses this number when +sending WhatsApp messages. + +Values must be formatted using [E.164](https://en.wikipedia.org/wiki/E.164). +For example: +- US: `+14155550132` +- Germany: `+498999998000` + +## Automatically populating IP and geolocation fields + +If enabled for your project, Iterable can automatically populate the following +geolocation fields: `ip`, `city`, `region`, `country`, and `timeZone`. This is +helpful for campaigns that use [Quiet Hours](https://support.iterable.com/hc/articles/16928857601940) +and [send in different time zones](https://support.iterable.com/hc/articles/14761834767508#sending-in-different-time-zones), +as Iterable can use the user's IP address to infer their time zone and send +messages at the most appropriate time. IP lookup is skipped when the event's +`userAgent` contains "GoogleImageProxy" (for example, when a user opens an email +from Gmail). + +To have Iterable automatically update user fields based on their IP geolocation: + +1. Set up click tracking for your email and/or SMS campaigns: + - [Email link tracking](https://support.iterable.com/hc/articles/7130133854612#step-3-set-up-tracking-domains) + must be enabled to capture the user's IP address on email click events. + - [SMS shortlinks](https://support.iterable.com/hc/articles/4403865896724) + must be enabled to capture the user's IP address on SMS click events. + +2. Update the user's `ip` field with the [**Overwrite IP Field**](https://support.iterable.com/hc/articles/206382645#overwrite-ip-field) + project setting. This automatically captures a user's IP address each time + they click a link in an email and/or SMS campaign. + +3. Turn on the [**Overwrite Location Fields**](https://support.iterable.com/hc/articles/206382645#overwrite-location-fields) + project setting to update the `city`, `region`, `country`, and `timeZone` + fields based on the user's IP address, every time the `ip` field is updated. + +## Other reserved field names + +The following field names are reserved by Iterable, and you can't manually set +them: + +- `campaignId` +- `channelId` +- `eventName` +- `messageTypeId` +- `templateId` +- `total` + +Also avoid using `size` as a field name. You can do it, but referencing +such a field can be unreliable, since it sometimes references the +[number of items in an object or array](https://support.iterable.com/hc/articles/36531450481300#display-the-size-of-an-array). + +## Common event fields + +You'll often see the following fields on Iterable system events: + +- `messageId`: A unique identifier for a particular send of a particular campaign + to a particular user. +- `contentId`: An ID associated with the specific template revision sent to the + user for a campaign. +- `catalogLookupCount`: The number of times a catalog was referenced in the message. +- `catalogCollectionCount`: The number of times a catalog collection was referenced + in the message. + +## Want to learn more? + +For more information about some of the topics in this article, check out this +Iterable Academy course. Iterable Academy is open to everyone — you don't need +to be an Iterable customer! + +- [Foundations: Setting Up Senders](https://academy.iterable.com/setting-up-senders) \ No newline at end of file diff --git a/pipeline/config/android.yml b/pipeline/config/android.yml index 0d5d19a..ac46ec4 100644 --- a/pipeline/config/android.yml +++ b/pipeline/config/android.yml @@ -95,6 +95,33 @@ articles: feature: unknown-user-activation archetype: identity + # Identity rules the skill's Step 0 decisions cite. Platform-neutral, so all + # three platform configs pull the same paths. + - slug: user-id-validation-in-iterable + source_path: docs/audience-management/users-and-user-profiles/user-id-validation-in-iterable/index.md + feature: user-profiles + archetype: identity + + - slug: user-profile-fields-used-by-iterable + source_path: docs/audience-management/users-and-user-profiles/user-profile-fields-used-by-iterable/index.md + feature: user-profiles + archetype: identity + + - slug: handling-anonymous-users + source_path: docs/developer-and-api-docs/api/handling-anonymous-users/index.md + feature: user-profiles + archetype: identity + + - slug: creating-users-with-apis + source_path: docs/developer-and-api-docs/api/creating-users-with-APIs/index.md + feature: user-profiles + archetype: identity + + - slug: updating-a-users-email-address + source_path: docs/developer-and-api-docs/api/updating-a-users-email-address/index.md + feature: user-profiles + archetype: identity + # `prerequisite` articles document Iterable's web app, not the SDK. They cover # the Preflight inputs a developer who owns dashboard access has to create # themselves; platform-neutral, so react-native.yml pulls the same paths. @@ -108,6 +135,16 @@ articles: feature: jwt-authentication archetype: prerequisite + - slug: project-types-and-unique-identifiers + source_path: docs/setup-and-administration/projects/project-types-and-unique-identifiers/index.md + feature: user-profiles + archetype: prerequisite + + - slug: european-data-center + source_path: docs/welcome-to-iterable/about-iterable/iterables-european-data-center-edc/index.md + feature: integration + archetype: prerequisite + - slug: fcm-http-v1-migration source_path: docs/sending-messages-by-channel/push-notification/mobile-push-notifications-migrating-to-the-fcm-http-v1-api/index.md feature: push-notifications diff --git a/pipeline/config/ios.yml b/pipeline/config/ios.yml index 5dd9563..8a5be33 100644 --- a/pipeline/config/ios.yml +++ b/pipeline/config/ios.yml @@ -141,6 +141,33 @@ articles: feature: unknown-user-activation archetype: identity + # Identity rules the skill's Step 0 decisions cite. Platform-neutral, so all + # three platform configs pull the same paths. + - slug: user-id-validation-in-iterable + source_path: docs/audience-management/users-and-user-profiles/user-id-validation-in-iterable/index.md + feature: user-profiles + archetype: identity + + - slug: user-profile-fields-used-by-iterable + source_path: docs/audience-management/users-and-user-profiles/user-profile-fields-used-by-iterable/index.md + feature: user-profiles + archetype: identity + + - slug: handling-anonymous-users + source_path: docs/developer-and-api-docs/api/handling-anonymous-users/index.md + feature: user-profiles + archetype: identity + + - slug: creating-users-with-apis + source_path: docs/developer-and-api-docs/api/creating-users-with-APIs/index.md + feature: user-profiles + archetype: identity + + - slug: updating-a-users-email-address + source_path: docs/developer-and-api-docs/api/updating-a-users-email-address/index.md + feature: user-profiles + archetype: identity + - slug: api-keys source_path: docs/developer-and-api-docs/api/api-keys/index.md feature: integration @@ -150,3 +177,13 @@ articles: source_path: docs/developer-and-api-docs/api/jwt-enabled-api-keys/index.md feature: jwt-authentication archetype: prerequisite + + - slug: project-types-and-unique-identifiers + source_path: docs/setup-and-administration/projects/project-types-and-unique-identifiers/index.md + feature: user-profiles + archetype: prerequisite + + - slug: european-data-center + source_path: docs/welcome-to-iterable/about-iterable/iterables-european-data-center-edc/index.md + feature: integration + archetype: prerequisite diff --git a/pipeline/config/pitfalls/android.yml b/pipeline/config/pitfalls/android.yml index 7554550..c10ada6 100644 --- a/pipeline/config/pitfalls/android.yml +++ b/pipeline/config/pitfalls/android.yml @@ -42,7 +42,7 @@ pitfalls: features: [integration, user-profiles] - id: 5 - title: Custom deep-link scheme dropped silently + title: URL scheme missing from setAllowedProtocols triggers: - "setAllowedProtocols" - "custom URL scheme" @@ -163,7 +163,7 @@ pitfalls: features: [integration, push-notifications] - id: 20 - title: Link tap routed to a handler that isn't set (silently dropped) + title: Link tap routed to a handler that isn't set triggers: - "setUrlHandler" - "setCustomActionHandler" @@ -204,3 +204,20 @@ pitfalls: - "OneSignal" - "Braze" features: [push-notifications, integration] + + - id: 24 + title: Link bounces between the SDK and the app + triggers: + - "handleAppLink" + - "ACTION_VIEW" + - "isIterableDeeplink" + - "loop" + features: [deep-links, integration] + + - id: 25 + title: App Links reference uses names that don't compile + triggers: + - "getAndTrackDeeplink" + - "isIterableDeepLink" + - "android-app-links" + features: [deep-links] diff --git a/pipeline/config/pitfalls/ios.yml b/pipeline/config/pitfalls/ios.yml index 5acab63..18dcb4a 100644 --- a/pipeline/config/pitfalls/ios.yml +++ b/pipeline/config/pitfalls/ios.yml @@ -264,3 +264,12 @@ pitfalls: - "nearly matches" - "continue userActivity" features: [deep-linking] + + - id: 29 + title: No registerToken log line is read as no registration + triggers: + - "registerToken" + - "Device registration succeeded" + - "ITBDebug" + - "logDelegate" + features: [push-notifications] diff --git a/pipeline/config/pitfalls/react-native.yml b/pipeline/config/pitfalls/react-native.yml index 7ac8f26..49f5cc1 100644 --- a/pipeline/config/pitfalls/react-native.yml +++ b/pipeline/config/pitfalls/react-native.yml @@ -8,10 +8,11 @@ # Keep `triggers` narrow enough to avoid noise but broad enough to catch # the obvious mentions. Over-triggering is fine — a human reviews. # -# Sourced from RN SDK 3.1.0 master (public exports vs NativeRNIterableAPI +# Sourced from RN SDK 3.2.1 (public exports vs NativeRNIterableAPI # spec, README native-version table, CHANGELOG init-promise fix, example/). # Ids 7–11 and 13–14 are Expo config-plugin traps. Id 12 is Xcode 27 iOS -# (bare and Expo). Apply Expo ids only after SKILL.md Step 0 classifies Expo. +# (bare and Expo). 19–20 are the iOS Release Simulator build, 21 JavaScript, +# 22 bare iOS permission. Apply Expo ids only after SKILL.md Step 0 classifies Expo. platform: react-native @@ -148,3 +149,63 @@ pitfalls: - "@react-native-firebase/messaging" - "react-native-onesignal" features: [push-notifications, integration] + + - id: 16 + title: Link bounces between the SDK and the app + triggers: + - "urlHandler" + - "handleAppLink" + - "Linking.openURL" + - "Linking.addEventListener" + features: [deep-links, integration] + + - id: 17 + title: "Expo: the plugin does not configure deep links" + triggers: + - "associatedDomains" + - "intentFilters" + - "applinks:" + features: [deep-links, integration] + + - id: 18 + title: npm install returns 401 for @iterable packages + triggers: + - "E401" + - "npm.pkg.github.com" + - "@iterable:registry" + - ".npmrc" + features: [integration] + + - id: 19 + title: "iOS: a stale .app installed for proof" + triggers: + - "derivedDataPath" + - "DerivedData" + - "Release-iphonesimulator" + - "simctl install" + features: [push-notifications, integration] + + - id: 20 + title: "iOS: code signing disabled strips aps-environment" + triggers: + - "CODE_SIGNING_ALLOWED=NO" + - "CODE_SIGNING_REQUIRED=NO" + - "CODE_SIGN_IDENTITY=\"\"" + - "CODE_SIGN_ENTITLEMENTS" + - "aps-environment" + features: [push-notifications, integration] + + - id: 21 + title: Iterable.registerForPush() does not exist + triggers: + - "registerForPush" + - "autoPushRegistration" + features: [push-notifications, integration] + + - id: 22 + title: "Bare iOS: nothing asks for notification permission" + triggers: + - "requestAuthorization" + - "UNUserNotificationCenter" + - "react-native-permissions" + features: [push-notifications, integration] diff --git a/pipeline/config/react-native.yml b/pipeline/config/react-native.yml index 6fae716..4b704c8 100644 --- a/pipeline/config/react-native.yml +++ b/pipeline/config/react-native.yml @@ -11,7 +11,12 @@ # # Expo is one article in this set, not a separate platform. The second # package for Expo projects (`@iterable/expo-plugin`) is recorded in -# SKILL.md / PITFALLS.md, not in `sdk.tag` (that pin is the RN SDK). +# SKILL.md / PITFALLS.md, not in `sdk.tag` (that pin is the RN SDK). It stays +# at 1.1.0: npm's 1.2.0 is deprecated ("Published accidentally; use 1.1.0 +# instead") and built from the same commit. +# +# 3.2.1, not 3.2.0: 3.2.0's Android module did not compile against its own +# native pin (CHANGELOG 3.2.1). platform: react-native @@ -22,7 +27,7 @@ source: sdk: repo: Iterable/react-native-sdk - tag: 3.1.0 + tag: 3.2.1 artifact: "@iterable/react-native-sdk" changelog_path: CHANGELOG.md @@ -80,6 +85,38 @@ articles: feature: user-profiles archetype: identity + - slug: identifying-the-user + source_path: docs/developer-and-api-docs/managing-user-profiles/identifying-the-user/index.md + feature: user-profiles + archetype: identity + + # Identity rules the skill's Step 0 decisions cite. Platform-neutral, so all + # three platform configs pull the same paths. + - slug: user-id-validation-in-iterable + source_path: docs/audience-management/users-and-user-profiles/user-id-validation-in-iterable/index.md + feature: user-profiles + archetype: identity + + - slug: user-profile-fields-used-by-iterable + source_path: docs/audience-management/users-and-user-profiles/user-profile-fields-used-by-iterable/index.md + feature: user-profiles + archetype: identity + + - slug: handling-anonymous-users + source_path: docs/developer-and-api-docs/api/handling-anonymous-users/index.md + feature: user-profiles + archetype: identity + + - slug: creating-users-with-apis + source_path: docs/developer-and-api-docs/api/creating-users-with-APIs/index.md + feature: user-profiles + archetype: identity + + - slug: updating-a-users-email-address + source_path: docs/developer-and-api-docs/api/updating-a-users-email-address/index.md + feature: user-profiles + archetype: identity + - slug: expo source_path: docs/developer-and-api-docs/iterables-react-native-sdk/expo/index.md feature: integration @@ -108,6 +145,16 @@ articles: feature: jwt-authentication archetype: prerequisite + - slug: project-types-and-unique-identifiers + source_path: docs/setup-and-administration/projects/project-types-and-unique-identifiers/index.md + feature: user-profiles + archetype: prerequisite + + - slug: european-data-center + source_path: docs/welcome-to-iterable/about-iterable/iterables-european-data-center-edc/index.md + feature: integration + archetype: prerequisite + - slug: fcm-http-v1-migration source_path: docs/sending-messages-by-channel/push-notification/mobile-push-notifications-migrating-to-the-fcm-http-v1-api/index.md feature: push-notifications diff --git a/tests/agent-next-action.sh b/tests/agent-next-action.sh index ec65fcb..3e640a2 100755 --- a/tests/agent-next-action.sh +++ b/tests/agent-next-action.sh @@ -26,6 +26,7 @@ export WS="$TMP/ws"; mkdir -p "$WS/artifacts" # PATH is replaced rather than prefixed, so removing a stub really removes the # binary. Prefixing left the developer's own adb one entry further down, and the # missing-tool case passed by accident against a machine that had everything. +REAL_NODE="$(command -v node)" STUB="$TMP/bin"; mkdir -p "$STUB" for c in node gcloud adb; do printf '#!/bin/sh\nexit 0\n' > "$STUB/$c"; chmod +x "$STUB/$c"; done export PATH="$STUB:/usr/bin:/bin" @@ -393,6 +394,121 @@ DRIVER=agent state "$(row G16 green tool "Push arrives on device" "arrived 2s after send")" expect none done "a push reached the device" "all green — nothing left" +# ------------------------------------------------------------ the app's own push +# An app that keeps its own push gets one optional line on done, and nothing else moves. +NODE_BIN="$TMP/node-bin"; mkdir -p "$NODE_BIN"; ln -s "$REAL_NODE" "$NODE_BIN/node" +OWN="$TMP/own-push" +mkdir -p "$OWN/app/src/main/java/com/example" "$OWN/app/build/generated" +cat > "$OWN/app/src/main/java/com/example/AppMessagingService.kt" <<'EOF' +package com.example +class AppMessagingService : FirebaseMessagingService() { + override fun onMessageReceived(message: RemoteMessage) { + if (IterableFirebaseMessagingService.handleMessageReceived(this, message)) return + OrderNotifications.show(this, message.data) + } + override fun onNewToken(token: String) { + IterableFirebaseMessagingService.handleTokenRefresh() + OrderApi.registerToken(token) + } +} +EOF +cat > "$OWN/app/src/main/AndroidManifest.xml" <<'EOF' + + + + + + + +EOF +printf 'class Decoy : FirebaseMessagingService() { override fun onNewToken(t: String) { Decoy.send(t) } }\n' \ + > "$OWN/app/build/generated/Decoy.kt" +SIGNAL="$TMP/onesignal" +mkdir -p "$SIGNAL/app" +printf "dependencies {\n implementation 'com.iterable:iterableapi:3.6.2'\n implementation 'com.onesignal:OneSignal:[5.0.0, 5.99.99]'\n}\n" > "$SIGNAL/app/build.gradle" +ONLY="$TMP/iterable-only" +mkdir -p "$ONLY/app/src/main/java/com/example" "$ONLY/app/src/test/java" +cat > "$ONLY/app/build.gradle" <<'EOF' +dependencies { + implementation 'com.iterable:iterableapi:3.6.2' + implementation 'com.google.firebase:firebase-messaging:24.1.0' + // implementation 'com.onesignal:OneSignal:5.1.0' +} +EOF +cat > "$ONLY/app/src/main/java/com/example/ForwardingService.java" <<'EOF' +package com.example; +public class ForwardingService extends FirebaseMessagingService { + @Override public void onMessageReceived(RemoteMessage message) { + Log.d("push", "received"); + IterableFirebaseMessagingService.handleMessageReceived(this, message); + } + @Override public void onNewToken(String token) { + super.onNewToken(token); + IterableFirebaseMessagingService.handleTokenRefresh(); + } +} +EOF +cat > "$ONLY/app/src/main/AndroidManifest.xml" <<'EOF' + + + + + + + + + + + +EOF +printf 'class OnlyInTests : FirebaseMessagingService() { override fun onNewToken(t: String) { Spy.send(t) } }\n' \ + > "$ONLY/app/src/test/java/OnlyInTests.kt" +RN_ANDROID="$TMP/rn-android" +mkdir -p "$RN_ANDROID/android/app" +printf '{"dependencies":{"react-native":"0.88.0","@iterable/react-native-sdk":"3.0.0","@react-native-firebase/messaging":"23.0.0"}}\n' > "$RN_ANDROID/package.json" +printf "dependencies { implementation 'com.iterable:iterableapi:3.6.2' }\n" > "$RN_ANDROID/android/app/build.gradle" + +signals_of() { + "$REAL_NODE" bin/existing-push.js scan android "$1" | + "$REAL_NODE" -e 'let i="";process.stdin.on("data",c=>i+=c).on("end",()=>process.stdout.write(JSON.parse(i).map(f=>`${f.signal}:${f.file}`).sort().join(" ")))' +} +want_signals() { + local got; got="$(signals_of "$2")" + [[ "$got" == "$3" ]] && ok "$(printf '%-44s %s' "$1" "${3:-none}")" || bad "$1" "got: ${got:-none}" +} +want_signals "an app's own service and its intent filter" "$OWN" \ + "messaging_event_filter:app/src/main/AndroidManifest.xml messaging_service:app/src/main/java/com/example/AppMessagingService.kt" +want_signals "OneSignal in the app's Gradle file" "$SIGNAL" "push_sdk:app/build.gradle" +want_signals "a React Native push library in package.json" "$RN_ANDROID" "push_sdk:package.json" +want_signals "Iterable-only: a forwarding service, Iterable's own" "$ONLY" "" + +done_with() { + local owner kind gate cmd summary + IFS="$NEXT_SEP" read -r owner kind gate cmd summary <<< "$(PROJECT_ROOT="$1" PATH="$NODE_BIN:$PATH" next_action)" + printf '%s|%s|%s' "$owner" "$kind" "$summary" +} +LINE="a push reached the device — nothing left to do. Iterable push is proven. The app also has its own push path (" +OFFER="). If the developer wants, offer the optional after-integration check that the app's own notifications still show." +own_done="$(done_with "$OWN")" +[[ "$own_done" == "none|done|$LINE"*"AppMessagingService handles onMessageReceived and onNewToken itself"*"$OFFER" ]] && + ok "$(printf '%-44s %s' "done offers the optional check, still done" "none/done")" || + bad "done did not carry the optional line" "$own_done" +[[ "$(done_with "$ONLY")" == "none|done|a push reached the device — nothing left to do" ]] && + ok "$(printf '%-44s %s' "an Iterable-only app's done has no follow-up" "none/done")" || + bad "an Iterable-only app got a follow-up" "$(done_with "$ONLY")" +cp "$WS/state.tsv" "$TMP/state.before" +state "$(row G16 pending tool "Push arrives on device" "no push yet")" +pending_own="$(done_with "$OWN")" +[[ "$pending_own" == "agent|send_proof|"* && "$pending_own" != *"own push path"* ]] && + ok "$(printf '%-44s %s' "the app's own push never changes a step" "agent/send_proof")" || + bad "the app's own push changed a pending step" "$pending_own" +cp "$TMP/state.before" "$WS/state.tsv" +before="$(shasum < "$WS/state.tsv")"; done_with "$OWN" >/dev/null +[[ "$(shasum < "$WS/state.tsv")" == "$before" ]] && + ! grep -q 'existing-push' bin/gates bin/gates-device.sh bin/gates-iterable.sh && + ok "$(printf '%-44s %s' "no gate reads it, and state.tsv is untouched" "gates unchanged")" || + bad "the existing-push check reached the gates" "$(grep -l 'existing-push' bin/gates*)" + echo ((FAILED)) && { echo " FAILED"; exit 1; } echo " All good — every state routes to one owner and one action." diff --git a/tests/agent-questions.sh b/tests/agent-questions.sh index 8391944..abe17bf 100644 --- a/tests/agent-questions.sh +++ b/tests/agent-questions.sh @@ -41,14 +41,14 @@ EOF # q [arg] — one question object, from a workspace with no network in reach. q() { - WS="$W" PID=my-proj PACKAGE=com.dogshelter PATH="$TOOLS:$PATH" bash -c ' + WS="$W" PID=my-proj PACKAGE=com.sampleshop PATH="$TOOLS:$PATH" bash -c ' source bin/config.sh >/dev/null 2>&1; source bin/questions.sh; question_for "$@"' _ "$@" } # The kind next_action actually reports for that same state, so these tests ask the # question the ladder would ask and not one chosen here to pass. kind_now() { - WS="$W" PID=my-proj PACKAGE=com.dogshelter PATH="$TOOLS:$PATH" bash -c ' + WS="$W" PID=my-proj PACKAGE=com.sampleshop PATH="$TOOLS:$PATH" bash -c ' source bin/config.sh >/dev/null 2>&1 IFS="$NEXT_SEP" read -r o k g c s <<< "$(next_action)"; printf %s "$k"' } @@ -203,7 +203,7 @@ echo # The point of pulling these two lists into functions. Either front end can be read by # the developer, and the one that drifts is the one they happen to be looking at. ctx="$(q approve_firebase | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>JSON.parse(s).context.forEach(l=>console.log(l)))')" -plan="$(WS="$W" PID=my-proj PACKAGE=com.dogshelter bash -c 'source bin/config.sh >/dev/null 2>&1; firebase_plan | plan_bulleted')" +plan="$(WS="$W" PID=my-proj PACKAGE=com.sampleshop bash -c 'source bin/config.sh >/dev/null 2>&1; firebase_plan | plan_bulleted')" missing=0 while IFS= read -r l; do [[ -n "$l" ]] || continue @@ -217,7 +217,7 @@ while IFS= read -r l; do done <<< "$(bash -c 'source bin/config.sh >/dev/null 2>&1; firebase_limits')" ((lim)) || ok "so does every line of firebase_limits — changes and boundary together" -for want in "Nothing has happened yet" "my-proj" "com.dogshelter" "teardown"; do +for want in "Nothing has happened yet" "my-proj" "com.sampleshop" "teardown"; do grep -qF -- "$want" <<< "$ctx" && ok "the context says '$want'" \ || bad "the context never mentions '$want'" done @@ -332,27 +332,39 @@ grep -qF "no API" <<< "$entry" \ # without running that command therefore showed the right options above nothing, and a # live trial produced exactly that: a selector with invented instructions, including a # rename this repo had already deleted. One call carries both now. -ctx() { q iterable_step "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log((JSON.parse(s).context||[]).join("\n"))})'; } +# Agents relay `prompt` and were seen never printing `context`, so the step itself has to +# be in the prompt: links, values and warnings, with the question as its last line. +prm() { q iterable_step "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(JSON.parse(s).prompt||"")})'; } +step_has() { #