Udeyou OTP - Flutter / Dart Integration Guide ============================================= Base URL https://udeyou.com/api/v1 Docs https://udeyou.com/docs Format JSON requests and responses (Content-Type: application/json) Auth Authorization: Bearer or X-API-Key: This English guide is the reference version. The code is the same code shown on https://udeyou.com/docs, tested against the live API. 1. Before you start 1) Create an account: https://udeyou.com/register New accounts start a free 2-month trial: 2000 messages in the first month and 2000 more in the second. 2) Create an API key in the dashboard: https://udeyou.com/dashboard#keys The key is shown once. Keep it in the UDEYOU_API_KEY environment variable on your server. 3) Key types udy_test_... No email and no charge, works with a zero balance. The code comes back in the response ("code") together with "test_mode": true. Use it for development and tests. udy_live_... Sends the email and uses 1 credit per code. The credit is refunded automatically if delivery fails. Each key only sees codes sent with its own type. 4) Keep the key on your server. Never put it in browser JavaScript, a mobile app or a code repository. 2. How it fits together IMPORTANT: never put the API key inside a mobile app. The Flutter app talks to YOUR backend only, and your backend calls Udeyou with the key. Part A is a ready Dart backend; Part B is the app. Your backend exposes two routes to the app: POST /auth/otp/send {"email": "..."} -> {"otp_id": "...", "expires_in": 300} POST /auth/otp/verify {"otp_id": "...", "code": "..."} -> {"verified": true} On errors it returns the same HTTP status as Udeyou with "error", "message" and the extra fields (retry_after, attempts_remaining, fields), or 503 service_unavailable when the key, the balance or the service is the problem (logged on the server, not shown to the user). 3. Part A: Dart backend (Dart 3, packages http, shelf, shelf_router) Project layout: pubspec.yaml, lib/udeyou_client.dart, bin/demo.dart, bin/server.dart. Then: dart pub get UDEYOU_API_KEY=udy_test_... dart run bin/demo.dart user@example.com UDEYOU_API_KEY=udy_live_... dart run bin/server.dart (PORT, default 8081) bin/demo.dart sends and verifies one code from the command line. bin/server.dart is the backend your app calls. ----- BEGIN pubspec.yaml ----- name: otp_backend description: Udeyou OTP client and a sample shelf backend for Flutter apps. publish_to: none environment: sdk: ^3.0.0 dependencies: http: ^1.2.0 shelf: ^1.4.0 shelf_router: ^1.1.0 dev_dependencies: lints: ^5.0.0 ----- END pubspec.yaml ----- ----- BEGIN lib/udeyou_client.dart ----- // Udeyou OTP client for Dart servers (package:http). // Keep the API key on the server. Never ship it inside a mobile or web app. import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; class UdeyouException implements Exception { UdeyouException(this.statusCode, this.code, this.message, [this.details = const {}]); final int statusCode; final String code; final String message; /// Extra fields such as retry_after, attempts_remaining or fields. final Map details; int? get retryAfter => details['retry_after'] as int?; int? get attemptsRemaining => details['attempts_remaining'] as int?; @override String toString() => 'UdeyouException($statusCode $code: $message)'; } class UdeyouClient { UdeyouClient({ required this.apiKey, this.baseUrl = 'https://udeyou.com/api/v1', http.Client? httpClient, }) : _http = httpClient ?? http.Client(); final String apiKey; final String baseUrl; final http.Client _http; static const _timeout = Duration(seconds: 20); /// Sends a code to [to]. Returns otp_id, expires_in, expires_at, credits_remaining /// (and code when using a udy_test_ key). Future> sendOtp( String to, { String lang = 'ar', String? senderName, int? codeLength, int? expiresIn, }) { return _request('POST', '/otp/send', { 'to': to, 'lang': lang, if (senderName != null) 'sender_name': senderName, if (codeLength != null) 'code_length': codeLength, if (expiresIn != null) 'expires_in': expiresIn, }); } /// Verifies [code]. Returns verified, otp_id, to and verified_at. Future> verifyOtp({required String otpId, required String code}) { return _request('POST', '/otp/verify', {'otp_id': otpId, 'code': code}); } /// Returns company, mode and credits. Future> balance() => _request('GET', '/balance'); void close() => _http.close(); Future> _request(String method, String path, [Map? body]) async { final uri = Uri.parse('$baseUrl$path'); final headers = { 'Authorization': 'Bearer $apiKey', 'Content-Type': 'application/json', 'Accept': 'application/json', }; final http.Response res; try { res = await (method == 'GET' ? _http.get(uri, headers: headers) : _http.post(uri, headers: headers, body: jsonEncode(body))) .timeout(_timeout); } on TimeoutException { throw UdeyouException(0, 'timeout', 'No response within ${_timeout.inSeconds} s'); } Map json; try { json = jsonDecode(res.body) as Map; } on FormatException { json = const {}; } if (res.statusCode >= 400 || json['success'] != true) { final error = Map.from(json['error'] as Map? ?? const {}); throw UdeyouException( res.statusCode, error.remove('code') as String? ?? 'http_${res.statusCode}', error.remove('message') as String? ?? 'Unexpected response', error, ); } return json['data'] as Map; } } ----- END lib/udeyou_client.dart ----- ----- BEGIN bin/demo.dart ----- // Usage: UDEYOU_API_KEY=udy_test_... dart run bin/demo.dart user@example.com import 'dart:io'; import 'package:otp_backend/udeyou_client.dart'; Future main(List args) async { final env = Platform.environment; final udeyou = UdeyouClient( apiKey: env['UDEYOU_API_KEY'] ?? '', baseUrl: env['UDEYOU_BASE_URL'] ?? 'https://udeyou.com/api/v1', ); final to = args.isNotEmpty ? args.first : 'user@example.com'; try { // 1) Send the code final sent = await udeyou.sendOtp(to); print('Sent ${sent['otp_id']} to ${sent['to']} (expires in ${sent['expires_in']} s)'); // 2) Verify the code the user typed. Test keys (udy_test_) return it in the response. var code = sent['code'] as String?; if (code == null) { stdout.write('Code: '); code = stdin.readLineSync()?.trim() ?? ''; } final result = await udeyou.verifyOtp(otpId: sent['otp_id'] as String, code: code); print('Verified ${result['to']} at ${result['verified_at']}'); } on UdeyouException catch (e) { stderr.writeln('Error ${e.statusCode} ${e.code}: ${e.message}'); if (e.details.isNotEmpty) stderr.writeln(e.details); exitCode = 1; } finally { udeyou.close(); } } ----- END bin/demo.dart ----- ----- BEGIN bin/server.dart ----- // Your backend: the Flutter app calls these routes, and only this server holds the Udeyou key. // Usage: UDEYOU_API_KEY=udy_live_... dart run bin/server.dart import 'dart:convert'; import 'dart:io'; import 'package:otp_backend/udeyou_client.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as io; import 'package:shelf_router/shelf_router.dart'; final udeyou = UdeyouClient( apiKey: Platform.environment['UDEYOU_API_KEY']!, baseUrl: Platform.environment['UDEYOU_BASE_URL'] ?? 'https://udeyou.com/api/v1', ); Future main() async { final router = Router() // {email} -> {otp_id, expires_in} ..post('/auth/otp/send', (Request req) async { final body = await _readJson(req); final sent = await udeyou.sendOtp('${body['email'] ?? ''}'.trim()); return _json(200, {'otp_id': sent['otp_id'], 'expires_in': sent['expires_in']}); }) // {otp_id, code} -> {verified} ..post('/auth/otp/verify', (Request req) async { final body = await _readJson(req); final result = await udeyou.verifyOtp( otpId: '${body['otp_id'] ?? ''}', code: '${body['code'] ?? ''}', ); // result['to'] is the verified email: sign the user in or mark the address as verified here. return _json(200, {'verified': result['verified'] == true}); }); final handler = const Pipeline().addMiddleware(_errors()).addHandler(router.call); final server = await io.serve( handler, InternetAddress.anyIPv4, int.parse(Platform.environment['PORT'] ?? '8081')); print('Listening on http://${server.address.host}:${server.port}'); } Future> _readJson(Request req) async { final decoded = jsonDecode(await req.readAsString()); if (decoded is Map) return decoded; throw const FormatException('Expected a JSON object'); } Response _json(int status, Map body) => Response(status, body: jsonEncode(body), headers: {'Content-Type': 'application/json'}); Middleware _errors() => (inner) => (req) async { try { return await inner(req); } on FormatException { return _json(400, {'error': 'invalid_json', 'message': 'Send a JSON object.'}); } on UdeyouException catch (e) { // A bad key, an empty balance or an outage is our problem: log it, do not expose it. if (e.statusCode == 0 || e.statusCode >= 500 || const {401, 402, 403}.contains(e.statusCode)) { stderr.writeln(e); return _json(503, {'error': 'service_unavailable', 'message': 'Try again later.'}); } return _json(e.statusCode, { 'error': e.code, 'message': e.message, if (e.retryAfter != null) 'retry_after': e.retryAfter, if (e.attemptsRemaining != null) 'attempts_remaining': e.attemptsRemaining, if (e.details['fields'] != null) 'fields': e.details['fields'], }); } }; ----- END bin/server.dart ----- 4. Part B: Flutter app Add the http package to the app: flutter pub add http (http: ^1.2.0 or later) Put the three files in lib/ and replace https://api.example.com in lib/main.dart with your backend URL. otp_api.dart talks to your backend, otp_screen.dart is a ready sign-in screen (email, send, code, verify) with clear messages for invalid_code, resend_too_soon, recipient_limit, rate_limited, otp_expired and too_many_attempts. ----- BEGIN lib/otp_api.dart ----- // Flutter app side: talks to YOUR backend only. The Udeyou key never ships in the app. import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; class OtpApiException implements Exception { OtpApiException( this.code, this.message, { this.retryAfter, this.attemptsRemaining, }); final String code; final String message; final int? retryAfter; final int? attemptsRemaining; @override String toString() => message; } class OtpApi { OtpApi(this.baseUrl, {http.Client? client}) : _http = client ?? http.Client(); /// Your backend, for example https://api.example.com final String baseUrl; final http.Client _http; Future<({String otpId, int expiresIn})> sendCode(String email) async { final data = await _post('/auth/otp/send', {'email': email}); return ( otpId: data['otp_id'] as String, expiresIn: data['expires_in'] as int, ); } Future verifyCode(String otpId, String code) async { final data = await _post('/auth/otp/verify', { 'otp_id': otpId, 'code': code, }); return data['verified'] == true; } Future> _post( String path, Map body, ) async { final http.Response res; try { res = await _http .post( Uri.parse('$baseUrl$path'), headers: {'Content-Type': 'application/json'}, body: jsonEncode(body), ) .timeout(const Duration(seconds: 20)); } on TimeoutException { throw OtpApiException('timeout', 'The server took too long to respond.'); } final Object? json; try { json = jsonDecode(res.body); } on FormatException { throw OtpApiException( 'bad_response', 'Unexpected server response (${res.statusCode}).', ); } final data = json is Map ? json : {}; if (res.statusCode != 200) { throw OtpApiException( data['error'] as String? ?? 'http_${res.statusCode}', data['message'] as String? ?? 'Request failed (${res.statusCode}).', retryAfter: data['retry_after'] as int?, attemptsRemaining: data['attempts_remaining'] as int?, ); } return data; } } ----- END lib/otp_api.dart ----- ----- BEGIN lib/otp_screen.dart ----- import 'package:flutter/material.dart'; import 'otp_api.dart'; class OtpScreen extends StatefulWidget { const OtpScreen({super.key, required this.api, required this.onVerified}); final OtpApi api; final ValueChanged onVerified; // receives the verified email @override State createState() => _OtpScreenState(); } class _OtpScreenState extends State { final _email = TextEditingController(); final _code = TextEditingController(); String? _otpId; bool _loading = false; String? _error; @override void dispose() { _email.dispose(); _code.dispose(); super.dispose(); } Future _run(Future Function() action) async { setState(() { _loading = true; _error = null; }); try { await action(); } on OtpApiException catch (e) { _error = switch (e.code) { 'invalid_code' => 'Incorrect code. ${e.attemptsRemaining ?? 0} attempts left.', 'resend_too_soon' || 'recipient_limit' || 'rate_limited' => 'Please wait ${e.retryAfter ?? 60} seconds and try again.', 'otp_expired' || 'too_many_attempts' => 'This code is no longer valid. Request a new one.', _ => e.message, }; } catch (_) { _error = 'Could not reach the server. Check your connection.'; } finally { if (mounted) setState(() => _loading = false); } } Future _send() => _run(() async { final sent = await widget.api.sendCode(_email.text.trim()); _code.clear(); _otpId = sent.otpId; }); Future _verify() => _run(() async { if (await widget.api.verifyCode(_otpId!, _code.text.trim())) { widget.onVerified(_email.text.trim()); } }); @override Widget build(BuildContext context) { final codeSent = _otpId != null; return Scaffold( appBar: AppBar(title: const Text('Sign in')), body: ListView( padding: const EdgeInsets.all(24), children: [ TextField( controller: _email, enabled: !_loading, keyboardType: TextInputType.emailAddress, autofillHints: const [AutofillHints.email], decoration: const InputDecoration(labelText: 'Email'), ), const SizedBox(height: 12), OutlinedButton( onPressed: _loading ? null : _send, child: Text(codeSent ? 'Resend code' : 'Send code'), ), if (codeSent) ...[ const SizedBox(height: 24), TextField( controller: _code, enabled: !_loading, keyboardType: TextInputType.number, autofillHints: const [AutofillHints.oneTimeCode], maxLength: 6, textAlign: TextAlign.center, decoration: const InputDecoration( labelText: '6-digit code', counterText: '', ), onSubmitted: (_) => _verify(), ), const SizedBox(height: 12), FilledButton( onPressed: _loading ? null : _verify, child: const Text('Verify'), ), ], if (_loading) const Padding( padding: EdgeInsets.only(top: 16), child: Center(child: CircularProgressIndicator()), ), if (_error != null) Padding( padding: const EdgeInsets.only(top: 16), child: Text( _error!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), ], ), ); } } ----- END lib/otp_screen.dart ----- ----- BEGIN lib/main.dart ----- import 'package:flutter/material.dart'; import 'otp_api.dart'; import 'otp_screen.dart'; void main() => runApp(const App()); class App extends StatelessWidget { const App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: Builder( builder: (context) => OtpScreen( api: OtpApi('https://api.example.com'), onVerified: (email) => ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text('Verified $email'))), ), ), ); } } ----- END lib/main.dart ----- 5. API reference POST /otp/send -> 201 Created to string required Recipient email, up to 190 characters. sender_name string optional Name shown in the inbox, 2-60 characters. Default: your account name. lang string optional "ar" (default) or "en": language of the default message and footer. code_length integer optional 4-10 digits. Default 6. expires_in integer optional Lifetime in seconds, 60-3600. Default 300. code string optional Your own code, 4-10 letters or digits. Otherwise Udeyou generates a numeric code. template string optional Plain text up to 1000 characters. Must contain {{code}}; {{sender_name}} and {{minutes}} are also available. subject string optional Up to 120 characters, same placeholders. reply_to string optional Address that receives the customer's replies. Send code_length and expires_in as JSON numbers: "6" as a string is rejected with 422. Sending a new code to the same address cancels the previous ones. Every email ends with a line saying it was sent on behalf of sender_name through Udeyou OTP. data: otp_id, to, status ("sent"), mode ("live" or "test"), expires_in, expires_at (ISO 8601, UTC), credits_remaining, and test_mode + code with a test key (keep the code as a string: it may start with 0). POST /otp/verify -> 200 OK code string required The code the customer typed (4-10 letters or digits). otp_id string recommended The id returned by /otp/send. to string alternative Recipient email instead of otp_id; checks the latest code sent to it. data: verified (true), otp_id, to, verified_at. Use data.to as the verified address. A code works once, expires after expires_in seconds and is locked after 5 wrong attempts. GET /balance -> 200 OK data: company, mode, credits. GET /health -> 200 OK (no key needed) {"success": true, "status": "ok"} Success: {"success": true, "data": {...}} Failure: {"success": false, "error": {"code": "...", "message": "...", ...}} 6. Errors (use error.code in your logic; message is English help text) 400 invalid_json Body is not a JSON object. 400 invalid_code Wrong code. attempts_remaining = tries left. 401 invalid_api_key Key missing, malformed, unknown or revoked. 402 insufficient_credits Live key and a zero balance. 403 account_suspended The account is suspended. 404 otp_not_found No code matches otp_id or to for this key type. 404 not_found Unknown endpoint. 405 method_not_allowed HTTP method not supported for this endpoint. 409 otp_already_used The code was already used. 410 otp_expired Expired, or a newer code was sent to the same address. 422 validation_error Invalid fields (details in error.fields on /otp/send). 429 rate_limited More than 60 send requests per minute (retry_after). 429 resend_too_soon Less than 60 s since the last code to this address (retry_after). 429 recipient_limit 5 codes to this address in the last hour (retry_after). 429 too_many_attempts All 5 attempts used: request a new code. 500 server_error Unexpected server error. 502 delivery_failed The email could not be delivered; the credit was refunded. 7. Limits (per account, shared by live and test keys) - Send requests: 60 per minute (rate_limited, retry_after 60). - Gap between two codes to the same address: 60 seconds (resend_too_soon, retry_after 60). - Codes to the same address: 5 per hour (recipient_limit, retry_after 3600). - Wrong attempts: 5 per code (too_many_attempts, then request a new code). - Code lifetime: 300 seconds by default, 60-3600 with expires_in (otp_expired). Only /otp/send counts toward the per-minute limit. retry_after is the window length in seconds, not the time left: wait that long, then retry. 8. Best practices - Keep the key in an environment variable on the server only. - Use a test key in development and automated tests, a live key in production. - Store otp_id in the user session on the server and verify with it. - Trust data.to from the verify response, not an address sent by the app. - Treat the code as a string; it may start with 0. - Show attempts_remaining, and disable "resend" for 60 seconds after each send. - Set a timeout on every request and hide network errors from users. - Revoke a leaked key in the dashboard, then create a new one. - Never log codes or keys. Support: https://udeyou.com/contact (include the otp_id and the error code)