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
- La app elige o hace una foto con
image_picker. - La app la envía en
multipart/form-dataa tu endpoint (por ejemploPOST /api/verify), con el token de sesión de tu usuario. - Tu backend decide
expectychecks, llama a Constaia y responde a la app con un JSON reducido. - 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:
| Respuesta | Cuerpo |
|---|---|
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
dependencies:
flutter:
sdk: flutter
http: ^1.2.2
image_picker: ^1.1.2En iOS, image_picker necesita NSCameraUsageDescription y NSPhotoLibraryUsageDescription en
ios/Runner/Info.plist.
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
languageque 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.
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.1CONSTAIA_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.
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| Fichero | Veredicto | Motivo |
|---|---|---|
dni_valid.jpg | valid | "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired con severidad error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality con severidad warning; avisos blurry y low_quality |
dni_valid.jpg con full_name=Juan Pérez | invalid | holder 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-Keypor 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
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.
Siguientes pasos
Vapor
Valida documentos con Constaia desde Swift en el servidor con Vapor 4, subida multipart con el cliente de Vapor, Codable y webhooks HMAC con swift-crypto.
n8n
Valida documentos desde n8n con el nodo HTTP Request (multipart o JSON), enruta por veredicto y recibe webhooks de Constaia verificando la firma.