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

URL: https://www.back4app.com/blog/pt/como-criar-o-backend-de-um-app-flutter-login-dados-e-push
Publicado: 2026-10-27 · Testado: 2026-09-24
Testado em: 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
Publisher: Back4app Engineering

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](https://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 de | O que isso exige | Quem faz aqui |
|---|---|---|
| Criar conta, entrar, continuar logado | Hash de senha, session tokens, revogação, e-mail de redefinição | Backend: `_User`, `_Session`, `POST /users`, `POST /login` |
| Dados que o usuário possui | Uma classe, um dono em toda linha, uma regra de que outros usuários não podem ler | Backend: classe `Note`, ACL, permissão por classe, hook `beforeSave` |
| Listas que escalam | Filtros, ordenação, paginação, um total | Backend: `where`, `order`, `limit`, `skip`, `count=1` em `GET /classes/Note` |
| Notificações push | Um armazém de tokens de dispositivo, um remetente que guarda as credenciais | Backend: classe `_Installation`, `Parse.Push.send` no Cloud Code, FCM/APNs |
| Regras que o app não consegue pular | Código que roda em toda escrita, seja quem for que a enviou | Backend: 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](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server); 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](/blog/blog-assets/backend-for-a-flutter-app-login-data-and-push/architecture-diagram.svg)

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:

```dart
// 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> call(String method, String path,
      {Map? body, Map? 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 ? {} : jsonDecode(r.body) as Map;
    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:

```bash
# 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:

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

/// POST /classes/Note — owner pointer + owner-only ACL set here AND by the hook.
Future 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:

```javascript
// 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.

{/* captura: cloud-code-note-hook.jpg — editor de Cloud Code com main.js aberto em Parse.Cloud.beforeSave("Note", …) e o botão Deploy */}

## 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:

```dart
// 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 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 list({int limit = 20, int skip = 0, bool? done, String? titleStartsWith}) async {
  final where = {
    '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(Note.fromJson).toList();
  return NotePage(items, r['count'] as int, skip);
}

/// PUT /classes/Note/:id — only the fields you pass change.
Future 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](/blog/blog-assets/backend-for-a-flutter-app-login-data-and-push/2-logged-in.jpg)

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](/blog/blog-assets/backend-for-a-flutter-app-login-data-and-push/3-notes-ticked.jpg)

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](/blog/blog-assets/backend-for-a-flutter-app-login-data-and-push/4-filter-done.jpg)

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

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.

| Chamada | Execuções | Mín | Mediana | Máx |
|---|---|---|---|---|
| `POST /users` (cadastro) | 1 | 1316 ms | 1316 ms | 1316 ms |
| `POST /classes/Note` (create, hook + push do afterSave) | 8 | 404 ms | 440 ms | 476 ms |
| `GET /classes/Note` (list, limit 2, count=1) | 6 | 400 ms | 420 ms | 422 ms |
| `PUT /classes/Note/:id` (update) | 6 | 409 ms | 425 ms | 447 ms |
| `DELETE /classes/Note/:id` | 6 | 409 ms | 480 ms | 1048 ms |
| `POST /installations` (registrar dispositivo) | 1 | 445 ms | 445 ms | 445 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.

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.

```text
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 ana | Antes do hook (13:07 UTC, 2026-09-24) | Com o hook (14:13 UTC, 2026-09-24) |
|---|---|---|
| `GET /classes/Note/:id` | 404 / 101 `Object not found.` | 404 / 101 `Object not found.` |
| `GET /classes/Note?where={owner: ana}` | 200, `count: 0` | 200, `count: 0` |
| `DELETE /classes/Note/:id` | 404 / 101 `Object not found.` | 404 / 101 `Object not found.` |
| `PUT /classes/Note/:id {"title":"hacked"}` | **200 `{"updatedAt":"…13:08:48.601Z"}`**, linha inalterada | **404 / 101 `Object not found.`** |
| `PUT /classes/Note/xxxxxxxxxx` (id inventado) | **200 `{"updatedAt":"…"}`** | 404 / 101 `Object not found.` |
| ana, `PUT` na própria nota | 200, linha alterada | 200, 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:

```dart
// Stack: Flutter 3.x / Dart 3.x, package:http | File: lib/push_api.dart
Future> register({
  required String installationId,
  required String deviceType,        // 'android' | 'ios' | 'web'
  String? deviceToken,
  List 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](/blog/blog-assets/backend-for-a-flutter-app-login-data-and-push/5-push-registered.jpg)

{/* captura: database-installation-row.jpg — Database → _Installation com a linha de um dispositivo real: deviceType android, deviceToken borrado, channels ["notes"], pointer user */}

**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:

```javascript
// 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.

{/* captura: push-settings-fcm.jpg — a página de configurações de push depois de salvar as credenciais do FCM, project id borrado */}
{/* captura: push-console-sent.jpg — o console de push depois de enviar ao canal notes, com as contagens Sent / Failed */}
{/* captura: device-notification.jpg — 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](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to).
