Como funciona
- Uma vez, no painel: você cola a chave de push da Apple (.p8) e a conta de serviço do Firebase. O SuperDB confere as duas com a Apple e com o Google antes de guardar.
- No app: a cada abertura, o app manda o token do aparelho para
POST /push/v1/aparelhos. O aparelho fica ligado a um dono — o id do usuário no seu app. - Para mandar: do seu servidor,
POST /push/v1/enviarcom a chave de servidor; ou de dentro do banco,push.enviar()numa função do projeto. Você diz os donos; o SuperDB acha os aparelhos de cada um. - O recibo: para cada aparelho, se a Apple ou o Google aceitou — e, quando recusou, por quê. Aparelho de app desinstalado sai da lista sozinho no primeiro envio que falhar.
No iPhone o SuperDB fala direto com a Apple. No Android não há outro caminho: o Google só entrega push pelo Firebase Cloud Messaging. O Firebase aqui é só o cano — os aparelhos, a regra de quem recebe e o recibo ficam no SuperDB.
1. As chaves (uma vez por projeto)
No painel, abra o projeto → Push.
Apple (iPhone)
- No Apple Developer: Certificates, IDs & Profiles → Keys → +, marque Apple Push Notifications service (APNs) e baixe o arquivo
AuthKey_XXXXXXXXXX.p8. A Apple deixa baixar uma vez só: guarde. - No painel, suba o .p8 e preencha o Key ID (os 10 caracteres do nome do arquivo), o Team ID (em Membership) e o Bundle ID (o
ios.bundleIdentifierdo app.json). - O painel confere com a Apple nos dois ambientes — produção (App Store, TestFlight) e sandbox (build de desenvolvimento) — e diz se algum recusou.
Android (Firebase Cloud Messaging)
- No console do Firebase: Configurações do projeto → Contas de serviço → Gerar nova chave privada. Baixa um JSON com
"type": "service_account". - Suba esse JSON no painel. O
google-services.jsoné outro arquivo: ele vai dentro do app (android.googleServicesFileno app.json). - O painel confere com o Google. Se ele disser que a API está desligada, ative a Firebase Cloud Messaging API no Google Cloud do mesmo projeto — o recado do Google traz o link.
A .p8 e a chave privada do Google ficam cifradas com AES-256-GCM, com uma chave que só abre no contexto do seu projeto, e nenhuma resposta da API as devolve. Sem painel, o mesmo pela API de gestão (management key sdb_pmk_):
curl -X PUT https://auth.superdb.com.br/platform/v1/projects/<projectId>/push/credenciais/apns \
-H "Authorization: Bearer $SUPERDB_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile p8 AuthKey_ABC123DEF4.p8 \
'{key_id: "ABC123DEF4", team_id: "TEAM123456", bundle_id: "br.com.suaempresa.app", p8: $p8}')"
# Android: PUT .../push/credenciais/fcm com {"conta_de_servico": "<o JSON inteiro>"}
2. Registrar o aparelho (no app)
Com Expo, o token nativo vem de getDevicePushTokenAsync(). Não use o ExponentPushToken: ele só funciona pelo serviço de push do Expo, e a API recusa com token_do_expo.
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
export async function registrarAparelho(superdb, anonKey, idDoJogador) {
const { status } = await Notifications.requestPermissionsAsync()
if (status !== 'granted') return
const { data: token } = await Notifications.getDevicePushTokenAsync()
// Logado: o data_plane_token da sessão — o dono vira o usuário, verificado.
// Sem login: a anon key + um id seu no campo "dono" (não verificado).
const sessao = await superdb.auth.getDataPlaneToken()
await fetch('https://auth.superdb.com.br/push/v1/aparelhos', {
method: 'POST',
headers: {
Authorization: `Bearer ${sessao ?? anonKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
plataforma: Platform.OS === 'ios' ? 'ios' : 'android',
...(sessao ? {} : { dono: idDoJogador }),
versao_app: '1.4.0',
}),
})
}
| Campo | O que é |
|---|---|
token | O data de getDevicePushTokenAsync(). No iPhone é hexadecimal; no Android, o token do FCM. |
plataforma | ios ou android. |
ambiente | Opcional: producao (padrão) ou sandbox. Se errar, tudo bem: quando a Apple diz que o token não é daquele ambiente, o SuperDB tenta o outro e grava o certo. |
dono | O id do usuário no seu app, até 200 caracteres. Com a sessão do usuário, é ignorado — o dono é quem está logado. |
versao_app | Opcional, até 60 caracteres. Aparece no painel. |
A resposta é 201 na primeira vez e 200 nas seguintes (o mesmo token atualiza a mesma linha): { "id", "dono", "dono_verificado", "ativo": true }. Guarde o id se quiser mandar para um aparelho específico.
Quem é o dono
| Credencial usada no registro | Dono | Verificado |
|---|---|---|
Sessão do usuário (data_plane_token) | O usuário logado | Sim |
Chave de servidor (service_role) | O dono do corpo | Sim — é o seu backend dizendo |
| Chave anon | O dono do corpo | Não |
A chave anon é pública: qualquer um que a extraia do app pode registrar um aparelho em nome de qualquer id. Sem login, não ponha no push o que não pode vazar — "é sua vez na sala 4" pode; o saldo da conta, não.
Quem tem login fica protegido de três jeitos: a chave anon não pode declarar como dono um usuário do Auth do projeto (403 dono_e_usuario — esse registro é com a sessão); um registro com a anon não troca o dono de um aparelho que uma sessão verificou; e um dono que tem aparelho verificado não recebe nos aparelhos só declarados.
Se o seu app tem identidade própria (sem o Auth do SuperDB), registre pelo seu servidor, com a chave de servidor: o dono vira verificado.
No logout, descadastre — senão o próximo usuário daquele celular recebe o push do anterior:
await fetch('https://auth.superdb.com.br/push/v1/aparelhos', {
method: 'DELETE',
headers: { Authorization: `Bearer ${anonKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
Teste num development build (EAS), não no Expo Go: no Expo Go o token nativo é do app Expo Go, e a Apple recusa com DeviceTokenNotForTopic. Com o plugin do expo-notifications no app.json, o build do iOS já sai com a capacidade de push ligada.
3. Mandar pelo seu servidor
Só com a chave de servidor (service_role), que nunca vai no app. Com a anon ou com a sessão, a API responde 403 chave_de_servidor.
curl -X POST https://auth.superdb.com.br/push/v1/enviar \
-H "Authorization: Bearer $SUPERDB_SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"destinatarios": ["id-do-jogador"],
"titulo": "É sua vez",
"corpo": "A Ana jogou. A mesa está esperando você.",
"dados": { "sala": "sala-4" },
"opcoes": { "agrupar": "sala-4", "ttl": 600 }
}'
Resposta 202 { "id", "status": "pendente", "recibo": "/push/v1/envios/<id>" }. O envio entra numa fila e sai em cerca de um segundo.
| Campo | Regra |
|---|---|
destinatarios | Ids dos donos. Até 500 por envio — divida em lotes. |
aparelhos | Opcional: ids de aparelho (os do registro), até 500. |
titulo / corpo | De 1 a 200 / de 1 a 1.000 caracteres. |
dados | Objeto JSON de até 3 KB. Chega no app junto com a notificação (no Android, cada valor vira texto). Título, corpo e dados somados: até 3,5 KB — a Apple aceita 4 KB por notificação. |
As opcoes, todas opcionais — uma opção inválida é ignorada, não derruba o envio:
| Opção | Efeito |
|---|---|
ttl | Segundos que a Apple e o Google seguram a mensagem se o aparelho estiver desligado (até 28 dias). |
agrupar | Mensagens com a mesma chave se substituem no aparelho — "é sua vez" da mesma sala não empilha. Até 64 bytes. |
som | Nome do arquivo de som do app, ou null para silencioso. O padrão é o som padrão. |
badge | O número no ícone (iPhone). |
prioridade | alta (padrão) ou normal. |
canal_android | O canal de notificação do Android (precisa existir no app). |
4. Mandar de dentro do banco
Quando o dado muda no banco, o push pode sair dali mesmo — sem servidor seu no meio. Rode no SQL Editor do painel (pode rodar de novo sem medo):
-- Quem está em cada sala. O seu servidor (ou uma função sua) grava quem entrou.
create table if not exists sala_jogadores (
sala text not null,
jogador uuid not null,
primary key (sala, jogador)
);
-- A jogada: quem jogou e para quem passou a vez.
create table if not exists jogadas (
id bigint generated always as identity primary key,
sala text not null,
jogador uuid not null default auth.uid(),
proximo_jogador uuid not null,
feita_em timestamptz not null default now()
);
alter table sala_jogadores enable row level security;
alter table jogadas enable row level security;
-- "Esta pessoa está nesta sala?" SECURITY DEFINER: lê sala_jogadores com a
-- permissão do dono do projeto, sem dar leitura da tabela a ninguém.
create or replace function na_sala(p_sala text, p_jogador uuid) returns boolean
language sql stable security definer set search_path from current as $$
select exists (select 1 from sala_jogadores where sala = p_sala and jogador = p_jogador)
$$;
-- Só joga quem está na sala, e só passa a vez para quem também está.
drop policy if exists jogar on jogadas;
create policy jogar on jogadas
for insert to authenticated
with check (jogador = auth.uid() and na_sala(sala, auth.uid()) and na_sala(sala, proximo_jogador));
revoke all on sala_jogadores, jogadas from anon, authenticated;
grant insert on jogadas to authenticated;
-- SECURITY DEFINER: roda como o dono do projeto, e é esse papel que o
-- push.enviar confere para saber de qual projeto o push sai.
create or replace function avisa_proximo() returns trigger
language plpgsql security definer set search_path = pg_catalog, pg_temp as $$
begin
-- No máximo um aviso por jogador a cada 30 segundos: jogada repetida não vira
-- enxurrada no celular de ninguém, nem gasta o limite do mês.
if exists (select 1 from push.envios
where destinatarios = array[new.proximo_jogador::text]
and criado_em > now() - interval '30 seconds') then
return new;
end if;
perform push.enviar(
array[new.proximo_jogador::text],
'É sua vez',
'A mesa está esperando você',
jsonb_build_object('sala', new.sala),
jsonb_build_object('agrupar', new.sala)
);
return new;
end $$;
drop trigger if exists avisa_proximo on jogadas;
create trigger avisa_proximo after insert on jogadas
for each row execute function avisa_proximo();
Duas travas da receita valem para qualquer push disparado de dentro do banco: a policy decide para quem se pode mandar (aqui, só para quem está na mesma sala) e o gatilho segura a frequência (um aviso por jogador a cada 30 segundos). Sem as duas, qualquer usuário logado mandaria push para qualquer outro, quantas vezes quisesse — e gastaria o limite do mês do seu projeto.
A assinatura: push.enviar(destinatarios text[], titulo text, corpo text, dados jsonb default '{}', opcoes jsonb default '{}') returns uuid — devolve o id do envio. As regras de tamanho são as mesmas da API. Dentro da função, push.envios mostra só os envios do seu projeto — é o que o freio da receita consulta.
push.enviar precisa rodar como o dono do projeto — por isso a função é security definer, criada pelo SQL Editor ou pela API de SQL. Chamado direto pela anon, pela sessão ou pela service_role, ele recusa: esses papéis são os mesmos em todos os projetos e não dizem de qual projeto o push sairia. E se expuser uma função dessas por RPC, confira dentro dela quem pode avisar quem — senão qualquer usuário manda push para qualquer outro.
5. O recibo
Pelo servidor: GET /push/v1/envios/<id> com a chave de servidor. No painel: Push → Últimos envios. No SQL Editor: select * from push.envios e select * from push.entregas — cada projeto enxerga só os próprios.
{
"id": "…", "status": "concluido", "total": 2, "aceitos": 1, "recusados": 1,
"entregas": [
{ "dono": "id-do-jogador", "plataforma": "android", "resultado": "aceito", "motivo": null },
{ "dono": "id-do-jogador", "plataforma": "ios", "resultado": "recusado",
"motivo": "apns:Unregistered",
"explicacao": "O app foi desinstalado ou as notificações foram desligadas neste iPhone. O aparelho saiu dos ativos." }
]
}
| Motivo | O que fazer |
|---|---|
apns:Unregistered, fcm:UNREGISTERED | Nada: o app saiu do aparelho, e o aparelho saiu dos ativos. |
apns:BadDeviceToken, apns:DeviceTokenNotForTopic | O token é de outro app ou de outro Bundle ID (Expo Go, por exemplo). |
apns:InvalidProviderToken | A Apple não aceitou a chave: confira Key ID, Team ID e a .p8 no painel. |
fcm:SENDER_ID_MISMATCH | A conta de serviço é de outro projeto do Firebase que o do google-services.json. |
sem_credencial_apns, sem_credencial_fcm | Falta a chave daquela plataforma no painel. |
cota_mensal_esgotada | Acabou o limite do plano no mês. |
apns:indisponivel, fcm:indisponivel, prazo_esgotado | A Apple ou o Google estavam fora do ar ou lentos, e o resto do envio foi interrompido para não segurar a fila. Não conta no limite: mande de novo. |
apns:chave_de_outro_ambiente | A chave da Apple está restrita a produção ou a sandbox, e o aparelho é do outro ambiente. |
"Aceito" quer dizer que a Apple ou o Google recebeu. Se a notificação não aparecer, a causa mais comum é a permissão de notificação desligada no aparelho.
Limites e entrega
- Por mês: cada aparelho que foi à Apple ou ao Google conta um envio, aceito ou não; recusa nossa (sem credencial, fora do limite, mensagem grande demais, serviço fora do ar) não conta. O limite depende do plano — no Grátis e no Site é para construir e testar; os números estão em preços. Passou do limite,
POST /push/v1/enviarresponde429 cota_mensal_esgotadacomRetry-Afteraté a virada do mês (meia-noite de Brasília). - Fila: até 1.000 envios pendentes por projeto; passou disso,
429 fila_cheia. - Entrega pelo menos uma vez: se o servidor cair no meio de um envio, ele volta para a fila e algum aparelho pode receber duas vezes. Com
agrupar, a segunda substitui a primeira. - Até 10 aparelhos ativos por dono: quando entra o 11º, o que está há mais tempo sem abrir o app sai.
Dados e LGPD
O aparelho liga um celular a uma pessoa, e é tratado como dado pessoal:
- Quando o usuário apaga a conta, os aparelhos dele param de receber na hora e saem de vez na exclusão definitiva. O
GET /auth/v1/lgpd/exporttraz os aparelhos cadastrados (sem o token). - Recibos e envios ficam 30 dias. Aparelho desativado sai depois de 90 dias; aparelho que não abre o app há 270 dias também sai.
- O SQL do projeto lê os aparelhos sem a coluna do token, e ninguém além da plataforma lê as credenciais.
O que ainda não fazemos
- Push na web (notificação no navegador) — ainda não.
- Token do Expo (
ExponentPushToken) — não: o caminho é o token nativo. - Agendar um envio para depois, e tópicos ou segmentos — ainda não. Para agendar, dispare do seu servidor na hora certa.
- Método no SDK — ainda não: por enquanto é
fetch, como nos exemplos.