A pesquisa no aplicativo não tem SDK: são quatro chamadas HTTP com JSON, que funcionam em qualquer linguagem. Seu app pergunta se pode mostrar a pesquisa, monta a tela com o visual dele e envia a resposta. Quem decide se a pesquisa aparece é o WiseData NPS, com as regras da pesquisa (amostragem, descanso, frequência). Por isso o app não guarda regra nenhuma, e ajustar essas regras não exige versão nova.
O exemplo deste guia é um app de controle financeiro que chama a pesquisa logo depois de a pessoa tocar em "Fechar o mês".
Antes de começar
- Uma pesquisa com o canal Aplicativo, ativa (veja o artigo Pesquisa dentro do aplicativo). O canal não está disponível para pesquisas de eNPS e Concorrência.
- O ID da pesquisa e o endereço da API, copiados do card Pesquisa no aplicativo na tela da pesquisa.
- Não há token: as chamadas são públicas e identificadas pelo ID da pesquisa, como o widget do site. Não coloque nenhum segredo no app.
Testando antes de publicar
Não existe ambiente de testes nem modo de teste: o endereço da API é um só, e toda resposta enviada a uma pesquisa ativa entra nos resultados dela. Por isso, teste com uma pesquisa separada:
- Crie uma segunda pesquisa com o canal Aplicativo, por exemplo "NPS do app — testes", e ative. Pesquisa só de aplicativo não pede envio de teste para ativar.
- Nela, deixe Taxa de amostragem em 100%, Dias de descanso após fechar em 0 e Não repetir por (dias) vazio. Assim a pesquisa aparece a cada chamada, sem cair na amostragem, no descanso nem no limite de frequência.
- Use o ID dessa pesquisa na versão de desenvolvimento do app e o ID da pesquisa real na versão publicada. As respostas de teste ficam só na pesquisa de testes.
- Quando terminar, pause e arquive a pesquisa de testes. Depois de ativada, ela não pode mais ser excluída.
Depois de responder, o mesmo aparelho recebe already_responded nas chamadas seguintes. Para testar de novo, apague os dados do app (ou reinstale): o app gera um device_id novo e a pesquisa volta a aparecer. Se o app envia o id do usuário logado, entre também com outro usuário: quem já respondeu é reconhecido pelo aparelho ou pelo usuário.
A pesquisa precisa estar ativa. Rascunho, pausada ou com início agendado para o futuro responde 404, e concluída responde 410.
Visão geral
- Base: o Endereço da API do card, no formato
https://api.wisedatanps.com/api/v1/app/{surveyId}. Ele já termina no ID da pesquisa; nos exemplos abaixo,BASEé esse endereço sem o ID no final. - Formato: JSON, com
Accept: application/json(eContent-Type: application/jsonnos POST) - Identidade:
device_id, obrigatório em todas as chamadas: um UUID que o app gera uma vez e guarda no aparelho (de 8 a 128 caracteres). O id da pessoa logada no seu sistema é opcional e vai junto quando houver. - Tipos: ids são sempre texto. Envie
"42", não42: um id numérico no JSON é recusado com422.
O fluxo
- Na primeira vez, gere um UUID e guarde no aparelho. Esse é o
device_idem todas as chamadas. - No momento de valor ("Fechar o mês"), chame
GET /config. - Se vier
eligible: true, abra a sua tela com os textos recebidos. A exibição já foi contada nessa chamada, então chame apenas quando for mostrar de fato. - A pessoa escolhe a nota e, opcionalmente, escreve um comentário:
POST /respond. - Se ela fechar sem responder:
POST /dismiss. A pesquisa não volta pelos dias de descanso configurados. - Se houver botão de ação no agradecimento e ela tocar:
POST /cta-click.
Envie o id do usuário logado sempre que houver. Com ele, a pessoa é contada pelo usuário, e quem troca de celular não recebe a pesquisa de novo antes da hora.
GET /config — posso mostrar agora?
GET /api/v1/app/{surveyId}/config?device_id=6f1c...&user_id=42Parâmetros, na query string:
device_id— obrigatório. Sem ele a resposta é422, mesmo queuser_idesteja presente: o id do usuário complementa o aparelho, não o substitui.user_id— opcional. O id da pessoa logada no seu sistema.
Não há outros parâmetros. Plataforma e versão do app vão só no POST /respond.
Quando é para mostrar:
{ "data": { "eligible": true, "reason": null, "survey": { "id": "01J9Z...", "question": "De 0 a 10, o quanto você recomendaria o app a um 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": "O que você mais gosta no app?", "followupPassive": "O que faltou para nota 10?", "followupDetractor": "O que podemos melhorar?", "thankYouMessage": "Obrigado pelo seu feedback!", "ctaPromoter": { "title": null, "message": "Indique para um amigo", "buttonLabel": "Indicar", "buttonUrl": "https://..." }, "ctaPassive": null, "ctaDetractor": null }, "branding": { "companyName": "...", "primaryColor": "#128B7F", "secondaryColor": "#26D267", "logoUrl": null } } }Quando não é para mostrar, a resposta continua sendo 200: eligible vem false, survey e branding vêm null e reason diz o motivo.
O que o app faz é igual para todos os motivos: não abre a tela e segue o fluxo normal, sem mensagem para a pessoa. Não repita a chamada na hora nem em laço, porque a resposta será a mesma. Chame de novo só no próximo momento de valor (o próximo "Fechar o mês"). Também não guarde o resultado no aparelho: as regras mudam no painel, e quem decide a cada chamada é o servidor.
already_responded— a pessoa já respondeu. Se a pesquisa tem Não repetir por (dias), ela volta a ser elegível quando esse prazo passa desde a resposta; sem esse prazo, responde uma vez só.snoozed— fechou sem responder (POST /dismiss) há menos dias que os Dias de descanso após fechar. Volta depois desse prazo.frequency_cap_reached— a pesquisa já foi mostrada a ela dentro do prazo de Não repetir por (dias). Volta depois desse prazo.sampled_out— ficou fora da Taxa de amostragem. O sorteio é fixo por pessoa e por pesquisa: com a mesma taxa, ela continua fora em todas as chamadas. Aumentar a taxa no painel inclui mais gente.
Trate qualquer outro valor de reason da mesma forma: eligible: false sempre quer dizer "não mostre agora".
POST /respond — a resposta
{ "device_id": "6f1c...", "score": 9, "comment": "Fechar o mês ficou muito mais rápido", "user": { "id": "42", "email": "ana@exemplo.com", "name": "Ana" }, "app": { "platform": "ios", "version": "1.5.0" }, "time_on_survey_ms": 8200, "time_to_submit_ms": 5100 }device_idescoresão obrigatórios. A nota precisa estar dentro descale.minescale.max.comment— opcional, até 2.000 caracteres. É aqui que vai o que a pessoa escreveu em resposta à pergunta de acompanhamento (followupPromotere as outras): não existe outro campo para ela.user— opcional.id,emailenameaparecem no feedback. Atenção: aqui o id do usuário vai dentro deuser("user": { "id": "42" }). Umuser_idsolto no corpo desta chamada é ignorado, e a resposta fica ligada só ao aparelho.user.traits— opcional. Até 10 pares de rótulo e valor simples (texto, número ou verdadeiro/falso), por exemplo{ "plano": "premium" }.app.platform—ios,androidouother.app.versionalimenta o Resultado por versão do app, então envie sempre.time_on_survey_ms(desde que a tela abriu) etime_to_submit_ms(desde a escolha da nota) — opcionais. Resposta rápida demais para ter sido lida perde pontos na nota de qualidade; abaixo do mínimo, ela fica gravada, mas sai do NPS oficial.
Respostas: 201 gravou; 409 a pessoa já respondeu; 422 dado inválido (o corpo traz errors por campo).
POST /dismiss e POST /cta-click
{ "device_id": "6f1c...", "user_id": "42" }Os dois recebem só a identidade: device_id obrigatório e user_id opcional, os mesmos nomes do GET /config (aqui o id do usuário fica solto no corpo, e não dentro de user). Respondem 200, e uma falha nessas duas chamadas pode ser ignorada.
Montando a tela
- Botões de nota de
scale.minascale.max. Numa pesquisa de NPS são onze: num celular em pé, use duas linhas (0–5 e 6–10), porque numa linha só os botões ficam pequenos demais para o dedo. - Faixa da nota: até
lowMaxé detrator; atémidMax, neutro; acima, promotor. A faixa escolhe a pergunta de acompanhamento (followupDetractor,followupPassiveoufollowupPromoter) e o botão do agradecimento (ctaDetractor…). Pergunta ou botãonullquer dizer que não foram configurados. - Agradecimento:
thankYouMessagee, se houver, o botão da faixa. Abra o link do botão só se ele começar comhttp://ouhttps://. - O visual é do seu app.
brandingtraz as cores da conta no NPS, caso queira usá-las.
Sem rede
Se o POST /respond falhar por falta de conexão, por erro 5xx ou por 429 (limite de chamadas), guarde o corpo no aparelho e reenvie mais tarde. Os demais 4xx são definitivos (409 já respondeu, 404/410 pesquisa fora do ar, 422 dado inválido): descarte a resposta, senão o app reenvia para sempre.
Não peça avaliação na loja depois da nota
Mandar quem deu nota alta para avaliar na App Store ou no Google Play logo depois da pesquisa é o filtro de avaliações que as duas lojas proíbem. Se quiser pedir avaliação, faça em outro momento, sem ligação com a pesquisa.
Exemplos
Os quatro exemplos fazem a mesma coisa: guardam o device_id, buscam a pesquisa e enviam a resposta ou o adiamento. A tela fica com você. No app financeiro, o uso é: depois de "Fechar o mês", chamar fetchSurvey; se vier uma pesquisa, abrir a sua tela; no fim, chamar sendAnswer ou dismiss.
React Native (TypeScript)
Usa @react-native-async-storage/async-storage, expo-crypto e 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)
Só Foundation: URLSession e 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 e o org.json que já vem no 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 os pacotes http, shared_preferences e 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; } }Limites de uso
config,dismissecta-click: até 20 chamadas por minuto por aparelho.respond: até 5 por minuto por aparelho.- Há também um limite por endereço IP, mais folgado, pensado para muitos celulares que saem pelo mesmo IP da operadora: ele só existe para conter abuso.
- Estas chamadas não entram no limite geral da API: valem só os limites acima. Passou de qualquer um deles, a API responde
429; trate como falta de rede (veja Sem rede).
Erros comuns
Todo erro vem em JSON com message. No 422, vem também errors, com a lista de problemas por campo.
- 404 — ID da pesquisa errado, pesquisa sem o canal Aplicativo, ou pesquisa que não está ativa (rascunho, pausada ou com início agendado para o futuro).
- 409 — no
POST /respond, essa pessoa já respondeu. - 410 — a pesquisa foi concluída ou a janela de coleta terminou.
- 422 — os mais comuns: falta
device_id(em qualquer chamada, inclusive noGET /config);device_idcom menos de 8 caracteres; id de usuário enviado como número em vez de texto; nota fora da escala;app.platformdiferente deios,androidouother. - 429 — limite de chamadas. Tente de novo depois.
Para onde ir depois
- Canais de envio — o artigo Pesquisa dentro do aplicativo, com as regras de exibição e as métricas.
- Resultados e relatórios — o NPS do app ao lado dos outros canais.
Resumindo: o app guarda um device_id, chama GET /config no momento de valor, mostra a própria tela quando vier eligible: true e envia POST /respond ou POST /dismiss; as regras ficam no painel do NPS.