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.
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
- The app picks or takes a photo with
image_picker. - The app sends it as
multipart/form-datato your endpoint (for examplePOST /api/verify), with your user's session token. - Your backend decides
expectandchecks, calls Constaia and answers the app with a trimmed-down JSON. - 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:
| Response | Body |
|---|---|
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
dependencies:
flutter:
sdk: flutter
http: ^1.2.2
image_picker: ^1.1.2On iOS, image_picker needs NSCameraUsageDescription and NSPhotoLibraryUsageDescription in
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';
/// 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
languageyour 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.
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_...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.
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| File | Verdict | Reason |
|---|---|---|
dni_valid.jpg | valid | "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error |
blurry.jpg | review | low_quality with severity warning; warnings blurry and low_quality |
dni_valid.jpg with full_name=Juan Pérez | invalid | holder 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-Keyper 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
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.
Next steps
Vapor
Validate documents with Constaia from server-side Swift with Vapor 4, multipart upload with the Vapor client, Codable and HMAC webhooks with swift-crypto.
n8n
Validate documents from n8n with the HTTP Request node (multipart or JSON), route by verdict and receive Constaia webhooks with signature checks.