Constaia
Integrations

Flutter

Validate documents from a Flutter app with image_picker by sending the photo to your backend, which calls Constaia; includes a Dart shelf server.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

A Flutter app never calls Constaia directly. The app uploads the photo to your backend, your backend forwards it to POST /v1/analyze with the key and returns to the app only what it needs to show. This guide covers both pieces: the Flutter screen with image_picker and a Dart shelf server that talks to Constaia.

The key does not go in the app

Don't put ck_live_… or ck_test_… in the app code, in --dart-define, in a bundled .env or in Remote Config. Anything inside an APK or IPA can be extracted. The app authenticates against your backend with your user's session; only your backend knows the Constaia key.

There is no official Dart SDK: the backend calls the REST API directly. If you prefer a generated client, the OpenAPI spec is at https://api.constaia.com/openapi.json (for example, with the dart generator of openapi-generator).

Architecture

  1. The app picks or takes a photo with image_picker.
  2. The app sends it as multipart/form-data to your endpoint (for example POST /api/verify), with your user's session token.
  3. Your backend decides expect and checks, calls Constaia and answers the app with a trimmed-down JSON.
  4. If Constaia answers 202, the result reaches your webhook and your backend notifies the app (push, polling your API…).

The contract between the app and your backend is yours. In this guide it is:

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

You can implement that endpoint with any backend: Node, Laravel, Go, Rails… Below there is one in pure Dart.

Flutter app

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

On iOS, image_picker needs NSCameraUsageDescription and NSPhotoLibraryUsageDescription in 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';

/// YOUR backend URL, not Constaia's.
const backendUrl = 'https://api.your-app.com';

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

  /// Your user's session token for your backend (not the Constaia key).
  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 ['We are reviewing the document. We will let you know when it is done.'];
        default:
          status = 'error';
          messages = [(body['error'] as Map?)?['message'] as String? ?? 'Error ${response.statusCode}'];
      }
    } catch (_) {
      status = 'error';
      messages = const ['The document could not be sent. Check your connection.'];
    }

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

  @override
  Widget build(BuildContext context) {
    final (color, title) = switch (_status) {
      'valid' => (Colors.green, 'Valid document'),
      'invalid' => (Colors.red, 'Invalid document'),
      'review' => (Colors.orange, 'We will review it manually'),
      'processing' => (Colors.blueGrey, 'Processing'),
      'error' => (Colors.red, 'Could not verify'),
      _ => (Colors.grey, ''),
    };

    return Scaffold(
      appBar: AppBar(title: const Text('Verify your ID')),
      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('Take photo'),
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: _loading ? null : () => _pickAndVerify(ImageSource.gallery),
              icon: const Icon(Icons.photo_library),
              label: const Text('Choose from gallery'),
            ),
            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),
            ],
          ],
        ),
      ),
    );
  }
}

How to handle each status in the UI:

  • Válido continue the flow.
  • No válido show the reasons (localised according to the language your backend sends) and let the user retake the photo.
  • Revisar don't block the user: say you will review it and notify them when there is a decision.

More in Verdicts.

Dart backend with shelf

If your backend is Dart too, this server receives the photo from the app, calls Constaia with http.MultipartRequest (fields file and options, the latter JSON as text) and verifies 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_...

The client uses one Idempotency-Key per operation and retries 429 responses honouring Retry-After with the same key, so there is no double charge. Keep the filename the app sends: in test mode the result depends on it.

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('CONSTAIA_API_KEY is missing'));
final webhookSecret =
    Platform.environment['CONSTAIA_WEBHOOK_SECRET'] ?? (throw StateError('CONSTAIA_WEBHOOK_SECRET is missing'));
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) or "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));

    // A synchronous analysis can take up to 30 s before returning 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},
    });

// Add your authentication here: every analysis spends credits.
Future<Response> verifyHandler(Request request) async {
  final form = request.formData();
  if (form == null) return _error(400, 'The document is missing.');

  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, 'The document is missing.');
  if (bytes.length > maxBytes) return _error(413, 'Max 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': 'en',
    });

    if (analysis['status'] != 'completed') {
      // 202: the result will arrive by 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, 'The document could not be verified. Please try again.');
  }
}

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 is stable across retries: dedupe on it in your database.
  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']}');
    // Queue heavy work, notify the app and answer within 15 s.
  }
  // Other types (batch.completed, credits.low, test…) are ignored.
  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('Listening on :${server.port}');
}

200 vs 202

A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed"; if not, 202 with status: "queued" or "processing" and the result arrives by webhook. With 'async': true in the options the API answers 202 right away, which is useful on mobile if you don't want to keep the connection open: the app shows "Processing" and your backend notifies it when analysis.completed arrives (or analysis.review_required, which is sent in addition to analysis.completed when the verdict is review).

Test mode

With a ck_test_… key on your backend no credits are spent and the result depends on the filename (it must be a real image or PDF). From the app, image_picker sets the name, so test the backend with curl first:

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
FileVerdictReason
dni_valid.jpgvalid"Valid until 12/03/2031."
dni_expired.jpginvalidnot_expired with severity error
blurry.jpgreviewlow_quality with severity warning; warnings blurry and low_quality
dni_valid.jpg with full_name=Juan Pérezinvalidholder with severity error

To test the states in the app, copy those files to the emulator gallery with the name intact, or force the filename in MultipartFile.fromBytes during development. More files in Test mode.

Errors

Constaia returns errors as { "error": { "type", "code", "message", "param", "request_id" } }. Your backend branches on type and code, logs request_id and returns an understandable message to the app. Never forward authentication or credit errors to the app: they are your server's problem. Full table in Errors.

Production checklist

  • The ck_live_… key (requires a verified email) lives only on your backend, never in the app or in --dart-define.
  • Authentication and a per-user limit on /api/verify: every analysis spends credits.
  • Downscale the photo in the app (maxWidth, imageQuality) and enforce 20 MB on the backend.
  • One Idempotency-Key per logical operation, reused on retries (Idempotency).
  • Respect the per-key limit (2 req/s on the free plan, 10 on paid) and Retry-After (Rate limits).
  • A webhook registered for 202 responses and async analyses; dedupe on webhook-id and answer fast.
  • Review storage and keep_results in Storage and privacy.

Next steps

Sur cette page