Assinatura Digital em Apps Mobile (Flutter e React Native): Backend-for-Frontend, Link de Assinatura e Deep Link de Retorno

Você tem um app em Flutter ou React Native e precisa que o usuário assine um contrato sem sair dele. A tentação é "embutir a API no app": guardar as credenciais no bundle, chamar a SignDocs direto do celular, abrir o link em uma WebView. Cada um desses três atalhos cria um problema de segurança ou de conversão. Este guia mostra o desenho que funciona em produção: backend-for-frontend cria a sessão, o app abre o link de assinatura no navegador do sistema, um deep link de retorno traz o usuário de volta e só o webhook (ou a consulta de status) diz se a assinatura aconteceu.

O fluxo de API por baixo é o mesmo de qualquer integração: POST /v1/signing-sessions devolve um url e um clientSecret, e o link final é url?cs=clientSecret. O que muda no mobile é onde cada passo roda e como o usuário volta. Se você ainda não tem credenciais, veja como obter as credenciais da API e o guia de autenticação OAuth2.

Por que o app nunca fala diretamente com a SignDocs

O token de acesso vem de POST /oauth2/token com client_id e client_secret. Qualquer segredo colocado em um app instalável é público: um APK ou IPA é descompactado em minutos, e mesmo ofuscação ou variáveis de build acabam em texto legível na memória. Com o segredo em mãos, um terceiro cria sessões na sua conta, consome a sua cota e assina documentos em seu nome.

O padrão correto é o backend-for-frontend (BFF): o app se autentica no seu backend com o mecanismo que já usa (JWT, sessão, Firebase Auth), e é o backend que chama a SignDocs, guarda os identificadores e devolve ao app apenas o que ele precisa mostrar.

Passo Onde roda O que trafega para o app
1. Usuário toca em "Assinar" App Pedido ao seu backend com o id do contrato
2. Criar sessão de assinatura Seu backend Nada da SignDocs ainda
3. Devolver o link Seu backend Apenas signingUrl (com ?cs=) e expiresAt
4. Assinar Navegador do sistema, na página hospedada da SignDocs Nada: a página é da plataforma
5. Voltar ao app Deep link (returnUrl) session_id na query string
6. Confirmar que assinou Seu backend (webhook ou GET status) O estado do contrato, vindo do seu backend

O clientSecret da sessão, aquele que vai no ?cs=, é diferente do client_secret OAuth2: ele autentica aquele signatário naquela sessão e precisa mesmo chegar ao dispositivo do usuário. É por isso que o link só deve ser entregue ao próprio signatário, dentro do app autenticado, nunca a uma tela compartilhada.

O backend: criar a sessão com returnUrl de deep link

O endpoint do BFF é curto. Ele monta o returnUrl apontando para o seu app e usa metadata para amarrar a sessão ao registro local. Escolha um perfil de autenticação por documento: CLICK_PLUS_OTP para a maioria dos aceites, BIOMETRIC quando a dúvida é quem está assinando; a comparação está em autenticação multimétodo.

// Backend (Node/Express, mas o formato é o mesmo em qualquer stack) app.post('/contratos/:id/assinatura', requireAuth, async (req, res) => { const contrato = await Contratos.paraUsuario(req.params.id, req.user.id); const sessao = await signdocs.signingSessions.create({ purpose: 'DOCUMENT_SIGNATURE', policy: { profile: 'CLICK_PLUS_OTP' }, signer: { name: req.user.nome, email: req.user.email, cpf: req.user.cpf }, document: { content: contrato.pdfBase64, filename: `contrato-${contrato.id}.pdf` }, // Deep link de volta ao app. A SignDocs anexa ?session_id=... (ou &session_id= se já houver query) returnUrl: 'https://app.seudominio.com.br/assinatura/retorno', metadata: { contratoId: contrato.id, usuarioId: req.user.id }, }, `contrato-${contrato.id}-${req.user.id}`); // X-Idempotency-Key: toque duplo não cria 2 sessões await Contratos.marcarPendente(contrato.id, sessao.transactionId, sessao.sessionId); res.json({ signingUrl: `${sessao.url}?cs=${sessao.clientSecret}`, expiresAt: sessao.expiresAt, }); });

Sobre o returnUrl: prefira um link universal / app link em HTTPS (https://app.seudominio.com.br/...) a um esquema customizado (meuapp://). O link HTTPS abre o app quando ele está instalado e cai em uma página web de fallback quando não está, o que também cobre o caso de o usuário ter assinado em outro dispositivo. Esquemas customizados funcionam, mas exigem que o app esteja instalado e são mais fáceis de sequestrar por outro app.

O app: abrir o link no navegador do sistema

A página hospedada de assinatura é uma aplicação web completa: mostra o documento, coleta o aceite, envia o OTP, aciona a câmera para a prova de vida nos perfis biométricos e registra as evidências. O melhor lugar para ela rodar é o navegador do sistema (Custom Tab no Android, SFSafariViewController no iOS), por três motivos práticos:

  • Câmera e permissões: a prova de vida usa getUserMedia; no navegador do sistema a permissão de câmera funciona como em qualquer site. Em uma WebView você precisa interceptar o pedido de permissão em código nativo, e o comportamento varia entre versões de Android e iOS.
  • Deep link de retorno: o navegador do sistema resolve links universais e app links nativamente. Em uma WebView, a navegação para o returnUrl precisa ser interceptada manualmente.
  • Geolocalização e user-agent reais: a trilha de auditoria registra IP, geolocalização, user-agent e horário em UTC. Um navegador padrão entrega isso sem surpresas; WebViews customizadas às vezes mascaram o user-agent ou bloqueiam a geolocalização.

Flutter

// pubspec: url_launcher, app_links (ou go_router com deep links) import 'package:url_launcher/url_launcher.dart'; Future<void> abrirAssinatura(String signingUrl) async { final uri = Uri.parse(signingUrl); // externalApplication abre Custom Tab / Safari View Controller, fora da WebView do app final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!ok) throw Exception('Não foi possível abrir o navegador'); } // Recebendo o deep link de retorno (app_links) final appLinks = AppLinks(); appLinks.uriLinkStream.listen((uri) async { if (uri.path == '/assinatura/retorno') { final sessionId = uri.queryParameters['session_id']; // NÃO marque como assinado aqui. Pergunte ao SEU backend. final estado = await api.get('/contratos/estado?session_id=$sessionId'); navegarPara(estado.assinado ? TelaConcluida() : TelaAguardando()); } });

React Native

// Linking (core) ou react-native-inappbrowser-reborn para Custom Tab / SFSafariViewController import { Linking } from 'react-native'; import InAppBrowser from 'react-native-inappbrowser-reborn'; export async function abrirAssinatura(signingUrl: string) { if (await InAppBrowser.isAvailable()) { await InAppBrowser.open(signingUrl, { ephemeralWebSession: false }); } else { await Linking.openURL(signingUrl); // navegador padrão } } // Deep link de retorno Linking.addEventListener('url', async ({ url }) => { const u = new URL(url); if (u.pathname === '/assinatura/retorno') { const sessionId = u.searchParams.get('session_id'); const estado = await api.get(`/contratos/estado?session_id=${sessionId}`); navigation.replace(estado.assinado ? 'Concluido' : 'Aguardando'); } });

O redirecionamento não é prova. A página hospedada redireciona para o returnUrl ao final e anexa session_id, mas qualquer pessoa pode abrir https://app.seudominio.com.br/assinatura/retorno?session_id=xyz à mão. O app usa o deep link só para saber que deve perguntar; quem responde "assinado" é o seu backend, com base no webhook SIGNING_SESSION.COMPLETED ou em GET /v1/transactions/{id}.

E se o usuário fechar o navegador no meio?

Acontece o tempo todo: a pessoa abre o link, é interrompida, volta ao app pelo seletor de tarefas. O deep link nunca dispara, e o app fica sem saber em que ponto ela parou. Trate os três cenários:

  • Voltou sem assinar, link ainda válido: mostre a tela "Assinatura pendente" com um botão "Continuar", que reabre o mesmo signingUrl. O link vale até expiresAt (72 horas por padrão) e a sessão continua de onde parou. Guarde o link no estado seguro do app (Keychain / EncryptedSharedPreferences), não em texto plano.
  • Voltou e o link expirou: GET /v1/transactions/{id} devolve EXPIRED. O backend cria uma nova sessão e o app recebe um novo link; nada do que foi feito na sessão antiga é reaproveitado.
  • Assinou em outro dispositivo (abriu o e-mail de convite no computador): quando o app voltar ao primeiro plano, consulte o seu backend. Um onResume / AppState.change === 'active' que chama /contratos/estado resolve isso sem polling contínuo.

Se o usuário perdeu o e-mail com o convite e o fluxo depende dele, POST /v1/signing-sessions/{id}/resend-invite reenvia. Se ele desistiu, POST /v1/signing-sessions/{id}/cancel encerra a sessão e o app pode oferecer recomeçar mais tarde.

WebView: quando faz sentido e como fazer direito

Há casos legítimos para uma WebView: quiosques, apps corporativos com MDM que bloqueia o navegador, ou requisitos de marca que exigem a assinatura "dentro" da tela. Se for por esse caminho, três cuidados:

  1. Permissão de câmera: no Android, implemente onPermissionRequest no WebChromeClient e conceda RESOURCE_VIDEO_CAPTURE depois de obter a permissão nativa; no iOS, o WKWebView pede a permissão de câmera sozinho a partir do iOS 15 desde que NSCameraUsageDescription exista no Info.plist. Teste os perfis biométricos em dispositivos reais, não só no emulador.
  2. Intercepte o returnUrl: use o callback de navegação (NavigationDelegate no webview_flutter, onShouldStartLoadWithRequest no react-native-webview) para detectar o returnUrl, ler o session_id e fechar a WebView, em vez de deixar a página de retorno carregar dentro dela.
  3. Não altere o user-agent nem bloqueie geolocalização: isso empobrece a trilha de auditoria que protege o seu próprio contrato.

Mesmo na WebView, a regra do BFF não muda: credenciais OAuth2 ficam no servidor e o app só recebe o link.

Vários signatários e o contrato com testemunhas

Quando o documento precisa de mais de uma assinatura (o usuário do app e um avalista, ou contratante e contratado), o backend cria um envelope em vez de uma sessão avulsa: POST /v1/envelopes com signingMode (PARALLEL ou SEQUENTIAL), totalSigners, o documento e expiresInMinutes (de 5 minutos a 7 dias); depois POST /v1/envelopes/{id}/sessions para cada pessoa, com signerIndex a partir de 1 e a política daquele signatário. O usuário do app recebe o link dele pelo BFF, como antes; os demais recebem o convite por e-mail e assinam no navegador de onde estiverem, sem instalar nada. O webhook ENVELOPE.ALL_SIGNED avisa quando o último assinou. Detalhes de ordem e paralelismo estão em ordem de assinatura com múltiplos signatários.

Checklist antes de publicar nas lojas

  1. Nenhum client_id/client_secret no bundle; grep no build final antes de submeter.
  2. Link de assinatura entregue ao app apenas em endpoint autenticado do seu backend.
  3. Abertura em Custom Tab / SFSafariViewController; WebView só com os cuidados acima.
  4. returnUrl como link universal / app link, com página de fallback web.
  5. Deep link apenas dispara consulta ao backend; "assinado" vem do webhook ou do GET.
  6. Tela de "pendente" com reabertura do mesmo link e consulta ao voltar ao primeiro plano.
  7. Perfis biométricos testados em aparelhos reais, Android e iOS.
  8. Homologação com api-hml.signdocs.com.br em build de teste; produção só com a URL de produção.

Se a sua necessidade é apenas que a sua equipe assine e envie documentos pelo celular, sem construir nada, os aplicativos da SignDocs Brasil para Android e iOS já fazem isso, com o plano Grátis de 5 documentos por mês. A integração deste guia é para quando a assinatura precisa acontecer dentro do seu produto. O sandbox de homologação é gratuito, sem cartão, com biometria simulada e entidades que expiram em 7 dias; em produção, o acesso à API é por plano sob medida com o time comercial.

Perguntas Frequentes

Posso chamar a API da SignDocs diretamente do app Flutter ou React Native?

Não deveria. O token de acesso vem de POST /oauth2/token com client_id e client_secret, e qualquer segredo dentro de um APK ou IPA é público: o pacote é descompactado em minutos e ofuscação não resolve. Com o segredo, um terceiro cria sessões na sua conta e consome a sua cota. O desenho correto é o backend-for-frontend: o app se autentica no seu backend com o mecanismo que já usa, o backend chama a SignDocs e devolve ao app apenas o link de assinatura e a data de expiração.

Devo abrir o link de assinatura em uma WebView ou no navegador?

No navegador do sistema (Custom Tab no Android, SFSafariViewController no iOS), via url_launcher com LaunchMode.externalApplication no Flutter ou Linking / InAppBrowser no React Native. A página hospedada usa a câmera para a prova de vida nos perfis biométricos, resolve links universais de retorno e entrega user-agent e geolocalização reais para a trilha de auditoria. Tudo isso funciona sem esforço no navegador; em uma WebView exige interceptar permissões e navegação em código nativo. WebView só com esses cuidados implementados e testados em aparelhos reais.

O que é o returnUrl e o que chega nele?

É a URL para onde a página hospedada redireciona o signatário ao terminar. A SignDocs anexa session_id à query string (com ? ou &, conforme a URL já tenha parâmetros). Para apps, use um link universal / app link em HTTPS: abre o app quando instalado e cai em uma página web quando não está. O app lê o session_id e pergunta ao seu backend o estado do contrato. O redirecionamento em si não prova nada, porque qualquer pessoa pode digitar essa URL: quem confirma a assinatura é o webhook SIGNING_SESSION.COMPLETED ou GET /v1/transactions/{id}.

O usuário fechou o navegador antes de assinar. E agora?

Guarde o link de assinatura no armazenamento seguro do app e mostre uma tela de pendência com o botão de continuar, que reabre o mesmo link: ele vale até expiresAt (72 horas por padrão) e a sessão retoma de onde parou. Ao voltar ao primeiro plano, consulte o seu backend para cobrir o caso de a pessoa ter assinado em outro dispositivo. Se o link expirou, GET /v1/transactions/{id} devolve EXPIRED e o backend cria uma nova sessão. Se o e-mail de convite se perdeu, POST /v1/signing-sessions/{id}/resend-invite reenvia.

Como faço quando o contrato precisa de mais de um signatário?

O backend cria um envelope com POST /v1/envelopes (signingMode PARALLEL ou SEQUENTIAL, totalSigners, documento e expiresInMinutes entre 5 minutos e 7 dias) e adiciona cada pessoa com POST /v1/envelopes/{id}/sessions, informando signerIndex a partir de 1 e a política de autenticação dela. O usuário do app recebe o link dele pelo backend; os outros recebem o convite por e-mail e assinam no navegador, sem instalar nada. O webhook ENVELOPE.ALL_SIGNED avisa quando o último assinou. Um envelope carrega um único documento.

Preciso construir isso ou os apps da SignDocs resolvem?

Depende de quem assina e onde. Se a necessidade é a sua equipe enviar e assinar documentos pelo celular, os aplicativos da SignDocs Brasil para Android e iOS já fazem isso, com o plano Grátis de 5 documentos por mês. A integração deste guia é para quando a assinatura precisa acontecer dentro do seu próprio produto, com os seus usuários. Nesse caso, o sandbox de homologação é gratuito e sem cartão, com biometria simulada, e o acesso em produção é por plano sob medida com o time comercial.

Teste o fluxo mobile no sandbox

Credenciais de homologação gratuitas, sem cartão: crie a sessão pelo seu backend, abra o link no aparelho e receba o deep link de retorno com biometria simulada. Em produção, plano sob medida com o time comercial.

Criar credenciais de homologação Fale com o time comercial