Constaia
Integraciones

Flutter

Valida documentos desde una app Flutter con image_picker enviando la foto a tu backend, que llama a Constaia; incluye un servidor Dart con shelf.

Una app Flutter nunca llama a Constaia directamente. La app sube la foto a tu backend, tu backend la reenvía a POST /v1/analyze con la clave y devuelve a la app solo lo que necesita mostrar. Esta guía cubre las dos piezas: la pantalla Flutter con image_picker y un servidor Dart con shelf que habla con Constaia.

La clave no va en la app

No incluyas ck_live_… ni ck_test_… en el código de la app, en --dart-define, en un .env empaquetado ni en Remote Config. Todo lo que va dentro de un APK o un IPA se puede extraer. La app se autentica contra tu backend con la sesión de tu usuario; solo tu backend conoce la clave de Constaia.

No hay SDK oficial para Dart: el backend llama a la API REST directamente. Si prefieres un cliente generado, la especificación OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con el generador dart de openapi-generator).

Arquitectura

  1. La app elige o hace una foto con image_picker.
  2. La app la envía en multipart/form-data a tu endpoint (por ejemplo POST /api/verify), con el token de sesión de tu usuario.
  3. Tu backend decide expect y checks, llama a Constaia y responde a la app con un JSON reducido.
  4. Si Constaia responde 202, el resultado llega a tu webhook y tu backend avisa a la app (push, polling a tu API…).

El contrato entre la app y tu backend es tuyo. En esta guía es:

RespuestaCuerpo
200{ "id", "status": "valid" | "invalid" | "review", "reasons": [mensajes], "warnings": [...] }
202{ "id", "status": "queued" | "processing" }
error{ "error": { "message" } }

Puedes implementar ese endpoint con cualquier backend: Node, Laravel, Go, Rails… Más abajo tienes uno en Dart puro.

App Flutter

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.2
  image_picker: ^1.1.2

En iOS, image_picker necesita NSCameraUsageDescription y NSPhotoLibraryUsageDescription en ios/Runner/Info.plist.

lib/verify_id_screen.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';

/// URL de TU backend, no de Constaia.
const backendUrl = 'https://api.tu-app.com';

class VerifyIdScreen extends StatefulWidget {
  const VerifyIdScreen({super.key, required this.sessionToken, this.fullName});

  /// Token de sesión de tu usuario en tu backend (no la clave de Constaia).
  final String sessionToken;
  final String? fullName;

  @override
  State<VerifyIdScreen> createState() => _VerifyIdScreenState();
}

class _VerifyIdScreenState extends State<VerifyIdScreen> {
  final _picker = ImagePicker();
  bool _loading = false;
  String? _status;
  List<String> _messages = const [];

  Future<void> _pickAndVerify(ImageSource source) async {
    final photo = await _picker.pickImage(source: source, maxWidth: 2400, imageQuality: 90);
    if (photo == null) return;

    setState(() {
      _loading = true;
      _status = null;
      _messages = const [];
    });

    String status;
    List<String> messages;
    try {
      final request = http.MultipartRequest('POST', Uri.parse('$backendUrl/api/verify'))
        ..headers['Authorization'] = 'Bearer ${widget.sessionToken}'
        ..files.add(http.MultipartFile.fromBytes('file', await photo.readAsBytes(), filename: photo.name));
      if (widget.fullName != null) request.fields['full_name'] = widget.fullName!;

      final response = await http.Response.fromStream(
        await request.send().timeout(const Duration(seconds: 90)),
      );
      final body = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;

      switch (response.statusCode) {
        case 200:
          status = body['status'] as String? ?? 'error';
          messages = List<String>.from(body['reasons'] as List? ?? const []);
        case 202:
          status = 'processing';
          messages = const ['Estamos revisando el documento. Te avisaremos al terminar.'];
        default:
          status = 'error';
          messages = [(body['error'] as Map?)?['message'] as String? ?? 'Error ${response.statusCode}'];
      }
    } catch (_) {
      status = 'error';
      messages = const ['No se pudo enviar el documento. Revisa tu conexión.'];
    }

    if (!mounted) return;
    setState(() {
      _loading = false;
      _status = status;
      _messages = messages;
    });
  }

  @override
  Widget build(BuildContext context) {
    final (color, title) = switch (_status) {
      'valid' => (Colors.green, 'Documento válido'),
      'invalid' => (Colors.red, 'Documento no válido'),
      'review' => (Colors.orange, 'Lo revisaremos manualmente'),
      'processing' => (Colors.blueGrey, 'En proceso'),
      'error' => (Colors.red, 'No se pudo verificar'),
      _ => (Colors.grey, ''),
    };

    return Scaffold(
      appBar: AppBar(title: const Text('Verifica tu DNI')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            FilledButton.icon(
              onPressed: _loading ? null : () => _pickAndVerify(ImageSource.camera),
              icon: const Icon(Icons.photo_camera),
              label: const Text('Hacer foto'),
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: _loading ? null : () => _pickAndVerify(ImageSource.gallery),
              icon: const Icon(Icons.photo_library),
              label: const Text('Elegir de la galería'),
            ),
            const SizedBox(height: 24),
            if (_loading) const Center(child: CircularProgressIndicator()),
            if (!_loading && _status != null) ...[
              Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(color: color)),
              const SizedBox(height: 8),
              for (final message in _messages) Text(message),
            ],
          ],
        ),
      ),
    );
  }
}

Cómo tratar cada estado en la interfaz:

  • Válido continúa el flujo.
  • No válido muestra los motivos (vienen traducidos según el language que envía tu backend) y deja repetir la foto.
  • Revisar no bloquees al usuario: dile que lo revisaréis y avísale cuando haya decisión.

Más en Veredictos.

Backend en Dart con shelf

Si tu backend también es Dart, este servidor recibe la foto de la app, llama a Constaia con http.MultipartRequest (campos file y options, este último JSON en texto) y verifica los webhooks.

server/pubspec.yaml
name: constaia_server
environment:
  sdk: ^3.3.0

dependencies:
  crypto: ^3.0.6
  http: ^1.2.2
  shelf: ^1.4.2
  shelf_multipart: ^2.0.1
  shelf_router: ^1.1.4
  uuid: ^4.5.1
.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

El cliente usa una Idempotency-Key por operación y reintenta los 429 respetando Retry-After con la misma clave, así que no hay doble cobro. Conserva el nombre del fichero que envía la app: en modo test el resultado depende de él.

server/bin/server.dart
import 'dart:convert';
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_multipart/shelf_multipart.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:uuid/uuid.dart';

const maxBytes = 20 * 1024 * 1024;
const maxRetries = 2;

final apiKey = Platform.environment['CONSTAIA_API_KEY'] ?? (throw StateError('Falta CONSTAIA_API_KEY'));
final webhookSecret =
    Platform.environment['CONSTAIA_WEBHOOK_SECRET'] ?? (throw StateError('Falta CONSTAIA_WEBHOOK_SECRET'));
final _client = http.Client();

class ConstaiaException implements Exception {
  ConstaiaException(this.status, this.type, this.code, this.message, this.requestId);

  final int status;
  final String? type;
  final String? code;
  final String message;
  final String? requestId;
}

/// status "completed" (HTTP 200) o "queued"/"processing" (HTTP 202).
Future<Map<String, dynamic>> analyze(List<int> bytes, String filename, Map<String, dynamic> options) async {
  final idempotencyKey = const Uuid().v4();

  for (var attempt = 0;; attempt++) {
    final request = http.MultipartRequest('POST', Uri.parse('https://api.constaia.com/v1/analyze'))
      ..headers['Authorization'] = 'Bearer $apiKey'
      ..headers['Idempotency-Key'] = idempotencyKey
      ..fields['options'] = jsonEncode(options)
      ..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));

    // El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
    final response = await http.Response.fromStream(
      await _client.send(request).timeout(const Duration(seconds: 60)),
    );
    final body = _decode(response);
    if (response.statusCode >= 200 && response.statusCode < 300) return body;

    if (response.statusCode == 429 && attempt < maxRetries) {
      final wait = int.tryParse(response.headers['retry-after'] ?? '') ?? 1;
      await Future<void>.delayed(Duration(seconds: wait));
      continue;
    }

    final error = body['error'] as Map<String, dynamic>? ?? const {};
    throw ConstaiaException(
      response.statusCode,
      error['type'] as String?,
      error['code'] as String?,
      error['message'] as String? ?? 'HTTP ${response.statusCode}',
      error['request_id'] as String? ?? response.headers['x-request-id'],
    );
  }
}

Map<String, dynamic> _decode(http.Response response) {
  try {
    final value = jsonDecode(utf8.decode(response.bodyBytes));
    return value is Map<String, dynamic> ? value : {};
  } on FormatException {
    return {};
  }
}

Response _json(int status, Object body) =>
    Response(status, body: jsonEncode(body), headers: {'content-type': 'application/json'});

Response _error(int status, String message) => _json(status, {
      'error': {'message': message},
    });

// Añade aquí tu autenticación: cada análisis consume créditos.
Future<Response> verifyHandler(Request request) async {
  final form = request.formData();
  if (form == null) return _error(400, 'Falta el documento.');

  List<int>? bytes;
  var filename = 'document';
  String? fullName;
  await for (final data in form.formData) {
    if (data.name == 'file') {
      filename = data.filename ?? filename;
      bytes = await data.part.readBytes();
    } else if (data.name == 'full_name') {
      fullName = await data.part.readString();
    }
  }
  if (bytes == null || bytes.isEmpty) return _error(400, 'Falta el documento.');
  if (bytes.length > maxBytes) return _error(413, 'Máximo 20 MB.');

  final checks = <String, dynamic>{'not_expired': true};
  if (fullName != null && fullName.trim().isNotEmpty) checks['holder'] = {'full_name': fullName};

  try {
    final analysis = await analyze(bytes, filename, {
      'expect': 'es_dni',
      'checks': checks,
      'storage': 'none',
      'language': 'es',
    });

    if (analysis['status'] != 'completed') {
      // 202: el resultado llegará por webhook.
      return _json(202, {'id': analysis['id'], 'status': analysis['status']});
    }

    final verdict = analysis['verdict'] as Map<String, dynamic>?;
    return _json(200, {
      'id': analysis['id'],
      'status': verdict?['status'],
      'reasons': [for (final r in (verdict?['reasons'] as List? ?? const [])) (r as Map)['message']],
      'warnings': analysis['warnings'] ?? const [],
    });
  } on ConstaiaException catch (e) {
    stderr.writeln('Constaia ${e.status} ${e.code} request_id=${e.requestId}');
    return e.type == 'invalid_request'
        ? _error(422, e.message)
        : _error(502, 'No se pudo verificar el documento. Inténtalo de nuevo.');
  }
}

bool verifyWebhook(String secret, Map<String, String> headers, String body) {
  final id = headers['webhook-id'];
  final timestamp = headers['webhook-timestamp'];
  final signatures = headers['webhook-signature'];
  if (id == null || timestamp == null || signatures == null) return false;

  final ts = int.tryParse(timestamp);
  final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
  if (ts == null || (now - ts).abs() > 300) return false;

  final key = base64Decode(secret.startsWith('whsec_') ? secret.substring(6) : secret);
  final expected = Hmac(sha256, key).convert(utf8.encode('$id.$timestamp.$body')).bytes;

  for (final part in signatures.split(' ')) {
    final comma = part.indexOf(',');
    if (comma < 0 || part.substring(0, comma) != 'v1') continue;
    try {
      if (_constantTimeEquals(base64Decode(part.substring(comma + 1)), expected)) return true;
    } on FormatException {
      continue;
    }
  }
  return false;
}

bool _constantTimeEquals(List<int> a, List<int> b) {
  if (a.length != b.length) return false;
  var diff = 0;
  for (var i = 0; i < a.length; i++) {
    diff |= a[i] ^ b[i];
  }
  return diff == 0;
}

Future<Response> webhookHandler(Request request) async {
  final raw = await request.readAsString(utf8);
  if (!verifyWebhook(webhookSecret, request.headers, raw)) {
    return Response(400, body: 'invalid signature');
  }

  final event = jsonDecode(raw) as Map<String, dynamic>;
  // webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
  final msgId = request.headers['webhook-id'];
  final type = event['type'];
  if (type == 'analysis.completed' || type == 'analysis.review_required' || type == 'analysis.failed') {
    final analysis = event['data'] as Map<String, dynamic>;
    print('$msgId $type ${analysis['id']} ${(analysis['verdict'] as Map?)?['status']}');
    // Encola el trabajo pesado, avisa a la app y responde en menos de 15 s.
  }
  // Otros tipos (batch.completed, credits.low, test…) se ignoran.
  return Response(204);
}

Future<void> main() async {
  final router = Router()
    ..post('/api/verify', verifyHandler)
    ..post('/webhooks/constaia', webhookHandler);

  final handler = const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
  print('Escuchando en :${server.port}');
}

200 frente a 202

El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed"; si no, 202 con status: "queued" o "processing" y el resultado llega por webhook. Con 'async': true en las opciones la API responde 202 al momento, algo útil en móvil si no quieres mantener la conexión abierta: la app muestra "En proceso" y tu backend la avisa cuando llegue analysis.completed (o analysis.review_required, que llega además de analysis.completed cuando el veredicto es review).

Probar en modo test

Con una clave ck_test_… en tu backend no se gastan créditos y el resultado depende del nombre del fichero (tiene que ser una imagen o un PDF real). Desde la app el nombre lo pone image_picker, así que prueba primero el backend con curl:

cd server && export $(cat ../.env | xargs) && dart run bin/server.dart
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:8080/api/verify
curl -F "file=@blurry.jpg" http://localhost:8080/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:8080/api/verify
FicheroVeredictoMotivo
dni_valid.jpgvalid"Vigente hasta el 12/03/2031."
dni_expired.jpginvalidnot_expired con severidad error: "Caducado el 15/06/2020."
blurry.jpgreviewlow_quality con severidad warning; avisos blurry y low_quality
dni_valid.jpg con full_name=Juan Pérezinvalidholder con severidad error

Para probar los estados en la app, copia esos ficheros a la galería del emulador con el nombre intacto, o fuerza el filename en MultipartFile.fromBytes durante el desarrollo. Más ficheros en Modo test.

Errores

Constaia responde los errores con { "error": { "type", "code", "message", "param", "request_id" } }. Tu backend decide por type y code, registra request_id y devuelve a la app un mensaje entendible. Nunca reenvíes a la app errores de autenticación ni de créditos: son problemas de tu servidor. Tabla completa en Errores.

Checklist de producción

  • La clave ck_live_… (requiere email verificado) vive solo en tu backend, nunca en la app ni en --dart-define.
  • Autenticación y límite por usuario en /api/verify: cada análisis consume créditos.
  • Reduce la foto en la app (maxWidth, imageQuality) y limita a 20 MB en el backend.
  • Una Idempotency-Key por operación lógica, reutilizada en los reintentos (Idempotencia).
  • Respeta el límite por clave (2 req/s en el plan gratuito, 10 en el de pago) y Retry-After (Límites).
  • Webhook registrado para los 202 y los análisis async; deduplica por webhook-id y responde rápido.
  • Revisa storage y keep_results en Almacenamiento y privacidad.

Siguientes pasos

En esta página