The in-app survey has no SDK: it is four HTTP calls with JSON, which work in any language. Your app asks whether it can show the survey, builds the screen with its own look and sends the answer. WiseData NPS decides whether the survey appears, using the survey's rules (sampling, snooze, frequency). That is why the app stores no rules, and changing them doesn't require a new version.
This guide's example is a personal finance app that calls the survey right after the person taps "Close the month".
Before you start
- An active survey on the App channel (see the article In-app survey). The channel isn't available for eNPS and Competition surveys.
- The Survey ID and the API address, copied from the In-app survey card on the survey screen.
- There is no token: the calls are public and identified by the survey ID, like the website widget. Don't put any secret in the app.
Testing before you publish
There is no test environment and no test mode: there is a single API address, and every answer sent to an active survey counts in its results. So test with a separate survey:
- Create a second survey on the App channel, for example "App NPS β testing", and activate it. An app-only survey doesn't require a test send to be activated.
- On it, set Sampling rate to 100%, Snooze days after dismissal to 0 and leave Don't repeat for (days) empty. That way the survey shows up on every call, without the sampling, the snooze or the frequency limit getting in the way.
- Use this survey's ID in the development build of the app and the real survey's ID in the published build. Test answers stay in the testing survey only.
- When you are done, pause and archive the testing survey. Once activated, it can no longer be deleted.
After answering, the same device gets already_responded on the following calls. To test again, clear the app's data (or reinstall it): the app generates a new device_id and the survey shows up again. If the app sends the logged-in user's id, also log in as another user: whoever already answered is recognized by the device or by the user.
The survey must be active. A draft, a paused survey or one scheduled to start in the future returns 404, and a completed one returns 410.
Overview
- Base: the API address on the card, in the format
https://api.wisedatanps.com/api/v1/app/{surveyId}. It already ends with the survey ID; in the examples below,BASEis this address without the ID at the end. - Format: JSON, with
Accept: application/json(andContent-Type: application/jsonon POSTs) - Identity:
device_id, required on every call: a UUID the app generates once and stores on the device (8 to 128 characters). The logged-in person's id in your system is optional and goes along when there is one. - Types: ids are always text. Send
"42", not42: a numeric id in the JSON is rejected with422.
The flow
- The first time, generate a UUID and store it on the device. That is the
device_idin every call. - At the moment of value ("Close the month"), call
GET /config. - If it returns
eligible: true, open your screen with the texts you received. The impression is counted by this call, so only call it when you are actually going to show the survey. - The person picks a score and optionally writes a comment:
POST /respond. - If they close without answering:
POST /dismiss. The survey won't come back for the configured snooze days. - If there is an action button on the thank-you and they tap it:
POST /cta-click.
Send the logged-in user's id whenever there is one. With it, people are counted by user, and someone who switches phones won't get the survey again ahead of time.
GET /config β can I show it now?
GET /api/v1/app/{surveyId}/config?device_id=6f1c...&user_id=42Parameters, in the query string:
device_idβ required. Without it the response is422, even whenuser_idis present: the user id complements the device, it doesn't replace it.user_idβ optional. The logged-in person's id in your system.
There are no other parameters. Platform and app version go only on POST /respond.
When it should be shown:
{ "data": { "eligible": true, "reason": null, "survey": { "id": "01J9Z...", "question": "On a scale of 0 to 10, how likely are you to recommend the app to a friend?", "scale": { "metric": "nps", "min": 0, "max": 10, "lowMax": 6, "midMax": 8, "values": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }, "followupPromoter": "What do you like most about the app?", "followupPassive": "What was missing for a 10?", "followupDetractor": "What can we improve?", "thankYouMessage": "Thank you for your feedback!", "ctaPromoter": { "title": null, "message": "Recommend us to a friend", "buttonLabel": "Refer", "buttonUrl": "https://..." }, "ctaPassive": null, "ctaDetractor": null }, "branding": { "companyName": "...", "primaryColor": "#128B7F", "secondaryColor": "#26D267", "logoUrl": null } } }When it shouldn't be shown, the response is still 200: eligible is false, survey and branding are null and reason says why.
What the app does is the same for every reason: don't open the screen and carry on with the normal flow, with no message to the person. Don't repeat the call right away or in a loop, because the answer will be the same. Call again only at the next moment of value (the next "Close the month"). Also don't store the result on the device: the rules change in the dashboard, and the server decides on every call.
already_respondedβ the person has already answered. If the survey has Don't repeat for (days), they become eligible again once that period has passed since the answer; without it, they answer only once.snoozedβ closed without answering (POST /dismiss) fewer days ago than the Snooze days after dismissal. It comes back after that period.frequency_cap_reachedβ the survey was already shown to them within the Don't repeat for (days) period. It comes back after that period.sampled_outβ left out by the Sampling rate. The draw is fixed per person and per survey: with the same rate, they stay out on every call. Raising the rate in the dashboard includes more people.
Handle any other reason value the same way: eligible: false always means "don't show it now".
POST /respond β the answer
{ "device_id": "6f1c...", "score": 9, "comment": "Closing the month got much faster", "user": { "id": "42", "email": "ana@example.com", "name": "Ana" }, "app": { "platform": "ios", "version": "1.5.0" }, "time_on_survey_ms": 8200, "time_to_submit_ms": 5100 }device_idandscoreare required. The score must be betweenscale.minandscale.max.commentβ optional, up to 2,000 characters. This is where what the person wrote in reply to the follow-up question (followupPromoterand the others) goes: there is no other field for it.userβ optional.id,emailandnameshow up on the feedback. Note: here the user id goes insideuser("user": { "id": "42" }). A looseuser_idin the body of this call is ignored, and the answer is tied to the device only.user.traitsβ optional. Up to 10 label/value pairs with simple values (text, number or true/false), for example{ "plan": "premium" }.app.platformβios,androidorother.app.versionfeeds Results by app version, so always send it.time_on_survey_ms(since the screen opened) andtime_to_submit_ms(since the score was picked) β optional. An answer too fast to have been read loses points on the quality score; below the minimum, it is still saved but left out of the official NPS.
Responses: 201 saved; 409 the person already answered; 422 invalid data (the body has errors per field).
POST /dismiss and POST /cta-click
{ "device_id": "6f1c...", "user_id": "42" }Both take only the identity: device_id required and user_id optional, the same names as GET /config (here the user id is loose in the body, not inside user). They return 200, and a failure on these two calls can be ignored.
Building the screen
- Score buttons from
scale.mintoscale.max. An NPS survey has eleven: on a phone in portrait, use two rows (0β5 and 6β10), because in a single row the buttons are too small for a finger. - Score band: up to
lowMaxis a detractor; up tomidMax, passive; above, promoter. The band picks the follow-up question (followupDetractor,followupPassiveorfollowupPromoter) and the thank-you button (ctaDetractorβ¦). Anullquestion or button means it was not configured. - Thank-you:
thankYouMessageand, if present, the band's button. Only open the button's link if it starts withhttp://orhttps://. - The look is your app's.
brandingcarries the account colors from NPS, in case you want them.
Offline
If POST /respond fails because there is no connection, with a 5xx error or with 429 (rate limit), store the body on the device and resend it later. The other 4xx responses are final (409 already answered, 404/410 survey not available, 422 invalid data): discard the answer, or the app will resend forever.
Don't ask for a store review after the score
Sending whoever gave a high score to rate the app on the App Store or Google Play right after the survey is the review filtering both stores prohibit. If you want to ask for a review, do it at another moment, unrelated to the survey.
Examples
All four examples do the same thing: store the device_id, fetch the survey and send the answer or the dismissal. The screen is yours. In the finance app, the usage is: after "Close the month", call fetchSurvey; if a survey comes back, open your screen; at the end, call sendAnswer or dismiss. Replace COLE_O_ID_AQUI with your survey ID.
React Native (TypeScript)
Uses @react-native-async-storage/async-storage, expo-crypto and expo-application. The code is the same as in the Portuguese version of this article.
import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Application from 'expo-application'; import * as Crypto from 'expo-crypto'; import { Platform } from 'react-native'; const BASE = 'https://api.wisedatanps.com/api/v1/app'; const SURVEY_ID = 'COLE_O_ID_AQUI'; async function deviceId(): Promise<string> { const saved = await AsyncStorage.getItem('nps.deviceId'); if (saved) return saved; const id = Crypto.randomUUID(); await AsyncStorage.setItem('nps.deviceId', id); return id; } async function post(path: string, body: object): Promise<number> { const res = await fetch(`${BASE}/${SURVEY_ID}/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); return res.status; } export async function fetchSurvey(userId?: string) { let query = `device_id=${encodeURIComponent(await deviceId())}`; if (userId) query += `&user_id=${encodeURIComponent(userId)}`; const res = await fetch(`${BASE}/${SURVEY_ID}/config?${query}`, { headers: { Accept: 'application/json' }, }); if (!res.ok) return null; const { data } = await res.json(); return data.eligible ? data.survey : null; } export async function sendAnswer(score: number, comment: string, userId?: string) { return post('respond', { device_id: await deviceId(), score, ...(comment.trim() ? { comment } : {}), ...(userId ? { user: { id: userId } } : {}), app: { platform: Platform.OS === 'ios' ? 'ios' : 'android', version: Application.nativeApplicationVersion ?? '', }, }); } export async function dismiss(userId?: string) { await post('dismiss', { device_id: await deviceId(), ...(userId ? { user_id: userId } : {}) }); }Swift (iOS)
Foundation only: URLSession and UserDefaults.
import Foundation struct NpsScale: Decodable { let min: Int; let max: Int; let lowMax: Int; let midMax: Int } struct NpsSurvey: Decodable { let id: String let question: String let scale: NpsScale let followupPromoter: String? let followupPassive: String? let followupDetractor: String? let thankYouMessage: String } private struct NpsConfig: Decodable { let eligible: Bool; let survey: NpsSurvey? } private struct Envelope<T: Decodable>: Decodable { let data: T } enum Nps { static let base = URL(string: "https://api.wisedatanps.com/api/v1/app")! static let surveyId = "COLE_O_ID_AQUI" static var deviceId: String { if let saved = UserDefaults.standard.string(forKey: "nps.deviceId") { return saved } let id = UUID().uuidString UserDefaults.standard.set(id, forKey: "nps.deviceId") return id } static func fetchSurvey(userId: String?) async throws -> NpsSurvey? { var components = URLComponents(url: base.appendingPathComponent("\(surveyId)/config"), resolvingAgainstBaseURL: false)! components.queryItems = [URLQueryItem(name: "device_id", value: deviceId)] if let userId { components.queryItems?.append(URLQueryItem(name: "user_id", value: userId)) } var request = URLRequest(url: components.url!) request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil } let config = try JSONDecoder().decode(Envelope<NpsConfig>.self, from: data).data return config.eligible ? config.survey : nil } @discardableResult static func sendAnswer(score: Int, comment: String?, userId: String?) async throws -> Int { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" var body: [String: Any] = [ "device_id": deviceId, "score": score, "app": ["platform": "ios", "version": version], ] if let comment, !comment.isEmpty { body["comment"] = comment } if let userId { body["user"] = ["id": userId] } return try await post("\(surveyId)/respond", body) } static func dismiss(userId: String?) async throws { var body: [String: Any] = ["device_id": deviceId] if let userId { body["user_id"] = userId } _ = try await post("\(surveyId)/dismiss", body) } private static func post(_ path: String, _ body: [String: Any]) async throws -> Int { var request = URLRequest(url: base.appendingPathComponent(path)) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONSerialization.data(withJSONObject: body) let (_, response) = try await URLSession.shared.data(for: request) return (response as? HTTPURLResponse)?.statusCode ?? 0 } }Kotlin (Android)
Uses OkHttp, coroutines and the org.json that ships with Android.
import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.util.UUID class NpsClient(private val context: Context) { private val http = OkHttpClient() private val base = "https://api.wisedatanps.com/api/v1/app" private val surveyId = "COLE_O_ID_AQUI" private val jsonType = "application/json".toMediaType() private val deviceId: String by lazy { val prefs = context.getSharedPreferences("nps", Context.MODE_PRIVATE) prefs.getString("deviceId", null) ?: UUID.randomUUID().toString().also { prefs.edit().putString("deviceId", it).apply() } } suspend fun fetchSurvey(userId: String?): JSONObject? = withContext(Dispatchers.IO) { val url = "$base/$surveyId/config".toHttpUrl().newBuilder() .addQueryParameter("device_id", deviceId) .apply { if (userId != null) addQueryParameter("user_id", userId) } .build() val request = Request.Builder().url(url).header("Accept", "application/json").build() http.newCall(request).execute().use { response -> val text = response.body?.string() if (!response.isSuccessful || text == null) return@withContext null val data = JSONObject(text).getJSONObject("data") if (data.getBoolean("eligible")) data.getJSONObject("survey") else null } } suspend fun sendAnswer(score: Int, comment: String?, userId: String?, appVersion: String): Int { val body = JSONObject() .put("device_id", deviceId) .put("score", score) .put("app", JSONObject().put("platform", "android").put("version", appVersion)) if (!comment.isNullOrBlank()) body.put("comment", comment) if (userId != null) body.put("user", JSONObject().put("id", userId)) return post("$surveyId/respond", body) } suspend fun dismiss(userId: String?) { val body = JSONObject().put("device_id", deviceId) if (userId != null) body.put("user_id", userId) post("$surveyId/dismiss", body) } private suspend fun post(path: String, body: JSONObject): Int = withContext(Dispatchers.IO) { val request = Request.Builder() .url("$base/$path") .header("Accept", "application/json") .post(body.toString().toRequestBody(jsonType)) .build() http.newCall(request).execute().use { it.code } } }Dart (Flutter)
Uses the http, shared_preferences and uuid packages.
import 'dart:convert'; import 'dart:io' show Platform; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; class NpsClient { static const _base = 'https://api.wisedatanps.com/api/v1/app'; static const _surveyId = 'COLE_O_ID_AQUI'; Future<String> _deviceId() async { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString('nps.deviceId'); if (saved != null) return saved; final id = const Uuid().v4(); await prefs.setString('nps.deviceId', id); return id; } Future<Map<String, dynamic>?> fetchSurvey({String? userId}) async { final uri = Uri.parse('$_base/$_surveyId/config').replace(queryParameters: { 'device_id': await _deviceId(), if (userId != null) 'user_id': userId, }); final res = await http.get(uri, headers: {'Accept': 'application/json'}); if (res.statusCode != 200) return null; final data = jsonDecode(res.body)['data'] as Map<String, dynamic>; return data['eligible'] == true ? data['survey'] as Map<String, dynamic> : null; } Future<int> sendAnswer(int score, {String? comment, String? userId, required String appVersion}) async { return _post('$_surveyId/respond', { 'device_id': await _deviceId(), 'score': score, if (comment != null && comment.trim().isNotEmpty) 'comment': comment, if (userId != null) 'user': {'id': userId}, 'app': {'platform': Platform.isIOS ? 'ios' : 'android', 'version': appVersion}, }); } Future<void> dismiss({String? userId}) async { await _post('$_surveyId/dismiss', { 'device_id': await _deviceId(), if (userId != null) 'user_id': userId, }); } Future<int> _post(String path, Map<String, dynamic> body) async { final res = await http.post( Uri.parse('$_base/$path'), headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, body: jsonEncode(body), ); return res.statusCode; } }Rate limits
config,dismissandcta-click: up to 20 calls per minute per device.respond: up to 5 per minute per device.- There is also a looser per-IP-address limit, sized for many phones going out through the same carrier IP: it exists only to contain abuse.
- These calls don't count against the general API limit: only the limits above apply. Over any of them, the API returns
429; handle it like a network failure (see Offline).
Common errors
Every error comes as JSON with message. On 422, it also has errors, listing the problems per field.
- 404 β wrong survey ID, a survey without the App channel, or a survey that is not active (draft, paused or scheduled to start in the future).
- 409 β on
POST /respond, this person has already answered. - 410 β the survey was completed or its collection window ended.
- 422 β the most common:
device_idmissing (on any call, includingGET /config);device_idshorter than 8 characters; a user id sent as a number instead of text; a score outside the scale;app.platformother thanios,androidorother. - 429 β rate limit. Try again later.
Where to go next
- Sending channels β the article In-app survey, with the display rules and the metrics.
- Results and reports β the app's NPS alongside the other channels.
In short: the app stores a device_id, calls GET /config at the moment of value, shows its own screen when eligible: true comes back and sends POST /respond or POST /dismiss; the rules live in the NPS dashboard.