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 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% e Dias de descanso após fechar em 0. Assim a pesquisa aparece a cada chamada, sem cair na amostragem nem no descanso.
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.
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 recebe nota de qualidade baixa e 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, e até 300 por minuto por IP.respond: até 5 por minuto por aparelho, e até 120 por minuto por IP.O limite principal é por aparelho. O limite por IP é bem mais folgado porque celulares da mesma operadora saem pelo mesmo endereço. Passou de qualquer um dos dois, a API responde
429.
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.