La encuesta en la aplicación no tiene SDK: son cuatro llamadas HTTP con JSON, que funcionan en cualquier lenguaje. Tu app pregunta si puede mostrar la encuesta, arma la pantalla con su propio aspecto y envía la respuesta. Quien decide si la encuesta aparece es WiseData NPS, con las reglas de la encuesta (muestreo, descanso, frecuencia). Por eso la app no guarda ninguna regla, y cambiarlas no exige una nueva versión.
El ejemplo de esta guía es una app de finanzas personales que llama a la encuesta justo después de que la persona toca "Cerrar el mes".
Antes de empezar
- Una encuesta activa en el canal Aplicación (ver el artículo Encuesta dentro de la aplicación). El canal no está disponible para encuestas de eNPS y Competencia.
- El ID de la encuesta y la dirección de la API, copiados de la tarjeta Encuesta en la aplicación en la pantalla de la encuesta.
- No hay token: las llamadas son públicas e identificadas por el ID de la encuesta, como el widget del sitio. No pongas ningún secreto en la app.
Probando antes de publicar
No existe un entorno de pruebas ni un modo de prueba: la dirección de la API es una sola, y toda respuesta enviada a una encuesta activa entra en sus resultados. Por eso, prueba con una encuesta aparte:
- Crea una segunda encuesta con el canal Aplicación, por ejemplo "NPS de la app — pruebas", y actívala. Una encuesta solo de aplicación no pide envío de prueba para activarse.
- En ella, deja la Tasa de muestreo en 100%, los Días de descanso tras cerrar en 0 y No repetir durante (días) vacío. Así la encuesta aparece en cada llamada, sin caer en el muestreo, el descanso ni el límite de frecuencia.
- Usa el ID de esta encuesta en la versión de desarrollo de la app y el ID de la encuesta real en la versión publicada. Las respuestas de prueba quedan solo en la encuesta de pruebas.
- Cuando termines, pausa y archiva la encuesta de pruebas. Después de activada, ya no se puede eliminar.
Después de responder, el mismo dispositivo recibe already_responded en las llamadas siguientes. Para probar de nuevo, borra los datos de la app (o reinstálala): la app genera un device_id nuevo y la encuesta vuelve a aparecer. Si la app envía el id del usuario conectado, entra también con otro usuario: quien ya respondió se reconoce por el dispositivo o por el usuario.
La encuesta debe estar activa. Un borrador, una encuesta pausada o con inicio programado en el futuro responde 404, y una concluida responde 410.
Visión general
- Base: la Dirección de la API de la tarjeta, con el formato
https://api.wisedatanps.com/api/v1/app/{surveyId}. Ya termina en el ID de la encuesta; en los ejemplos de abajo,BASEes esa dirección sin el ID al final. - Formato: JSON, con
Accept: application/json(yContent-Type: application/jsonen los POST) - Identidad:
device_id, obligatorio en todas las llamadas: un UUID que la app genera una vez y guarda en el dispositivo (de 8 a 128 caracteres). El id de la persona conectada en tu sistema es opcional y va junto cuando existe. - Tipos: los ids son siempre texto. Envía
"42", no42: un id numérico en el JSON se rechaza con422.
El flujo
- La primera vez, genera un UUID y guárdalo en el dispositivo. Ese es el
device_iden todas las llamadas. - En el momento de valor ("Cerrar el mes"), llama a
GET /config. - Si viene
eligible: true, abre tu pantalla con los textos recibidos. La exhibición ya se cuenta en esa llamada, así que llámala solo cuando vayas a mostrar la encuesta de verdad. - La persona elige la nota y, opcionalmente, escribe un comentario:
POST /respond. - Si cierra sin responder:
POST /dismiss. La encuesta no vuelve durante los días de descanso configurados. - Si hay un botón de acción en el agradecimiento y lo toca:
POST /cta-click.
Envía el id del usuario conectado siempre que exista. Con él, la persona se cuenta por usuario, y quien cambia de teléfono no recibe la encuesta de nuevo antes de tiempo.
GET /config — ¿puedo mostrarla ahora?
GET /api/v1/app/{surveyId}/config?device_id=6f1c...&user_id=42Parámetros, en la query string:
device_id— obligatorio. Sin él la respuesta es422, aunqueuser_idesté presente: el id del usuario complementa el dispositivo, no lo reemplaza.user_id— opcional. El id de la persona conectada en tu sistema.
No hay otros parámetros. La plataforma y la versión de la app van solo en el POST /respond.
Cuando hay que mostrarla:
{ "data": { "eligible": true, "reason": null, "survey": { "id": "01J9Z...", "question": "En una escala de 0 a 10, ¿qué tan probable es que recomiendes la app a un amigo?", "scale": { "metric": "nps", "min": 0, "max": 10, "lowMax": 6, "midMax": 8, "values": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }, "followupPromoter": "¿Qué es lo que más te gusta de la app?", "followupPassive": "¿Qué faltó para un 10?", "followupDetractor": "¿Qué podemos mejorar?", "thankYouMessage": "¡Gracias por tu opinión!", "ctaPromoter": { "title": null, "message": "Recomiéndanos a un amigo", "buttonLabel": "Recomendar", "buttonUrl": "https://..." }, "ctaPassive": null, "ctaDetractor": null }, "branding": { "companyName": "...", "primaryColor": "#128B7F", "secondaryColor": "#26D267", "logoUrl": null } } }Cuando no hay que mostrarla, la respuesta sigue siendo 200: eligible viene false, survey y branding vienen null y reason dice el motivo.
Lo que hace la app es igual para todos los motivos: no abre la pantalla y sigue el flujo normal, sin mensaje para la persona. No repitas la llamada en el momento ni en bucle, porque la respuesta será la misma. Vuelve a llamar solo en el próximo momento de valor (el próximo "Cerrar el mes"). Tampoco guardes el resultado en el dispositivo: las reglas cambian en el panel, y quien decide en cada llamada es el servidor.
already_responded— la persona ya respondió. Si la encuesta tiene No repetir durante (días), vuelve a ser elegible cuando pasa ese plazo desde la respuesta; sin ese plazo, responde una sola vez.snoozed— cerró sin responder (POST /dismiss) hace menos días que los Días de descanso tras cerrar. Vuelve después de ese plazo.frequency_cap_reached— la encuesta ya se le mostró dentro del plazo de No repetir durante (días). Vuelve después de ese plazo.sampled_out— quedó fuera de la Tasa de muestreo. El sorteo es fijo por persona y por encuesta: con la misma tasa, sigue fuera en todas las llamadas. Subir la tasa en el panel incluye a más personas.
Trata cualquier otro valor de reason de la misma forma: eligible: false siempre significa "no la muestres ahora".
POST /respond — la respuesta
{ "device_id": "6f1c...", "score": 9, "comment": "Cerrar el mes quedó mucho más rápido", "user": { "id": "42", "email": "ana@ejemplo.com", "name": "Ana" }, "app": { "platform": "ios", "version": "1.5.0" }, "time_on_survey_ms": 8200, "time_to_submit_ms": 5100 }device_idyscoreson obligatorios. La nota debe estar entrescale.minyscale.max.comment— opcional, hasta 2.000 caracteres. Aquí va lo que la persona escribió en respuesta a la pregunta de seguimiento (followupPromotery las demás): no existe otro campo para eso.user— opcional.id,emailynameaparecen en el comentario recibido. Atención: aquí el id del usuario va dentro deuser("user": { "id": "42" }). Unuser_idsuelto en el cuerpo de esta llamada se ignora, y la respuesta queda ligada solo al dispositivo.user.traits— opcional. Hasta 10 pares de etiqueta y valor simple (texto, número o verdadero/falso), por ejemplo{ "plan": "premium" }.app.platform—ios,androiduother.app.versionalimenta el Resultado por versión de la app, así que envíala siempre.time_on_survey_ms(desde que se abrió la pantalla) ytime_to_submit_ms(desde que se eligió la nota) — opcionales. Una respuesta demasiado rápida para haber sido leída pierde puntos en la nota de calidad; por debajo del mínimo, queda guardada, pero fuera del NPS oficial.
Respuestas: 201 guardada; 409 la persona ya respondió; 422 dato inválido (el cuerpo trae errors por campo).
POST /dismiss y POST /cta-click
{ "device_id": "6f1c...", "user_id": "42" }Ambos reciben solo la identidad: device_id obligatorio y user_id opcional, los mismos nombres del GET /config (aquí el id del usuario va suelto en el cuerpo, no dentro de user). Responden 200, y un fallo en estas dos llamadas se puede ignorar.
Armando la pantalla
- Botones de nota de
scale.minascale.max. En una encuesta de NPS son once: en un teléfono en vertical, usa dos filas (0–5 y 6–10), porque en una sola fila los botones quedan demasiado pequeños para el dedo. - Franja de la nota: hasta
lowMaxes detractor; hastamidMax, neutro; por encima, promotor. La franja elige la pregunta de seguimiento (followupDetractor,followupPassiveofollowupPromoter) y el botón del agradecimiento (ctaDetractor…). Una pregunta o botónnullsignifica que no se configuró. - Agradecimiento:
thankYouMessagey, si existe, el botón de la franja. Abre el enlace del botón solo si empieza conhttp://ohttps://. - El aspecto es el de tu app.
brandingtrae los colores de la cuenta en NPS, por si quieres usarlos.
Sin conexión
Si el POST /respond falla por falta de conexión, con un error 5xx o con 429 (límite de llamadas), guarda el cuerpo en el dispositivo y reenvíalo más tarde. Los demás 4xx son definitivos (409 ya respondió, 404/410 encuesta no disponible, 422 dato inválido): descarta la respuesta, o la app reenviará para siempre.
No pidas reseña en la tienda después de la nota
Enviar a quien dio nota alta a calificar en la App Store o en Google Play justo después de la encuesta es el filtro de reseñas que ambas tiendas prohíben. Si quieres pedir reseña, hazlo en otro momento, sin relación con la encuesta.
Ejemplos
Los cuatro ejemplos hacen lo mismo: guardan el device_id, buscan la encuesta y envían la respuesta o el aplazamiento. La pantalla es tuya. En la app de finanzas, el uso es: después de "Cerrar el mes", llamar a fetchSurvey; si viene una encuesta, abrir tu pantalla; al final, llamar a sendAnswer o dismiss. Reemplaza COLE_O_ID_AQUI por el ID de tu encuesta.
React Native (TypeScript)
Usa @react-native-async-storage/async-storage, expo-crypto y expo-application.
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)
Solo Foundation: URLSession y 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)
Usa OkHttp, coroutines y el org.json que ya viene en 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)
Usa los paquetes http, shared_preferences y uuid.
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; } }Límites de uso
config,dismissycta-click: hasta 20 llamadas por minuto por dispositivo.respond: hasta 5 por minuto por dispositivo.- También hay un límite por dirección IP, más holgado, pensado para muchos celulares que salen por la misma IP de la operadora: solo existe para contener abusos.
- Estas llamadas no entran en el límite general de la API: valen solo los límites de arriba. Superado cualquiera de ellos, la API responde
429; trátalo como una falta de conexión (ver Sin conexión).
Errores comunes
Todo error viene en JSON con message. En el 422, viene también errors, con la lista de problemas por campo.
- 404 — ID de la encuesta equivocado, encuesta sin el canal Aplicación, o encuesta que no está activa (borrador, pausada o con inicio programado en el futuro).
- 409 — en el
POST /respond, esa persona ya respondió. - 410 — la encuesta se concluyó o terminó su ventana de recolección.
- 422 — los más comunes: falta
device_id(en cualquier llamada, incluido elGET /config);device_idcon menos de 8 caracteres; id de usuario enviado como número en lugar de texto; nota fuera de la escala;app.platformdistinto deios,androiduother. - 429 — límite de llamadas. Inténtalo de nuevo más tarde.
A dónde ir después
- Canales de envío — el artículo Encuesta dentro de la aplicación, con las reglas de exhibición y las métricas.
- Resultados e informes — el NPS de la app junto a los demás canales.
En resumen: la app guarda un device_id, llama a GET /config en el momento de valor, muestra su propia pantalla cuando viene eligible: true y envía POST /respond o POST /dismiss; las reglas quedan en el panel de NPS.