دليل ربط Udeyou OTP بلغة Flutter و Dart ======================================= الرابط الأساسي https://udeyou.com/api/v1 التوثيق https://udeyou.com/docs الصيغة JSON في الطلب والرد (Content-Type: application/json) المصادقة Authorization: Bearer أو X-API-Key: هذه النسخة العربية، والنسخة الإنجليزية هي المرجع الأساسي. الكود هو نفسه المعروض في https://udeyou.com/docs ومُختبر على الواجهة. 1) قبل البدء 1) أنشئ حسابًا: https://udeyou.com/register تبدأ الحسابات الجديدة تجربة مجانية لمدة شهرين: 2000 رسالة في الشهر الأول ومثلها في الشهر الثاني. 2) أنشئ مفتاح API من لوحة التحكم: https://udeyou.com/dashboard#keys يظهر المفتاح مرة واحدة فقط، فاحفظه في متغير البيئة UDEYOU_API_KEY على خادمك. 3) أنواع المفاتيح udy_test_... لا يرسل بريدًا ولا يخصم رصيدًا، ويعمل حتى برصيد صفر. يعود الرمز في الرد ضمن الحقل code مع "test_mode": true. للتطوير والاختبار الآلي. udy_live_... يرسل البريد فعليًا ويخصم رصيدًا واحدًا لكل رمز، ويُعاد الرصيد تلقائيًا إذا تعذّر الإرسال. كل مفتاح يرى رموز نوعه فقط. 4) أبقِ المفتاح على خادمك، ولا تضعه في JavaScript المتصفح أو تطبيق الجوال أو مستودع الكود. 2) كيف تترابط الأجزاء تنبيه أمني: لا يوضع مفتاح API داخل تطبيق الجوال إطلاقًا. تطبيق Flutter يتصل بخادمك أنت فقط، وخادمك هو الذي يستدعي Udeyou بالمفتاح. الجزء (أ) خادم Dart جاهز، والجزء (ب) هو التطبيق. يوفّر خادمك للتطبيق مسارين: POST /auth/otp/send {"email": "..."} -> {"otp_id": "...", "expires_in": 300} POST /auth/otp/verify {"otp_id": "...", "code": "..."} -> {"verified": true} عند الخطأ يعيد حالة HTTP نفسها التي أعادتها Udeyou مع error و message والحقول الإضافية (retry_after و attempts_remaining و fields)، أو 503 service_unavailable إذا كانت المشكلة في المفتاح أو الرصيد أو الخدمة (تُسجَّل على الخادم ولا تُعرض للمستخدم). 3) الجزء (أ): خادم Dart (Dart 3 مع الحزم http و shelf و shelf_router) بنية المشروع: pubspec.yaml و lib/udeyou_client.dart و bin/demo.dart و bin/server.dart. ثم شغّل: 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، الافتراضي 8081) bin/demo.dart يرسل رمزًا ويتحقق منه من سطر الأوامر، و bin/server.dart هو الخادم الذي يستدعيه تطبيقك. ----- 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) الجزء (ب): تطبيق Flutter أضف الحزمة http إلى التطبيق: flutter pub add http (http: ^1.2.0 أو أحدث) ضع الملفات الثلاثة في lib/ واستبدل https://api.example.com في lib/main.dart برابط خادمك. otp_api.dart يتصل بخادمك، و otp_screen.dart شاشة دخول جاهزة (البريد، الإرسال، الرمز، التحقق) مع رسائل واضحة لـ invalid_code و resend_too_soon و recipient_limit و rate_limited و otp_expired و 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) مرجع الواجهة POST /otp/send -> 201 Created to نص إلزامي بريد المستلم، حتى 190 حرفًا. sender_name نص اختياري اسم المرسل في صندوق الوارد، من 2 إلى 60 حرفًا. الافتراضي اسم حسابك. lang نص اختياري ar (الافتراضي) أو en: لغة الرسالة الافتراضية والتذييل. code_length رقم اختياري من 4 إلى 10 خانات. الافتراضي 6. expires_in رقم اختياري الصلاحية بالثواني من 60 إلى 3600. الافتراضي 300. code نص اختياري رمز تحدده بنفسك من 4 إلى 10 أحرف أو أرقام، وإلا تولّد Udeyou رمزًا رقميًا. template نص اختياري نص عادي حتى 1000 حرف ويجب أن يحتوي {{code}}، ويمكن استخدام {{sender_name}} و {{minutes}}. subject نص اختياري حتى 120 حرفًا ويقبل المتغيرات نفسها. reply_to نص اختياري بريد يستقبل ردود العميل. أرسل code_length و expires_in كأرقام في JSON، فالقيمة النصية "6" تُرفض بالخطأ 422. إرسال رمز جديد إلى البريد نفسه يُلغي الرموز السابقة. وتنتهي كل رسالة بسطر يوضح أنها أُرسلت نيابةً عن sender_name عبر Udeyou OTP. data: otp_id و to و status ("sent") و mode ("live" أو "test") و expires_in و expires_at (ISO 8601 بتوقيت UTC) و credits_remaining، ومع المفتاح التجريبي test_mode و code (تعامل مع الرمز كنص فقد يبدأ بصفر). POST /otp/verify -> 200 OK code نص إلزامي الرمز الذي أدخله العميل (4 إلى 10 أحرف أو أرقام). otp_id نص موصى به المعرّف المعاد من /otp/send. to نص بديل بريد المستلم بدل otp_id، ويُتحقق من آخر رمز أُرسل إليه. data: verified (true) و otp_id و to و verified_at. اعتمد data.to بريدًا موثّقًا. الرمز يُستخدم مرة واحدة، وينتهي بعد expires_in ثانية، ويُقفل بعد 5 محاولات خاطئة. GET /balance -> 200 OK data: company و mode و credits. GET /health -> 200 OK (دون مفتاح) {"success": true, "status": "ok"} النجاح: {"success": true, "data": {...}} الفشل: {"success": false, "error": {"code": "...", "message": "...", ...}} 6) أكواد الأخطاء (اعتمد على error.code، أما message فنص توضيحي بالإنجليزية) 400 invalid_json جسم الطلب ليس كائن JSON. 400 invalid_code الرمز خاطئ، و attempts_remaining = المحاولات المتبقية. 401 invalid_api_key المفتاح مفقود أو بصيغة خاطئة أو غير معروف أو ملغى. 402 insufficient_credits مفتاح حي والرصيد صفر. 403 account_suspended الحساب موقوف. 404 otp_not_found لا يوجد رمز مطابق لـ otp_id أو to ضمن نوع المفتاح. 404 not_found المسار غير موجود. 405 method_not_allowed طريقة HTTP غير مدعومة لهذا المسار. 409 otp_already_used الرمز استُخدم مسبقًا. 410 otp_expired انتهت الصلاحية، أو أُرسل رمز أحدث إلى البريد نفسه. 422 validation_error حقول غير صالحة (التفاصيل في error.fields عند /otp/send). 429 rate_limited أكثر من 60 طلب إرسال في الدقيقة (retry_after). 429 resend_too_soon أقل من 60 ثانية منذ آخر رمز لهذا البريد (retry_after). 429 recipient_limit 5 رموز لهذا البريد خلال الساعة الأخيرة (retry_after). 429 too_many_attempts استُنفدت المحاولات (5)، اطلب رمزًا جديدًا. 500 server_error خطأ غير متوقع في الخادم. 502 delivery_failed تعذّر تسليم البريد، وأُعيد الرصيد. 7) الحدود (لكل حساب، ويتشاركها المفتاح الحي والتجريبي) - طلبات الإرسال: 60 في الدقيقة (rate_limited و retry_after 60). - الفاصل بين رمزين للبريد نفسه: 60 ثانية (resend_too_soon و retry_after 60). - الرموز للبريد نفسه: 5 في الساعة (recipient_limit و retry_after 3600). - المحاولات الخاطئة: 5 لكل رمز (too_many_attempts، ثم اطلب رمزًا جديدًا). - صلاحية الرمز: 300 ثانية افتراضيًا، ومن 60 إلى 3600 عبر expires_in (otp_expired). طلبات الإرسال وحدها تدخل في حد الدقيقة. وقيمة retry_after هي طول النافذة بالثواني وليست الوقت المتبقي: انتظرها ثم أعد المحاولة. 8) أفضل الممارسات - احفظ المفتاح في متغير بيئة على الخادم فقط. - المفتاح التجريبي للتطوير والاختبار الآلي، والحي للإنتاج فقط. - احفظ otp_id في جلسة المستخدم على الخادم وتحقق به. - اعتمد data.to من رد التحقق، لا البريد المرسل من التطبيق. - تعامل مع الرمز كنص، فقد يبدأ بصفر. - اعرض attempts_remaining، وعطّل زر إعادة الإرسال 60 ثانية بعد كل إرسال. - اضبط مهلة لكل طلب، ولا تعرض تفاصيل أخطاء الشبكة للمستخدم. - ألغِ أي مفتاح يُشتبه في تسرّبه من لوحة التحكم ثم أنشئ مفتاحًا جديدًا. - لا تسجّل الرموز أو المفاتيح في السجلات. الدعم: https://udeyou.com/contact (أرفق otp_id وكود الخطأ إن وُجد)