Integrando a pesquisa no seu aplicativo

15 min de leitura79
Copiar para a I.A.:

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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 (e Content-Type: application/json nos 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ão 42: um id numérico no JSON é recusado com 422.

O fluxo

  1. Na primeira vez, gere um UUID e guarde no aparelho. Esse é o device_id em todas as chamadas.

  2. No momento de valor ("Fechar o mês"), chame GET /config.

  3. 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.

  4. A pessoa escolhe a nota e, opcionalmente, escreve um comentário: POST /respond.

  5. Se ela fechar sem responder: POST /dismiss. A pesquisa não volta pelos dias de descanso configurados.

  6. 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=42

Parâmetros, na query string:

  • device_id — obrigatório. Sem ele a resposta é 422, mesmo que user_id esteja 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_id e score são obrigatórios. A nota precisa estar dentro de scale.min e scale.max.

  • comment — opcional, até 2.000 caracteres. É aqui que vai o que a pessoa escreveu em resposta à pergunta de acompanhamento (followupPromoter e as outras): não existe outro campo para ela.

  • user — opcional. id, email e name aparecem no feedback. Atenção: aqui o id do usuário vai dentro de user ("user": { "id": "42" }). Um user_id solto 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, android ou other. app.version alimenta o Resultado por versão do app, então envie sempre.

  • time_on_survey_ms (desde que a tela abriu) e time_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.min a scale.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, followupPassive ou followupPromoter) e o botão do agradecimento (ctaDetractor…). Pergunta ou botão null quer dizer que não foram configurados.

  • Agradecimento: thankYouMessage e, se houver, o botão da faixa. Abra o link do botão só se ele começar com http:// ou https://.

  • O visual é do seu app. branding traz 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, dismiss e cta-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 no GET /config); device_id com menos de 8 caracteres; id de usuário enviado como número em vez de texto; nota fora da escala; app.platform diferente de ios, android ou other.

  • 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.

Este artigo foi útil?