Como criar o backend de um app Flutter: login, dados e notificações push

Um app Flutter ganha contas, uma classe de notas em que cada linha pertence ao seu usuário, paginação e registro de push com doze chamadas REST e um hook no backend. Medimos cada chamada e achamos o update que diz 200 e não faz nada.

As chamadas REST que um app Flutter faz a um backend gerenciado para login, notas por usuário e registro de push, com o status e o tempo mediano medidos num teste Flutter em 24 de setembro de 2026, e as recusas que outro usuário recebe, incluindo um update que devolve 200 e não muda nada até um hook no backend transformá-lo em 404 / 101

Tradução do original em inglês, testado em 24 de setembro de 2026. Código, números e datas são os do original. Read in English

Você tem um app Flutter que funciona com dados falsos. Ele precisa de três coisas reais antes que outra pessoa possa usá-lo: contas, um lugar para guardar os dados de cada usuário onde os outros não enxerguem, e um jeito de alcançar o telefone quando o app está fechado. Nenhuma delas é uma tela. Todas são backend.

Um backend gerenciado dá a um app Flutter as três por HTTPS puro: uma classe de usuários com sessões, classes que você define com permissões por linha e uma classe Installation que um serviço de push lê. Seu app fala com ele via package:http, dois headers e um session token, sem SDK e sem administrar um servidor.

Construímos as metades de dados e de push em cima do starter de autenticação do post anterior, rodamos tudo com flutter test contra um backend Back4app real em 24 de setembro de 2026 e medimos cada chamada. Doze chamadas REST e um hook no backend. Uma das chamadas mente, e o hook é o que faz ela parar.

Pegue o código funcionando: o repositório companion está em github.com/templates-back4app/flutter-backend-starter: lib/notes_api.dart, lib/push_api.dart, lib/main.dart, cloud/main.js, scripts/create-note-class.sh e dois testes que rodam o fluxo inteiro contra o seu backend e imprimem a tabela de tempos mostrada abaixo.

O que um app Flutter precisa do backend antes de ser publicado?

Contas, dados que pertencem a um usuário e um canal de volta ao dispositivo. Todo o resto é desejável, não essencial. Veja o que cada item exige e onde ele fica quando o backend é gerenciado:

O app precisa deO que isso exigeQuem faz aqui
Criar conta, entrar, continuar logadoHash de senha, session tokens, revogação, e-mail de redefiniçãoBackend: _User, _Session, POST /users, POST /login
Dados que o usuário possuiUma classe, um dono em toda linha, uma regra de que outros usuários não podem lerBackend: classe Note, ACL, permissão por classe, hook beforeSave
Listas que escalamFiltros, ordenação, paginação, um totalBackend: where, order, limit, skip, count=1 em GET /classes/Note
Notificações pushUm armazém de tokens de dispositivo, um remetente que guarda as credenciaisBackend: classe _Installation, Parse.Push.send no Cloud Code, FCM/APNs
Regras que o app não consegue pularCódigo que roda em toda escrita, seja quem for que a enviouBackend: Cloud Code, com deploy pela dashboard

Do lado do app, tudo isso é um helper HTTP e três classes pequenas. A metade das contas é o post anterior, Como adicionar autenticação a um app web ou Flutter sem escrever um servidor de auth; o lib/auth_api.dart daqui são as mesmas quatro chamadas, com a mesma forma. Este post cobre as outras duas metades.

Arquitetura: um app Flutter com package:http envia o App ID, uma chave de cliente e o session token à REST API do backend; o backend mantém as classes _User, _Session, Note e _Installation, roda um hook beforeSave em Note que carimba owner e ACL, e seu Cloud Code envia pushes pelo FCM ou APNs ao dispositivo; o app nunca chama /push

Toda chamada passa por um método. Ele adiciona os dois headers que identificam o app, o session token assim que existe um, e um installation id para que o backend saiba qual dispositivo fez uma alteração:

// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/backend.dart
class Backend {
  String? sessionToken;   // in memory; persist it with flutter_secure_storage in a real app
  String? installationId; // sent as X-Parse-Installation-Id so Cloud Code knows which device made a change

  static const _headers = {
    'X-Parse-Application-Id': appId,
    'X-Parse-Client-Key': clientKey,   // a client key: identifies the app, not the user
    'X-Parse-Revocable-Session': '1',
    'Content-Type': 'application/json',
  };

  Future<Map<String, dynamic>> call(String method, String path,
      {Map<String, dynamic>? body, Map<String, String>? query}) async {
    final headers = {
      ..._headers,
      if (sessionToken != null) 'X-Parse-Session-Token': sessionToken!,
      if (installationId != null) 'X-Parse-Installation-Id': installationId!,
    };
    final uri = Uri.parse('$backendUrl$path').replace(queryParameters: query);
    final r = switch (method) {
      'GET' => await _http.get(uri, headers: headers),
      'POST' => await _http.post(uri, headers: headers, body: jsonEncode(body ?? {})),
      'PUT' => await _http.put(uri, headers: headers, body: jsonEncode(body ?? {})),
      'DELETE' => await _http.delete(uri, headers: headers),
      _ => throw ArgumentError(method),
    };
    final data = r.body.isEmpty ? <String, dynamic>{} : jsonDecode(r.body) as Map<String, dynamic>;
    if (r.statusCode >= 400) {
      throw BackendError(r.statusCode, data['code'] as int?, data['error']?.toString() ?? r.reasonPhrase ?? '');
    }
    return data;
  }
}

BackendError carrega o status HTTP e o code numérico do corpo. A tela ramifica pelo código: um 209 significa que o token foi revogado e o formulário volta; um 101 significa que a linha não é sua ou não existe.

Como manter cada nota privada para o usuário que a escreveu?

Com três configurações no backend e nenhuma no app: a classe só responde requisições que carregam um session token, toda linha carrega uma ACL que nomeia seu dono e mais ninguém, e um hook beforeSave define esse dono e essa ACL a partir da sessão, seja o que for que o cliente enviou. O app também os define, como cortesia a si mesmo; o hook é o que os torna verdade.

A classe, criada travada. O jeito preguiçoso de criar uma classe é escrever nela: o primeiro POST /classes/Note cria a classe, aberta para todo mundo. Fizemos do outro jeito, pelo terminal com a master key (chave mestra), antes que qualquer cliente tivesse a chance:

# Stack: bash + curl (schema API, master key) | File: scripts/create-note-class.sh
CLP='{"find":{"requiresAuthentication":true},"get":{"requiresAuthentication":true},"count":{"requiresAuthentication":true},
      "create":{"requiresAuthentication":true},"update":{"requiresAuthentication":true},"delete":{"requiresAuthentication":true},
      "addField":{}}'
FIELDS='{"title":{"type":"String"},"body":{"type":"String"},"done":{"type":"Boolean"},
         "owner":{"type":"Pointer","targetClass":"_User"}}'
curl -s -w "\nHTTP %{http_code}\n" -H "X-Parse-Application-Id: $APP_ID" -H "X-Parse-Master-Key: $MASTER_KEY" \
  -H "Content-Type: application/json" -X POST "$BASE/schemas/Note" \
  -d "{\"className\":\"Note\",\"fields\":$FIELDS,\"classLevelPermissions\":$CLP}"

O backend respondeu HTTP 200 com o schema ecoado de volta às 13:04:16 UTC, e cinco requisições anônimas de listagem nos segundos seguintes receberam 404 com {"code":101,"error":"Permission denied, user needs to be authenticated."}. addField: {} significa que nenhum cliente consegue adicionar uma coluna enviando um campo desconhecido.

A linha, com dono pela mão do app. NotesApi.create envia o pointer do dono e uma ACL que concede leitura e escrita a esse único usuário:

// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/notes_api.dart
Map<String, dynamic> get _ownerPointer => {'__type': 'Pointer', 'className': '_User', 'objectId': userId};

/// POST /classes/Note — owner pointer + owner-only ACL set here AND by the hook.
Future<Note> create(String title, {String body = ''}) async {
  final r = await backend.call('POST', '/classes/Note', body: {
    'title': title,
    'body': body,
    'done': false,
    'owner': _ownerPointer,
    'ACL': {userId: {'read': true, 'write': true}},   // nobody else, not even the public, can read this row
  });
  return Note(id: r['objectId'] as String, title: title, body: body, done: false,
      createdAt: DateTime.parse(r['createdAt'] as String), updatedAt: DateTime.parse(r['createdAt'] as String));
}

O backend responde 201 {"objectId":"fTnbKHLE7C","createdAt":"2026-09-24T13:08:45.130Z"}. Ler a linha de volta com a master key mostra a ACL guardada exatamente como foi enviada: "ACL":{"9MqGQbn5kM":{"read":true,"write":true}}.

A linha, com dono pela mão do backend. Tudo o que o app pode definir, um comando curl pode omitir. Então os mesmos dois campos são definidos de novo num hook beforeSave, a partir de request.user, que o backend resolve do session token e que o cliente não consegue falsificar:

// Stack: Node.js 22.x | Parse Server 7.x | File: cloud/main.js
Parse.Cloud.beforeSave("Note", (request) => {
  const note = request.object;
  if (!request.user) throw new Parse.Error(Parse.Error.SESSION_MISSING, "log in to save a note.");
  const title = (note.get("title") ?? "").trim();
  if (!title) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "a note needs a title.");
  note.set("title", title);
  if (note.isNew()) {
    note.set("owner", request.user);                 // request.user is resolved from the session token, never from the body
    note.setACL(new Parse.ACL(request.user));        // owner: read + write; everyone else, including the public: nothing
    if (note.get("done") === undefined) note.set("done", false);
  } else if (note.dirty("owner")) {
    throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, "a note cannot change owner.");
  }
});

Rodamos os testes antes do deploy (13:07 UTC) e de novo depois dele (Cloud Code → main.js → Deploy; testes rodados de novo às 14:13 UTC). Com o hook no ar, um POST /classes/Note {"title":"hook only"} seco voltou 201 com "ACL", "owner" e "done": false que o cliente nunca enviou.

Um título vazio recebeu 400 / 142 a note needs a title. O que o hook mudou para outro usuário está duas seções abaixo.

Como ler, paginar e filtrar notas a partir do Dart?

Com um GET /classes/Note e quatro parâmetros de query: where (um objeto JSON), order, limit e skip, mais count=1 para receber o total junto com a página. O lado Dart monta o where a partir dos filtros que a tela oferece e decodifica a página num NotePage pequeno, que sabe se há mais:

// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/notes_api.dart (continued)
class NotePage {
  NotePage(this.items, this.total, this.skip);
  final List<Note> items;
  final int total;       // from count=1: total rows the query matches, not just this page
  final int skip;
  bool get hasMore => skip + items.length < total;
}

/// GET /classes/Note?where=…&order=-createdAt&limit=…&skip=…&count=1
/// [done] filters on the boolean; [titleStartsWith] is a case-insensitive prefix match.
Future<NotePage> list({int limit = 20, int skip = 0, bool? done, String? titleStartsWith}) async {
  final where = <String, dynamic>{
    'owner': _ownerPointer,                         // explicit even though the ACL already hides other users' rows
    if (done != null) 'done': done,
    if (titleStartsWith != null && titleStartsWith.isNotEmpty)
      'title': {'\$regex': '^${RegExp.escape(titleStartsWith)}', '\$options': 'i'},
  };
  final r = await backend.call('GET', '/classes/Note', query: {
    'where': jsonEncode(where),
    'order': '-createdAt',
    'limit': '$limit',
    'skip': '$skip',
    'count': '1',
  });
  final items = (r['results'] as List).cast<Map<String, dynamic>>().map(Note.fromJson).toList();
  return NotePage(items, r['count'] as int, skip);
}

/// PUT /classes/Note/:id — only the fields you pass change.
Future<DateTime> update(String id, {String? title, String? body, bool? done}) async {
  final r = await backend.call('PUT', '/classes/Note/$id', body: {
    if (title != null) 'title': title,
    if (body != null) 'body': body,
    if (done != null) 'done': done,
  });
  return DateTime.parse(r['updatedAt'] as String);
}

/// DELETE /classes/Note/:id
Future<void> delete(String id) => backend.call('DELETE', '/classes/Note/$id');

O teste cria três notas, Groceries, Call the bank e Grout the bathroom, e pede limit=2: dois itens de volta, count: 3, hasMore verdadeiro. skip=2 devolve a terceira e hasMore falso. Um where com title: {$regex: "^gr", $options: "i"} devolve as duas que começam com gr. Depois de update(done: true) na primeira, where: {done: true} devolve exatamente essa.

A condição owner no where é redundante com a ACL: o backend já descarta as linhas que a sessão não pode ler. Ela fica ali para o dia em que uma nota ganhar uma lista sharedWith e a query precisar dizer de quem são as notas.

A tela é a versão simples de tudo isso: um campo de texto e Add, três chips de filtro, um checkbox por nota, Load more enquanto hasMore for verdadeiro e um ícone de apagar. Cada toque é uma chamada e um reload da página atual, e a barra de status cronometra o par.

A tela de notas do Flutter numa janela do tamanho de um celular logo após o login: uma barra de status verde com logged in in 3692 ms, um campo de nota nova vazio com um botão Add, os chips de filtro all, open e done, a linha 0 notes showing 0 e, abaixo, uma seção Push notifications com um botão Subscribe this device to notes

Dirigimos o build web num navegador headless: seis notas adicionadas uma a uma, duas marcadas como feitas. A lista mostra cinco e Load more (1 left), porque pageSize é 5 e count voltou 6. As linhas marcadas aparecem riscadas; a barra de status diz marked done in 1820 ms, que é o PUT mais o reload da página.

A tela de notas com seis notas e uma página de cinco: Book the dentist, Water the plants, Renew passport, Grout the bathroom e Call the bank, as duas últimas marcadas e riscadas, um link Load more (1 left) e uma barra de status com marked done in 1820 ms

O chip done envia where: {"owner": …, "done": true} e recebe de volta as duas notas marcadas com count: 2, em 786 ms do navegador para a única chamada de listagem.

A tela de notas filtrada pelo chip done: 2 notes showing 2, Renew passport e Call the bank marcadas, e uma barra de status com filtered done in 786 ms

Quanto tempo levam as chamadas de um teste Flutter contra o backend real?

Cerca de 440 ms para um create, 420 ms para uma listagem paginada, 425 ms para um update e 480 ms para um delete, em medianas, de um laptop ao backend em USA East em 24 de setembro de 2026, com os hooks beforeSave e afterSave no ar. Antes dos hooks, as mesmas chamadas marcavam 410, 423, 406 e 407 ms. O cadastro que abre o teste levou 1316 ms na conexão fria.

Medido · 24 de setembro de 2026 · flutter test → backend Back4app, USA East, hooks no ar
440 mscreate, POST /classes/Note
→
420 mslist, GET com where + count
425 msupdate, PUT /classes/Note/:id

Como medimos: um Stopwatch em volta de cada Backend.call dentro de test/notes_api_test.dart, iniciado antes do http.post e parado quando o corpo decodificado era devolvido, no plano Free, com um cadastro, três creates no fluxo mais cinco rodadas de tempo de create, list, update e delete.

Rodamos quatro vezes em 24 de setembro de 2026; a tabela é a última execução, às 14:13 UTC, com os hooks no ar.

ChamadaExecuçõesMínMedianaMáx
POST /users (cadastro)11316 ms1316 ms1316 ms
POST /classes/Note (create, hook + push do afterSave)8404 ms440 ms476 ms
GET /classes/Note (list, limit 2, count=1)6400 ms420 ms422 ms
PUT /classes/Note/:id (update)6409 ms425 ms447 ms
DELETE /classes/Note/:id6409 ms480 ms1048 ms
POST /installations (registrar dispositivo)1445 ms445 ms445 ms

As três execuções antes dos hooks puseram create, list e update em 410 a 428, 423 a 425 e 406 a 447 ms. Os hooks custaram ao create cerca de 30 ms na mediana, que é o beforeSave mais um afterSave que enfileira um push; um delete na última execução levou 1048 ms, o único outlier do dia.

O mesmo código no navegador: signed up in 3684 ms na primeira requisição de uma página recém-carregada (handshake TLS, um insert em _User e uma sessão), depois 786 ms para uma listagem filtrada e 1103 ms para o registro de push.

Doze chamadas, um helper HTTP, e todas caem na mesma faixa de 400 ms. O backend não é onde vai o orçamento de latência de um app Flutter.

O último bloco do teste é a própria tabela, impressa pelo flutter test. Rode contra o seu backend e você obtém seus próprios números; o código e as asserções são os mesmos.

00:11 +6: latency: create / list / update / delete over 5 more runs
| Call | Runs | Min | Median | Max |
| POST /classes/Note (create) | 8 | 404 ms | 440 ms | 476 ms |
| GET /classes/Note (list, limit 2) | 6 | 400 ms | 420 ms | 422 ms |
| PUT /classes/Note (update) | 6 | 409 ms | 425 ms | 447 ms |
| DELETE /classes/Note | 6 | 409 ms | 480 ms | 1048 ms |
00:23 +13: All tests passed!

O que outro usuário vê quando pede a sua nota?

Um 404 com código 101, Object not found, para leitura, atualização e exclusão: a mesma resposta que um id inventado recebe. Uma listagem no escopo da sessão dele devolve count: 0. Esse é o estado com o hook no ar. Antes dele, o update respondia 200 com um updatedAt novo e não mudava absolutamente nada, e esse fantasma é o achado deste post.

O teste cadastra um segundo usuário, bob, e o aponta para a nota da ana pelo object id real. Rodamos antes e depois do deploy, no mesmo backend, no mesmo dia:

bob → nota da anaAntes do hook (13:07 UTC, 2026-09-24)Com o hook (14:13 UTC, 2026-09-24)
GET /classes/Note/:id404 / 101 Object not found.404 / 101 Object not found.
GET /classes/Note?where={owner: ana}200, count: 0200, count: 0
DELETE /classes/Note/:id404 / 101 Object not found.404 / 101 Object not found.
PUT /classes/Note/:id {"title":"hacked"}200 {"updatedAt":"…13:08:48.601Z"}, linha inalterada404 / 101 Object not found.
PUT /classes/Note/xxxxxxxxxx (id inventado)200 {"updatedAt":"…"}404 / 101 Object not found.
ana, PUT na própria nota200, linha alterada200, linha alterada

Lemos a linha com a master key depois do PUT do bob: título ainda ana’s note, updatedAt ainda a hora de criação, ACL intacta. Nada aconteceu, e o app foi informado de que algo aconteceu. A ACL fez seu trabalho como filtro; o update casou com zero linhas e o backend reportou o casamento vazio como sucesso.

Por que o hook muda a resposta: para entregar a um trigger beforeSave o objeto original, o backend busca a linha primeiro, pelas permissões de quem chamou. Para o bob a busca não encontra nada, e a requisição para com 101 antes de qualquer escrita. Sem trigger não há busca, só um update com uma query filtrada pela ACL, e um update que não toca em nada não é erro.

Antes do nosso próprio deploy, conferimos a hipótese no backend do post anterior, onde o mesmo hook está no ar desde 17 de setembro: logados como a ana e o bob de lá, o PUT do bob recebeu 404 / 101 às 13:12 UTC.

Uma hora depois, com o hook neste backend, a asserção do teste virou de espere um DateTime para espere 404 / 101 e a suíte ficou verde de novo. Se essa linha um dia falhar com um DateTime, o hook não está no ar.

Como as notificações push chegam a um app Flutter, e o que configuramos?

Em três saltos: o app obtém um token de dispositivo do FCM (Android) ou do APNs (iOS) e o guarda na classe Installation do backend; o Cloud Code, que tem a master key, manda o backend enviar; o backend entrega a mensagem e os tokens ao FCM ou ao APNs, que fazem a entrega ao dispositivo. O app nunca envia um push e nunca guarda as credenciais de push.

O que rodamos em 24 de setembro de 2026: a chamada de registro do Dart contra o backend real, as sondas de terceiros na linha que ela criou e POST /push com a master key a partir do terminal.

O que não rodamos: credenciais do FCM na dashboard, um dispositivo real, uma entrega de verdade. Essas são a configuração padrão, listadas para o editor no fim desta seção. Nenhum tempo de entrega deste post foi observado.

Registro. Um POST /installations com um installation id que seu app gera uma vez, o tipo do dispositivo, o token, os canais, um pointer para o usuário e, a partir desta execução, uma ACL:

// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/push_api.dart
Future<Map<String, dynamic>> register({
  required String installationId,
  required String deviceType,        // 'android' | 'ios' | 'web'
  String? deviceToken,
  List<String> channels = const [],
  String? userId,
}) =>
    backend.call('POST', '/installations', body: {
      'installationId': installationId,
      'deviceType': deviceType,
      if (deviceToken != null) 'deviceToken': deviceToken,
      'channels': channels,
      if (userId != null) 'user': {'__type': 'Pointer', 'className': '_User', 'objectId': userId},
      // Measured 2026-09-24: without an ACL, anyone holding the row's objectId can GET it (token included) and
      // PUT its channels with the client key. With this ACL an outsider gets 404 / 101 and a phantom 200 that changes nothing.
      if (userId != null) 'ACL': {userId: {'read': true, 'write': true}},
    });

O backend respondeu 201 {objectId: rOAjx1e8Vk, createdAt: 2026-09-24T13:12:36.603Z} em 484 ms para um token Android sintético no canal notes. PUT /installations/:id {"channels":["notes","digest"]} devolveu 200 {updatedAt: …}, e GET /installations/:id da mesma sessão devolveu a linha completa: installationId, deviceType, deviceToken, channels, user, ACL.

O botão Subscribe this device to “notes” da tela faz a mesma chamada com deviceType: 'web' e sem token, que é tudo o que um navegador oferece sem uma biblioteca de mensagens. Ele registrou em 1103 ms, e a master key confirma a linha: qYD1lFjlyX, deviceType: web, channels: ["notes"], o pointer user e a ACL só do dono.

A tela de notas depois de tocar em Subscribe this device to notes: a barra de status diz registered for push in 1103 ms, e a seção Push notifications diz Registered as Installation qYD1lFjlyX, channel notes, delivery needs FCM/APNs credentials in the dashboard, com o botão agora desabilitado

A ACL não é opcional. Nosso primeiro registro não tinha nenhuma, e as permissões padrão da classe _Installation deixam qualquer requisição ler e atualizar uma linha pelo object id. Um GET /installations/w648kNfuoh anônimo com a chave de cliente devolveu a linha inteira, token incluído; o PUT de outro usuário trocou os canais para ["hijacked"] e a master key confirmou que ficou.

Só o find foi recusado (400 / 119). Com a ACL, um GET anônimo ou de outro usuário é 404 / 101 e o PUT vira o mesmo 200 fantasma das notas: nada muda.

Envio. Enviar é uma operação de master key e mora no Cloud Code. Duas formas: um push para os outros dispositivos do usuário quando uma nota é criada, e um push para um canal que um usuário logado pode disparar:

// Stack: Node.js 22.x | Parse Server 7.x | File: cloud/main.js (continued)
Parse.Cloud.afterSave("Note", async (request) => {
  const note = request.object;
  if (!request.user || note.existed()) return;              // only on create: existed() is true for an update
  const others = new Parse.Query(Parse.Installation)
    .equalTo("user", request.user)
    .notEqualTo("installationId", request.installationId);   // the device that made the note already knows
  await Parse.Push.send(
    { where: others, data: { title: "New note", alert: note.get("title"), noteId: note.id } },
    { useMasterKey: true }
  );
});

Parse.Cloud.define("notifyChannel", async (request) => {
  if (!request.user) throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, "log in first.");
  const { channel, message } = request.params;
  if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,40}$/.test(channel ?? "")) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "bad channel name.");
  const text = String(message ?? "").slice(0, 140);
  if (!text) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "message is empty.");
  await Parse.Push.send({ channels: [channel], data: { title: `#${channel}`, alert: text } }, { useMasterKey: true });
  return { channel, sent: true };   // "sent" means accepted by the backend; delivery is FCM/APNs' job and shows in _PushStatus
});

request.installationId é o header X-Parse-Installation-Id que o app envia; é por isso que Backend tem o campo. As duas funções estão no ar; todo create da execução de teste das 14:13 UTC passou pelo afterSave, que são os cerca de 30 ms que ele somou à mediana do create.

_PushStatus agora guarda uma linha por nota criada, {"title":"New note","alert":"timing 4",…} dirigida às installations daquele usuário, succeeded, numSent: 0. O pipeline roda até o ponto em que um token de dispositivo real é necessário.

O app chamando POST /push por conta própria recebe HTTP 403 com {"error":"unauthorized: master key is required"}, medido do teste; nenhum código numérico, só o status.

O que o backend diz antes de o FCM estar configurado. Enviamos POST /push ao canal notes com a master key do terminal, duas vezes: uma sem nenhuma installation, outra com o dispositivo Android sintético inscrito. Nas duas a resposta foi 200 {"result":true}.

A linha de _PushStatus alguns segundos depois dizia status: "succeeded", numSent: 0, numFailed: null. Succeeded significa que o backend aceitou o trabalho. Nada foi entregue, e nada disse isso.

O que ainda depende da dashboard: as credenciais da conta de serviço do FCM nas configurações de push do app, firebase_messaging no app com seu token passado a register, um push de teste ao canal notes pelo console de push ou por notifyChannel, e o tempo de entrega do log de push até a notificação no dispositivo.

Quais erros o build Flutter e o backend lançaram, e o que os corrigiu?

Sete, dos testes, do navegador e do terminal, em 24 de setembro de 2026. Dois deles devolvem 200.

RangeError: max must be in range 0 < max ≤ 2^32, was 0 no console do navegador, tela cinza, antes do primeiro frame. A tela gerava um installation id com Random().nextInt(1 << 32); no target web, o dart2js usa o shift de 32 bits do JavaScript, então 1 << 32 é 0. flutter analyze e flutter test no host, onde os inteiros têm 64 bits, não dizem nada sobre isso. Correção: um limite abaixo de 2^31, nextInt(1 << 30).

400 / 135 at least one ID field (deviceToken, installationId) must be specified in this operation em POST /installations só com um deviceType. O backend precisa de algo que identifique o dispositivo antes de ele ter um token; gere um UUID uma vez por instalação e guarde.

403 {"error":"unauthorized: master key is required"} em POST /push a partir do app. Nenhum código do Parse no corpo. Enviar é trabalho do Cloud Code; o app chama uma função ou salva uma linha e deixa um trigger enviar.

400 / 119 Clients aren't allowed to perform the find operation on the installation collection. em GET /installations com a chave de cliente. Listar dispositivos é só com a master key; ler um pelo id não é, a menos que a linha tenha uma ACL. Dê uma a ela.

404 / 101 Permission denied, user needs to be authenticated. em qualquer requisição a Note sem session token, segundos depois de a classe ser criada com requiresAuthentication. Correto, e imediato numa classe nova. O mesmo código com a mensagem Object not found. é o que um usuário logado recebe para uma linha que não é dele.

200 {"updatedAt":"…"} em PUT /classes/Note/:id para uma nota que a sessão não pode escrever, e para um id que não existe, na classe antes do deploy do hook beforeSave. Não é erro, e esse é o problema. Depois do deploy a mesma chamada é 404 / 101; até um hook estar no ar numa classe, leia depois de escrever.

400 / 142 a note needs a title. em POST /classes/Note {"title":" "} com o hook no ar. A regra roda no backend para todo cliente; a tela mostra a mensagem como ela vem.

E um que não devolveu nada: um main.dart.js velho. Depois da correção do RangeError, o flutter build web teve sucesso e o navegador rodou o bundle antigo do cache do service worker, erro e tudo. Recarregue com o cache desabilitado, ou mude a versão, antes de acreditar que um build web não mudou.

Quando um app Flutter precisa de um SDK completo ou de armazenamento offline-first?

Quando o app precisa funcionar sem rede, ou quando o modelo de dados é relacional demais para linhas com um dono. Os dois casos são reais, e package:http contra uma REST API não resolve nenhum deles.

  • Offline-first. Se as notas precisam ser editáveis num avião e mescladas depois, a fonte de verdade fica no dispositivo, com um motor de sincronização que lida com conflitos; um banco local com uma camada de sync é a categoria certa, e o backend vira o alvo da sincronização em vez da origem de toda listagem. O app deste post mostra um spinner enquanto a rede está fora e nada mais.
  • Live queries e cache de objetos. Um SDK dá uma assinatura ao vivo de uma query, cache e pinning local de objetos e modelos tipados. Se a sua tela precisa atualizar no instante em que outro dispositivo escreve, uma assinatura por WebSocket é o que você quer, e o SDK faz isso em poucas linhas; por REST, você faz polling.
  • Relações pesadas. Joins muitos-para-muitos entre várias classes com regras por linha que dependem do join ficam melhor atrás de Cloud Functions que rodam com a master key e devolvem um resultado moldado; o moderatorList do lockdown-lab é esse padrão.
  • Arquivos e mídia. Enviar fotos é um endpoint separado (POST /files/:name) e, num telefone, um job em segundo plano; não coberto aqui.

Para contas, as linhas de um usuário, paginação e um registro de push, as doze chamadas deste post são o cliente inteiro, e a parte que é sua é o hook de 30 linhas.

O que você construiria em seguida no app de notas em Flutter?

  • Mantenha o hook vigiado. O update do bob no teste espera 404 / 101; num backend onde cloud/main.js não está no ar, ele falha com um DateTime, de propósito. Rode no CI contra staging e você saberá quando alguém refizer o deploy sem o hook.
  • Um token real. Adicione firebase_messaging, passe FirebaseMessaging.instance.getToken() como deviceToken com deviceType: 'android' ou 'ios' e chame register depois do login. Depois envie ao canal notes e leia numSent.
  • Sessão e installation id persistidos. Os dois vivem em memória aqui; num telefone vão para o keychain com flutter_secure_storage, e me() roda na abertura do app para que um token revogado seja pego antes da primeira tela.
  • Notas compartilhadas. Um array sharedWith na linha, uma entrada extra na ACL por usuário no hook e um where que usa $or em owner e sharedWith. A condição owner que pareceu redundante acima é onde isso começa.
  • Trave as outras classes do mesmo jeito. _Installation precisou de uma ACL; _User, por padrão, deixa um usuário logado ler os outros. O conjunto completo de travas, com a sonda que prova cada uma, está em Seu frontend fala direto com o banco de dados. Veja como proteger o backend.

Tagsflutterdartrest-apiaclpush-notificationsinstallationcloud-codeparse-server

Perguntas frequentes

Preciso de um pacote SDK do Flutter para usar um backend, ou posso chamá-lo por REST?

REST basta. Com package:http, um app Flutter envia um App ID e uma chave de cliente em toda requisição e um session token depois que o usuário entra; o backend cuida de usuários, sessões, suas classes e os registros de push. Medimos create em 440 ms, list em 420 ms e update em 425 ms (medianas de 6 a 8 execuções do flutter test, laptop para USA East) em 24 de setembro de 2026, contra um backend Back4app com o hook de dono no ar, e sem SDK no app.

Como garanto que cada usuário só vê os próprios dados num app Flutter?

Três coisas no backend, não no app: uma permissão por classe que exige session token, uma ACL por linha que nomeia o dono e um hook beforeSave que define o dono e a ACL a partir de request.user, para que um cliente não possa esquecê-los. O app também pode definir a ACL, e o nosso define, mas o hook é o que faz valer para todo cliente. Leitura, atualização e exclusão por outro usuário são recusadas com 404 / 101 depois do deploy do hook; antes dele, o update devolvia 200 e não mudava nada.

Por que um update na linha de outro usuário devolve 200 sem mudar nada?

Porque, sem um hook beforeSave na classe, o backend executa o update como uma query filtrada pela ACL: zero linhas casam, nada é escrito e a resposta é 200 com um updatedAt novo. Medimos isso em 24 de setembro de 2026: um PUT numa nota escondida e um PUT num id inventado devolveram 200. Depois do deploy do hook no mesmo dia, o mesmo PUT devolveu 404 / 101, porque o hook precisa da linha original e não a encontra.

Como funcionam as notificações push com um app Flutter e um backend gerenciado?

O app pede ao FCM (Android) ou ao APNs (iOS) um token de dispositivo e o guarda na classe Installation do backend com POST /installations, junto com o tipo do dispositivo, um installation id, os canais que assina e um pointer para o usuário. Enviar é uma operação de master key: o Cloud Code chama Parse.Push.send com useMasterKey e o backend entrega a mensagem ao FCM ou ao APNs. O app que chama POST /push recebe 403.

Por que o push diz succeeded quando nada chegou?

Porque a resposta do backend informa que ele aceitou a requisição, não que um dispositivo a recebeu. Em 24 de setembro de 2026, com zero dispositivos entregáveis e sem credenciais do FCM, POST /push devolveu {result: true} e a linha de _PushStatus dizia status succeeded, numSent 0. Configure credenciais do FCM ou do APNs, registre um dispositivo real e leia numSent, não result.

Leitura complementar

Revisado e verificado por Back4app Engineering, Editorial review na Back4app

Testado em 24 de setembro de 2026 — Backend: Back4app, plano Free, USA East · App: Flutter 3 / Dart 3 com package:http (build web, Chrome) · Testes: flutter test no macOS · Schema: bash + curl com a master key.

Mais da Back4app Engineering

Backend · tutorial

Como adicionar autenticação a um app web ou Flutter sem escrever um servidor de auth

Backend · tutorial

Seu frontend fala direto com o banco de dados. Veja como proteger o backend

Backend · tutorial

Como criar e fazer o deploy de uma API REST em Node.js em 15 minutos, sem administrar um servidor