Visão geral
react-native-notti é o SDK oficial de React Native para notificações push do Notti (FCM no Android, APNs no iOS). Ele cuida do registro de dispositivo, tags, ID de usuário externo, estado da assinatura e dos eventos de notificação recebida/aberta/clicada, para que a integração nunca precise montar chamadas REST manualmente contra a API de Devices. É um Turbo Module (New Architecture) com uma camada fina em TypeScript sobre código nativo Kotlin/Swift, e inclui um config plugin do Expo para projetos que usam Expo prebuild.
Por padrão, o SDK se comunica com o Notti (https://app.zeepnotti.app), então initialize() só precisa do seu appId e clientKey.
Requisitos
- React Native com a New Architecture habilitada (Turbo Modules).
- Android: um
minSdkVersioncompatível comcom.google.firebase:firebase-messaginge um projeto Firebase com Cloud Messaging configurado. - iOS: a capability Push Notifications habilitada no target do seu app (APNs).
- Um App do Notti e seus
appId/clientKey- veja Primeiros passos com o Notti para criar um.
Instalação
npm install react-native-nottiIsso instala o Turbo Module tanto para React Native puro (bare) quanto para Expo. A configuração nativa difere conforme o caso:
React Native bare: coloque o
google-services.jsondo seu App emandroid/app/google-services.json, aplique o plugin Gradle do Google Services e declare a permissão de runtimePOST_NOTIFICATIONSno seuAndroidManifest.xml(Android). Habilite a capability Push Notifications no Xcode e encaminhe os callbacks do APNs do seu próprioAppDelegateparaNottiBridge/NottiPushDelegate(iOS) - o SDK evita method swizzling de propósito, então esse pequeno trecho de código do integrador é obrigatório.
Expo: adicione
"react-native-notti"ao array de plugins do seuapp.json/app.config.js. Noexpo prebuild, o plugin aplica o plugin Gradle do Google Services e copia ogoogle-services.jsonconfigurado (Android), e adiciona o entitlementaps-environment(iOS). A declaração da permissão de runtime no Android e o encaminhamento noAppDelegatedo iOS descritos acima continuam valendo da mesma forma - o plugin cuida só da configuração do projeto nativo, não desse caminho de código.

O valor de aps-environment que o plugin escreve é resolvido assim: a prop de plugin explícita apsEnvironment: 'development' | 'production' tem prioridade; caso contrário, production somente quando a variável de ambiente EAS_BUILD_PROFILE for exatamente production; caso contrário, development (qualquer outro nome de perfil do EAS - preview, staging, … - ou um expo prebuild local). O perfil preview padrão do EAS é ad hoc (distribution: "internal"), e builds ad hoc recebem tokens APNs de produção enquanto o plugin escreveria development; o archive/export do Xcode normalmente reescreve aps-environment a partir do provisioning profile de distribuição, mas, para que a saída do prebuild corresponda explicitamente à distribuição, fixe a prop por perfil em app.config.js:
const adHocOrStore = ['production', 'preview'].includes(process.env.EAS_BUILD_PROFILE);
export default {
expo: {
plugins: [['react-native-notti', { apsEnvironment: adHocOrStore ? 'production' : 'development' }]],
},
};Inicializando e registrando um dispositivo
Chame initialize() uma vez, por exemplo na inicialização do app, com o appId e o clientKey do seu App (nunca a chave REST - veja a Referência da API de Apps para entender o motivo):
import { Notti } from 'react-native-notti';
Notti.initialize('<appId>', '<clientKey>');Isso registra o dispositivo no Notti usando o token atual de FCM (Android) / APNs (iOS). É seguro chamar mais de uma vez - uma nova chamada com os mesmos appId/clientKey não faz nada - e nunca lança exceção: argumentos ausentes ou inválidos, ou um pré-requisito nativo de push ausente (sem google-services.json, sem a capability de APNs), são registrados em log, não lançados.
Peça a permissão de push do SO explicitamente, quando o seu app estiver pronto para exibir o prompt - ela não está atrelada a initialize():
const granted = await Notti.requestPermission();Tags, ID de usuário externo, ID do dispositivo e estado da assinatura
// Tag the device for Notti Segments (see /api-reference/segments).
Notti.User.addTag('plan', 'vip');
Notti.User.addTags({ plan: 'vip', region: 'br' });
Notti.User.removeTag('plan');
Notti.User.removeTags(['plan', 'region']);
// Associate the device with your own user id.
Notti.login('external-user-123');
Notti.logout();
// The Notti-internal device id your own backend needs to route
// notifications to this device.
const deviceIdChanged = Notti.addEventListener('deviceIdChanged', (id) => {
console.log('Notti device id', id);
});
const deviceId = Notti.getDeviceId();
deviceIdChanged.remove();
// Enable/disable delivery without unregistering the device.
Notti.setSubscription(true);Todas essas mutações são serializadas no cliente (uma chamada de rede em andamento por vez, last-write-wins sobre o estado local mesclado), então chamá-las em sequência é seguro. logout() também desvincula o dispositivo do usuário no backend do Notti: ele envia external_user_id: null, então pushes enviados ao usuário anterior por external_user_ids deixam de chegar a este dispositivo. Tags e inscrição são mantidas. Se o dispositivo estiver offline ou a chamada falhar, a limpeza é reenviada no próximo registro. Versões do SDK anteriores a esse comportamento limpam o ID só localmente, e a linha do Device mantém o valor anterior até um novo login().
getDeviceId() é uma leitura síncrona em cache que retorna null até que o registro atribua um ID (sem round-trip de rede). Assine 'deviceIdChanged'antes de chamá-la: o registro pode terminar entre as duas chamadas, e o evento não reemite um valor já atribuído. O evento também dispara quando o ID muda depois (reinstalação, troca de dispositivo, revogação/renovação).
Tratando eventos de notificação
const received = Notti.addEventListener('notificationReceived', (payload) => {
console.log(payload.title, payload.body, payload.data);
});
const clicked = Notti.addEventListener('notificationClicked', (payload) => {
console.log(payload.title, payload.body, payload.data);
});
// Call .remove() when done listening, e.g. in a useEffect cleanup.
received.remove();
clicked.remove();'notificationReceived' dispara em foreground; 'notificationClicked' dispara sempre que o app já está em execução (em background ou em foreground) e o usuário toca na notificação. Nenhum dos dois dispara para um toque em cold start - o toque que abre o app acontece antes de qualquer listener poder ser registrado. Verifique esse caso uma vez na inicialização:
Notti.getInitialNotificationClick().then((payload) => {
if (payload) {
console.log('app was launched by a notification tap', payload);
}
});Isso resolve a notificação que abriu o app em cold start, ou null se o app não foi aberto dessa forma, e só resolve uma vez por cold start - o lado nativo a limpa depois que essa chamada a lê.
O payload é { title?: string | null; body?: string | null; data?: { [key: string]: string } }. Ambas as plataformas entregam title/body ausente como null (não undefined), então compare com == null (ou ?? fallback) em vez de === undefined.
'deviceIdChanged' é um evento separado que você pode escutar junto com os eventos de notificação - ele dispara quando o ID do dispositivo é atribuído pela primeira vez ou muda depois (veja ID do dispositivo).
Relatório de eventos de notificação (CTR)
O SDK reporta automaticamente métricas de click-through ao Notti - não há API JS para isso, e nenhuma ação é necessária além de initialize(). Para todo push cujo payload data carregue tanto notification_id quanto delivery_id (definidos pelo Notti quando o push foi enviado pela API de Notifications), o SDK chama POST /v1/apps/{appId}/notifications/{notification_id}/events com o token de push atual do dispositivo:
openedeclicked- o usuário tocou no corpo da notificação (ação padrão), em foreground, em background ou a partir de um cold start. Um toque reporta os dois eventos para a mesma entrega.- somente
clicked- o usuário tocou em um botão de ação customizado (iOS). received- a notificação chegou enquanto o app estava em foreground. Notificações entregues enquanto o app está em background ou encerrado não são reportadas comoreceived.
Dispensas (dismissals) não são reportadas.
As versões do SDK até a 0.5.0 reportam apenas clicked para um toque no corpo. O backend conta uma entrega como aberta quando ela tem um evento opened ou clicked, e como clicada quando tem um evento clicked, então o CTR mantém seu significado entre versões do SDK e a taxa de abertura é igual ao CTR em toda versão do SDK hoje. O SDK 0.6.0 e posteriores também reportam opened.
A entrega é durável, mas best-effort: os eventos são persistidos em disco antes da primeira tentativa de rede, repetidos com o mesmo backoff do registro de dispositivo (veja Política de retentativas abaixo) e reenviados na próxima abertura do app ou mudança de rede se não tiverem sido enviados. Por isso, o backend pode receber o mesmo evento mais de uma vez (entrega at-least-once) - os números de CTR na API de Notifications (delivered_count, received_count, ctr) são calculados para tolerar isso. delivered_count conta as entregas que o provedor aceitou; received_count conta os dispositivos distintos que reportaram received.
Segmentação por localização (opt-in)
setLocationSharingEnabled permite que o país aproximado de um dispositivo seja usado na segmentação por Segmento. Vem desativado por padrão e o SDK nunca solicita a permissão de localização do SO por conta própria:
// Opt in - requires the host app to already hold OS location permission.
Notti.setLocationSharingEnabled(true);
// Opt out - clears any previously reported country server-side.
Notti.setLocationSharingEnabled(false);Com o opt-in ativo, no próximo início de sessão o SDK lê a última localização em cache do dispositivo (sem requisição ativa de localização, sem rastreamento em background) e faz geocodificação reversa no próprio dispositivo para um código de país ISO 3166-1 alpha-2, que é o único valor enviado ao Notti - coordenadas brutas nunca são enviadas nem armazenadas. Se o app host não tiver permissão de localização, os serviços de localização estiverem desligados ou não houver localização em cache, o campo é simplesmente omitido - sem prompt, sem erro.
O opt-out tem efeito imediato e durável: o país do dispositivo é limpo no servidor mesmo que o dispositivo esteja offline ou o app seja encerrado no meio da requisição, reenviando a limpeza pendente no próximo registro/foreground/retorno da rede.
Requisitos do app host:
- iOS: solicite a permissão de localização você mesmo e declare
NSLocationWhenInUseUsageDescriptionnoInfo.plist. - Android: declare e solicite
ACCESS_COARSE_LOCATION(ouACCESS_FINE_LOCATION) você mesmo - o manifest do SDK não a declara.
Política de retentativas
Todas as chamadas HTTP que o SDK faz - registro de dispositivo, toda atualização de dispositivo (tags, assinatura, telemetria, país) e eventos de CTR - compartilham um mesmo loop de retry: até 5 tentativas com backoff exponencial (2s, 4s, 8s, 16s) em erros de rede, 5xx, 408 e 429. Qualquer outro 4xx é terminal e não é repetido.
Notificações push ricas (iOS)
Anexar imagem/vídeo/áudio a um push no iOS exige uma Notification Service Extension (NSE) - um target de app-extension separado que a Apple exige para baixar e anexar mídia antes de a notificação ser exibida. O SDK fornece isso como um helper que você conecta a um target criado por você; não é algo que um Turbo Module consiga injetar automaticamente no seu projeto Xcode.
Não suportado no fluxo managed do Expo - isso exige criar manualmente um target nativo no Xcode, e o
expo prebuildo destruiria. Somente React Native bare (ou um app Expo ejetado).
No Xcode: File > New > Target… > Notification Service Extension.
Defina o iOS Deployment Target da extension com a mesma versão do seu app: o Xcode usa por padrão o iOS mais recente, e a extension não carregaria em dispositivos com uma versão mais antiga.
Se o build falhar com
Sandbox: bash deny(1) file-write-create .../Pods/resources-to-copy-..., defina Build Settings > User Script Sandboxing como No no target da extension: com o sandbox ligado, o script de cópia de recursos do CocoaPods não consegue escrever emPods/.
Adicione-o ao seu Podfile, em um target próprio:
rubytarget 'NotificationService' do pod 'Notti/NotificationServiceExtension', :path => '../node_modules/react-native-notti' endSubstitua o corpo gerado da extensão:
swiftimport UserNotifications import Notti @objc(NotificationService) class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { self.contentHandler = contentHandler bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent NottiNotificationServiceExtension.didReceive(request, withContentHandler: contentHandler) } override func serviceExtensionTimeWillExpire() { guard let contentHandler else { return } NottiNotificationServiceExtension.serviceExtensionTimeWillExpire( for: bestAttemptContent, contentHandler: contentHandler ) } }O nome
@objc(NotificationService)é obrigatório - sem ele, o iOS falha silenciosamente ao instanciar a extensão em runtime.
Somente URLs de anexo https:// são baixadas (limite de 10 MB); qualquer outra cai para a notificação simples. Uma Notification Service Extension não roda no iOS Simulator - teste em um dispositivo físico.

O podspec é dividido em dois subspecs: Notti/Core (o Turbo Module; o default_subspec, então o pod 'Notti' simples e o autolinking continuam resolvendo para ele) e Notti/NotificationServiceExtension (o helper acima, sem dependência de React). Podfiles que fixam subspecs explicitamente devem incluir Core no target do app. Notti/Core linka CoreLocation e referencia APIs de localização mesmo que você nunca ative o opt-in de reporte de país - a validação de upload do App Store Connect pode, por isso, avisar sobre uma purpose string de localização ausente (ITMS-90683) para qualquer app que use este SDK; declarar NSLocationWhenInUseUsageDescription evita isso.
Privacidade, rótulos da App Store e LGPD
Isto não é aconselhamento jurídico - valide com seu próprio time jurídico/DPO antes de publicar.
- O SDK coleta automaticamente, sem opt-out nesta versão: token de push, plataforma, versão do app e agregados de sessão (contagem de sessões, tempo cumulativo em foreground, timestamps da primeira/última sessão) usados para métricas de CTR e engajamento.
- Um botão de mensagem in-app com a ação
push_permissionpede a permissão de notificação do SO ou abre as configurações de notificação do app. Ele não coleta nem envia nada novo: o SDK já reportapermission_statuscomo parte do perfil do dispositivo, e a caixa de diálogo do sistema é uma escolha do próprio usuário. O listener de clique recebe esse clique comaction: null. setLocationSharingEnabled(acima) é o único opt-in: quando ativado, apenas um código de país ISO é enviado, nunca coordenadas.- Para os rótulos "nutrition" da App Store e o formulário Data safety do Google Play, isto tipicamente se mapeia para: Usage Data / Product Interaction (CTR, dados de sessão), Identifiers / Device ID (e User ID se você chamar
login) e Location / Coarse Location se você ativar o reporte de país. - O SDK não coleta consentimento e não decide uma base legal por você - o integrador é responsável pelo aviso de privacidade, pelo fluxo de consentimento e pela política de retenção desses dados sob a LGPD (ou equivalente).
Limitações conhecidas
- Sem confirmação confiável para
app_version. O backend aceita os campos de telemetria e os persiste, e a resposta do PATCH do dispositivo devolveapp_version, mas o SDK não o compara: trata qualquer 2xx como confirmado e não o reenvia até que o valor mude. Os snapshots de sessão são cumulativos e reenviados ao fim de toda sessão, então não são afetados da mesma forma. - Um evento de CTR ruim pode travar a fila de eventos. Os eventos são enviados em ordem e o envio para no primeiro erro não terminal; um evento que recebe
5xx/429de forma determinística bloqueia os eventos atrás dele até que 32 eventos mais novos o empurrem para fora da fila em disco. - Uma limpeza de país pendente que recebe um
4xxpermanente é reenviada a cada gatilho (registro, app em foreground, retorno da rede), porque a flag só é limpa em um 2xx. - Os eventos são entregues at-least-once: um reporte duplicado do mesmo evento
received/opened/clickedé possível após uma falha entre um envio bem-sucedido e a limpeza local. As métricas de CTR são calculadas para tolerar isso. - Ainda não validado em dispositivos reais em todos os cenários: fechamento de sessão em cold start / process kill, entrega em background no iOS sob rede lenta e o valor de
aps-environmentem um buildpreviewdo EAS.
Sem encaminhamento do APNs
Se a etapa de encaminhamento no AppDelegate do iOS descrita acima for omitida, initialize() ainda registra o dispositivo - com estado subscribed: false - mas a entrega de push não se completa até que os callbacks sejam conectados.