Pitch: Por que seu push não chegou: os erros do FCM e do APNs, e as falhas que ninguém reporta (com o que a gente aprendeu no Espresso Notify)
Uma notificação push tem duas vidas. Na primeira, seu servidor pede ao Firebase Cloud Messaging (FCM) ou ao APNs da Apple para entregar e recebe uma resposta. Na segunda, um celular em algum lugar decide se mostra ou não. A maior parte das horas perdidas em debug vem de ler a resposta da primeira vida como se ela descrevesse a segunda.
Este texto cobre as duas: o que os erros do FCM (HTTP v1) e do APNs significam, quais você deve repetir, quais são "apague esse token", e as falhas que voltam sem erro nenhum.
"Aceito" não é "entregue"
Quando o APNs responde 200, ele aceitou a sua requisição. Quando o FCM devolve o nome da mensagem, também. Um serviço de web push que responde 201 Created é a mesma coisa: a RFC 8030 diz que a mensagem "foi aceita", só isso.
A documentação da Apple não dá ao servidor que enviou um sinal de que a notificação foi exibida. O FCM é mais generoso, mas também separa as coisas: na exportação para o BigQuery, MESSAGE_ACCEPTED quer dizer que o servidor do FCM recebeu uma requisição válida, e MESSAGE_DELIVERED quer dizer que ela chegou ao SDK do FCM no aparelho, e esse campo não vem exportado por padrão. Os relatórios de entrega ainda dependem do Google Analytics e podem atrasar até 24 horas.
Para o seu pipeline: guarde "aceito pelo provedor" e "entregue/aberto" como fatos diferentes. O primeiro vem da resposta do provedor. Os outros dois só existem se o app avisar o servidor.
Coloque todo erro em um de quatro baldes
Antes das tabelas, uma regra que economiza horas. Todo erro de provedor cai em um destes:
- O token morreu. Apague e nunca mais envie para ele.
- A sua requisição está errada. Corrija; repetir a mesma requisição repete a falha.
- Credencial ou configuração errada. Conserte o setup; isso afeta muitos aparelhos de uma vez.
- O provedor está com problema, ou você está indo rápido demais. Tente de novo depois, com backoff exponencial e jitter.
Misturar os baldes é como times acabam apagando tokens saudáveis durante uma instabilidade, ou repetindo para sempre o envio a um token morto.
FCM (HTTP v1)
| Erro | HTTP | O que significa | O que fazer |
|---|---|---|---|
UNREGISTERED | 404 | O token não vale mais: o app foi desinstalado, cancelou o registro ou o token expirou | Apague o token |
INVALID_ARGUMENT | 400 | Algum parâmetro é inválido: token malformado, nome de pacote errado, mensagem grande demais, chave de dados reservada, TTL inválido | Leia o detalhe do BadRequest. Corrija a mensagem. Só apague o token depois de confirmar que o payload está válido |
SENDER_ID_MISMATCH | 403 | O remetente com que você se autenticou não é o remetente para o qual o token foi registrado | Confira se a service account é do mesmo projeto Firebase do app. Não apague tokens antes disso |
QUOTA_EXCEEDED | 429 | Taxa excedida para o projeto, para um aparelho ou para um tópico | Reduza a taxa. O FCM recomenda backoff exponencial com atraso inicial de pelo menos 1 minuto para taxa de mensagens e de tópico |
UNAVAILABLE | 503 | O FCM está sobrecarregado | Repita a mesma requisição, respeite o Retry-After se vier, use backoff exponencial e jitter |
INTERNAL | 500 | Erro interno desconhecido do FCM | Repita com backoff; veja a página de status do FCM; se persistir, fale com o suporte do Firebase |
THIRD_PARTY_AUTH_ERROR | 401 | O certificado do APNs ou a chave de web push está inválida ou ausente, para uma mensagem a iOS ou a um registro web | Corrija as credenciais de APNs ou de web push no console do Firebase |
Três detalhes da documentação oficial que passam batido:
INVALID_ARGUMENTé o ambíguo. O guia de tokens do próprio FCM diz que pode ser registro inválido ou payload ruim, e orienta tratar como token morto só quando você confirmou que o payload está certo. Registre o detalhe do campo antes de apagar qualquer coisa.- O limite de tamanho é 4096 bytes para a maioria das mensagens e 2048 bytes para mensagens a tópicos, contando chaves e valores. O TTL é um inteiro entre 0 e 2.419.200 segundos (4 semanas).
- No Android, o FCM expira um registro após 270 dias de inatividade. No iOS ele depende do APNs, que não tem essa expiração, então o Firebase recomenda que você mesmo limpe tokens antigos.
APNs
| Status | reason | O que significa | O que fazer |
|---|---|---|---|
| 400 | BadDeviceToken | O token é inválido, ou não bate com o ambiente | Primeiro confira sandbox vs produção. Só depois trate como morto |
| 400 | DeviceTokenNotForTopic | O token não pertence ao topic (bundle ID) que você enviou | Corrija o apns-topic. A Apple lista entre os erros que não se repete como está |
| 400 | TopicDisallowed | Enviar para esse topic não é permitido | Confira se o topic é permitido para o seu app e a sua chave |
| 403 | ExpiredProviderToken | O seu JWT está vencido | Gere outro e repita |
| 403 | InvalidProviderToken | O JWT é inválido ou a assinatura não pode ser verificada | Confira a chave, o Key ID e o Team ID |
| 410 | Unregistered | O token não está mais ativo para o topic; a resposta traz um timestamp | Pare de enviar para ele |
| 413 | PayloadTooLarge | O payload passou do limite | Reduza. 4 KB (4096 bytes), 5 KB para VoIP |
| 429 | TooManyRequests | Requisições demais para o mesmo token de aparelho | Repita com atraso |
| 429 | TooManyProviderTokenUpdates | Você renova o JWT com frequência demais | Renove no máximo uma vez a cada 20 minutos |
| 500 / 503 | InternalServerError, ServiceUnavailable, Shutdown | Problema do lado da Apple | Repita com backoff |
As regras nas palavras da própria Apple valem ficar perto do código: não repita BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered nem PayloadTooLarge; TooManyRequests pode ser repetido com atraso; respostas 5xx podem ser repetidas, e a Apple fala em esperar 15 minutos e usar back-off.
Mais duas coisas dessa página. Erros 4xx te atrasam: o APNs pode derrubar a conexão de um provedor que produz erros demais, e derruba mais cedo no caso de BadDeviceToken. Enviar para token morto não é só desperdício, é frear o seu tráfego bom. E o BadDeviceToken tem uma causa clássica: token de build do Xcode (desenvolvimento) pertence ao ambiente sandbox, enquanto builds do TestFlight e da App Store pertencem à produção. Enviou para o host errado, todo token do outro tipo falha.
Para o seu pipeline: mapeie cada código para um balde em um lugar só (uma tabela de consulta, não ifs espalhados) e registre o código bruto ao lado da sua classificação. Quando aparecer um motivo novo, o padrão deve ser "não apaga, não repete às cegas, alerta".
Falhas que voltam sem erro
Esta é a parte em que as tabelas não ajudam. O provedor disse sim, e o usuário não viu nada.
As notificações estão desligadas para o app. No Android 13 e posteriores, notificações vêm desligadas por padrão numa instalação nova, e se o usuário toca em "Não permitir", todos os canais ficam bloqueados. O token continua válido e o envio continua sendo aceito. O FCM ainda avisa que mensagens de alta prioridade que não geram notificação visível podem ser rebaixadas numa janela de 7 dias. Chame areNotificationsEnabled() no app e mande o resultado ao servidor junto com o token. Não perturbe e modos de Foco funcionam igual do ponto de vista do servidor: o sistema decide o que mostrar, e nada avisa você.
Doze e prioridade. Mensagens de prioridade normal do FCM podem ficar retidas enquanto o aparelho está em Doze; a prioridade alta é a opção pensada para conteúdo urgente e visível. O APNs tem a mesma ideia: apns-priority 10 é "agora", 5 é "quando a energia permitir", e 5 ou 1 podem ser agrupadas em rajadas ou limitadas. Escolha a prioridade por tipo de mensagem, não um padrão global.
Expiração com o celular offline. Sem TTL, o FCM pode guardar uma mensagem por até quatro semanas; com TTL 0, descarta o que não consegue entregar na hora. O APNs com apns-expiration: 0 tenta uma vez e não armazena. Se o push de "a promoção acaba em 1 hora" ficou na fila um dia, ele deveria ter expirado; defina o TTL de propósito.
Fabricantes de Android que matam apps. Alguns fabricantes encerram apps em segundo plano de forma agressiva para poupar bateria. O site da comunidade dontkillmyapp.com coloca Xiaomi, Samsung, OnePlus e Huawei na pior nota. Você não resolve isso pelo servidor, mas dá para detectar o padrão: se as suas estatísticas de entrega estão boas no geral e ruins para uma marca de aparelho, esse é o primeiro suspeito. Aponte os usuários para os passos específicos de cada fabricante na sua página de suporte.
Expo Go. Se você testa com Expo Go, o push remoto não está disponível lá no Android a partir do SDK 53; o Expo diz que é preciso um development build, e as notificações locais continuam funcionando no Expo Go. "Funciona no meu build e não no Expo Go" é comportamento esperado.
Web push. Três coisas pegam todo mundo. No iOS o pedido de permissão precisa vir de uma interação direta do usuário, e pedir a partir de um botão é o padrão seguro em qualquer lugar. O push precisa de um service worker registrado. E no iOS e iPadOS 16.4 ou posterior, o web push funciona para web apps adicionados à Tela de Início, não para uma aba comum do Safari. Quando uma assinatura expirou, a RFC 8030 diz que o serviço de push responde 404: apague a assinatura.
Uma ordem de diagnóstico que funciona
- O provedor aceitou a requisição? Leia o status e o
reason/código de erro, não só "sucesso". - O token está vivo? Veja a idade dele e quando o app o registrou pela última vez.
- Ambiente certo? Sandbox vs produção no APNs, projeto Firebase certo no FCM.
- O payload está abaixo do limite, com TTL e prioridade razoáveis?
- O app pode mostrar notificações nesse aparelho (permissão, canal, modos de foco)?
- O aparelho é de uma marca que mata apps, ou o build não recebe push remoto (Expo Go)?
- Se você controla o app, ele reportou o recebimento? Se não, você está chutando.
Sobre o projeto
Eu trabalho no Espresso Notify, um serviço de push para iOS, Android e web com dashboard e API REST, e os casos acima moldaram a forma como ele reporta uma falha. Cada aparelho que falha recebe um código estável e o que fazer a respeito, em vez de uma string crua do provedor; tokens que o provedor diz estarem mortos são removidos para o próximo envio pular; e a documentação diz com todas as letras que "enviado" significa que o provedor aceitou. É grátis até 10.000 aparelhos.
- Como os resultados de entrega são reportados: https://docs.espressonotify.com/docs/sending/verify-delivery
- Configuração da chave do iOS, incluindo a escolha entre sandbox e produção: https://docs.espressonotify.com/docs/credentials/apns
Uma pergunta para os comentários: quando o FCM devolve INVALID_ARGUMENT, como vocês decidem no pipeline entre "payload ruim" e "token morto"? Lê o detalhe do BadRequest ou espera uma segunda falha?