my_otp_way
Send and verify OTP codes from a Flutter app without shipping an API key
inside it. Anyone can unpack a published app and read its secrets, so this
package never talks to the MY-OTP-Way API at all —
it talks to three routes on your backend, which holds the key. What you get
here is the part every app re-implements and gets wrong: the expiry countdown,
the resend cooldown and the attempts counter, in one ChangeNotifier with a
typed error for every way the flow can fail.
Headless by design. It ships no OTP field, no theme and no opinions about what your screen looks like.
Install
dependencies:
my_otp_way: ^0.1.0
import 'package:my_otp_way/my_otp_way.dart';
How it works
┌──────────────────┐ ┌────────────────────────┐ ┌─────────────────┐
│ Your Flutter │ POST │ Your backend │ │ MY-OTP-Way API │
│ app │ ─────▶ │ myotpway/laravel-sdk │ ─────▶ │ │
│ (this package) │ │ MyOtpWay::routes() │ │ │
│ │ │ │ │ │
│ no API key │ │ holds the API key │ │ │
└──────────────────┘ └────────────────────────┘ └─────────────────┘
Your backend publishes three public routes — POST {prefix}/send,
POST {prefix}/resend, POST {prefix}/verify (prefix is my-otp by
default). With the
Laravel package that is
one line:
// routes/api.php — NOT routes/web.php.
// web.php attaches session + CSRF middleware, and a mobile binary cannot carry
// a CSRF token, so every POST from this package would come back 419.
MyOtpWay::routes();
Then point the client at your own domain:
final client = MyOtpWay(baseUrl: 'https://api.your-backend.example');
prefix and timeout are constructor arguments if you changed either on the
server: MyOtpWay(baseUrl: ..., prefix: 'auth/otp'). Call client.close()
when you are done with it.
A screen, end to end
OtpSession is a ChangeNotifier. It notifies on every state change and
once a second while a countdown is running, so the timers below tick without
any Timer of your own.
class VerifyScreen extends StatefulWidget {
const VerifyScreen({required this.phone, super.key});
final String phone;
@override
State<VerifyScreen> createState() => _VerifyScreenState();
}
class _VerifyScreenState extends State<VerifyScreen> {
final MyOtpWay _client =
MyOtpWay(baseUrl: 'https://api.your-backend.example');
late final OtpSession _session =
OtpSession(client: _client, phone: widget.phone);
@override
void initState() {
super.initState();
_session.send();
}
@override
void dispose() {
_session.dispose();
_client.close();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _session,
builder: (BuildContext context, Widget? child) => Column(
children: <Widget>[
Text('Status: ${_session.status.name}'),
Text('Expires in ${_session.expiresIn?.inSeconds ?? 0}s'),
TextField(onSubmitted: _session.verify),
TextButton(
onPressed: _session.canResend ? _session.resend : null,
child: Text(
_session.canResend
? 'Resend'
: 'Resend in ${_session.resendAvailableIn.inSeconds}s',
),
),
if (_session.attemptsRemaining case final int left)
Text('$left attempts left'),
if (_session.error case final MyOtpException e)
Text(describeOtpError(e)),
],
),
);
}
describeOtpError is below. A runnable version of this whole
screen — with a phone field, both countdowns and every getter on show — is in
example/.
What the session owns
| Getter | Type | |
|---|---|---|
status |
OtpSessionStatus |
idle, sending, awaiting, verifying, verified, failed |
requestId |
String? |
set once a send succeeds |
expiresIn |
Duration? |
counts down to zero; null before the first send |
resendAvailableIn |
Duration |
counts down to zero |
canResend |
bool |
false until a code has been sent and the cooldown has elapsed |
attemptsRemaining |
int? |
after a wrong code, when the server said |
error |
MyOtpException? |
the last failure; cleared by the next successful call |
And three methods: send(), resend(), verify(code). All three return
Future<void> and never throw — a failure lands in error, which is the
point of a state machine. Call dispose() when the screen goes away.
Three refusals never reach the network at all, because the answer is already known locally:
resend()during the cooldown setserrortoResendTooSoonand stays inawaiting. This is what stops a user draining your balance by holding the button down.send()after a successful send honours the same cooldown, so two taps on "Send" cost one message, not two.verify()before any send setserrortoInvalidRequest;resend()before any send sets it toNothingToResend.
A wrong code returns the session to awaiting so the user can retype it.
Everything else — a locked code, an expired one, an outage — ends in failed.
MyOtpBuilder
If you do not want to own a State yourself, MyOtpBuilder creates the
session, sends on mount, disposes on unmount and rebuilds your UI:
MyOtpBuilder(
// client and phone are read once, in initState. Key the widget on the
// number the user has SUBMITTED, so correcting a mistyped one builds a
// fresh session instead of silently verifying the old one.
key: ValueKey<String>(submittedPhone),
client: client,
phone: submittedPhone,
onVerified: () => Navigator.of(context).pushReplacementNamed('/home'),
builder: (BuildContext context, OtpSession session) => Column(
children: <Widget>[
Text('Status: ${session.status.name}'),
TextField(onSubmitted: session.verify),
TextButton(
onPressed: session.canResend ? session.resend : null,
child: Text(
session.canResend
? 'Resend'
: 'Resend in ${session.resendAvailableIn.inSeconds}s',
),
),
],
),
)
Never key on the live text of a phone field. sendOnInit sends from a
brand-new session, and a brand-new session has no requestId, so no cooldown
applies to it: every keystroke would mint a session and buy a message. Key on
a value that only changes when the user commits — the field's submitted value,
not its content.
onVerified fires exactly once. sendOnInit: false skips the automatic send.
Without the state machine
MyOtpWay is usable on its own. Here every call either returns or throws:
Future<void> rawFlow() async {
final MyOtpWay client =
MyOtpWay(baseUrl: 'https://api.your-backend.example');
try {
final SendResult sent = await client.send('+9647701234567');
await client.verify(requestId: sent.requestId, code: '123456');
// The number is confirmed: verify() returns normally, or throws.
} on WrongCode catch (e) {
debugPrint('wrong code, ${e.attemptsRemaining} attempts left');
} on MyOtpException catch (e) {
debugPrint(describeOtpError(e));
} finally {
client.close();
}
}
send() and resend() return a SendResult (requestId, expiresAt,
resendAvailableIn). verify() returns void: success is the absence of a
throw.
Errors
MyOtpException is sealed, so a switch over it needs no default arm
and the compiler will point at your code if a variant is ever added:
String describeOtpError(MyOtpException error) => switch (error) {
Forbidden() => 'Your backend refused this request.',
InvalidPhone() => 'That phone number does not look right.',
CountryNotAllowed() => 'That country is not enabled on this backend.',
NothingToResend() => 'Send a code before asking for another one.',
// availableIn is nullable: null means the server did not say how long,
// which is not the same as "resend now".
ResendTooSoon(:final Duration? availableIn) => availableIn == null
? 'Too soon to resend. Try again shortly.'
: 'Too soon to resend. Try again in ${availableIn.inSeconds}s.',
InvalidRequest() => 'The request was malformed.',
RateLimited(:final Duration? retryAfter) => retryAfter == null
? 'Too many requests. Try again later.'
: 'Too many requests. Try again in ${retryAfter.inSeconds}s.',
ServiceUnavailable() => 'The service is unavailable. Try again later.',
WrongCode(:final int? attemptsRemaining) => attemptsRemaining == null
? 'That code is wrong.'
: 'That code is wrong — $attemptsRemaining attempts left.',
CodeExpired() => 'That code expired. Ask for a new one.',
AlreadyUsed() => 'That code was already used.',
NotFound() => 'The server no longer knows about that request.',
TooManyAttempts() => 'Too many wrong codes. Start over.',
NetworkFailure() => 'Could not reach your backend.',
};
One variant per code in the proxy's error contract, plus one for a request that never left the device:
| Variant | Proxy error |
Extra property | When |
|---|---|---|---|
Forbidden |
forbidden |
— | your authorizeUsing() callback rejected the request |
InvalidPhone |
invalid_phone |
— | phone malformed, or the API rejected the recipient |
CountryNotAllowed |
country_not_allowed |
— | the number is outside your backend's allow-list |
NothingToResend |
nothing_to_resend |
— | /resend with no prior /send for that number |
ResendTooSoon |
resend_too_soon |
availableIn (Duration?) |
still inside the per-phone cooldown |
InvalidRequest |
invalid_request |
— | request_id or code missing or malformed |
RateLimited |
rate_limited |
retryAfter (Duration?) |
the per-IP throttle, or the API throttling your backend |
ServiceUnavailable |
service_unavailable |
— | anything the backend refuses to explain — and the fallback, see below |
WrongCode |
invalid_code |
attemptsRemaining (int?) |
wrong code, more attempts left |
CodeExpired |
expired |
— | the code expired before it was submitted |
AlreadyUsed |
already_used |
— | the code was already verified once |
NotFound |
not_found |
— | unknown request_id |
TooManyAttempts |
too_many_attempts |
— | attempt limit reached — a lockout, not a one-off wrong code |
NetworkFailure |
— | — | the request never reached your backend: DNS, TLS, a timeout |
Authenticating to your own proxy
Forbidden is the one row above you have to design for rather than react to.
The Laravel package's authorizeUsing() decides who may call /my-otp/send,
and its default allows everyone. That default is deliberate, not an
oversight: for most apps the OTP is the login, so there is no session yet to
authenticate with. The endpoint is defended in depth instead — a per-IP
throttle on each route, a per-phone cooldown, and a country allow-list — and
none of that is auth.
So know what you are running. Anyone who reads your app's traffic once can call
/my-otp/send from curl, and every accepted call spends real money in a
currency you are billed in. The layers above bound how fast, not whether. If
your app does have something to authenticate with by the time it sends a code
— an existing session, a device attestation, a signed URL — use it.
This package's constructor takes no headers argument, deliberately: it is a
transport, not an auth framework. Send whatever your backend already
authenticates with — a bearer token, a signed URL, a device attestation
header — through httpClient, using package:http's BaseClient. Add
import 'package:http/http.dart' as http; and wrap the client you would
otherwise have used:
class AuthedClient extends http.BaseClient {
AuthedClient(this._inner, this._token);
final http.Client _inner;
final String _token;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers['authorization'] = 'Bearer $_token';
return _inner.send(request);
}
}
final MyOtpWay client = MyOtpWay(
baseUrl: 'https://api.your-backend.example',
httpClient: AuthedClient(http.Client(), sessionToken),
);
Every request this package makes then carries the header, and your
authorizeUsing() callback has something to check. Note that close() on a
client you passed in is yours to call — this one does not own it.
Two nullables worth reading twice. ResendTooSoon.availableIn and
RateLimited.retryAfter are Duration?, and null means the server did
not say — not zero. Rendering a null as 0s invites an immediate retry
straight into another refusal, so branch on it rather than defaulting it.
Likewise WrongCode.attemptsRemaining is int?: absent is not "no attempts
left".
Every variant also carries message (String?) — the server's own words,
when it sent any. The variant is what you branch on and what you write copy
against; this package deliberately localises nothing.
message is untrusted upstream text. Do not ship it anywhere. The proxy
never sends a message key, so in a correctly configured deployment this
field is always null — anything in it arrived from somewhere that is not
following the contract, most often a backend left on APP_DEBUG=true, and it
can therefore contain a stack trace, a SQL statement or a balance figure. On
mobile, "for your logs" means Crashlytics or Sentry: off-device, third-party,
retained. Show it to nobody, and redact it before it reaches a crash reporter.
ServiceUnavailable is the fallback for anything unrecognised: a body whose
error key this package does not know, a body with no error key at all
(a routing 404, a cache outage, an HTML error page from a proxy sitting in
front of your backend), or a 202 from /send that carries no request_id.
Guessing something more specific there would mean telling a user to retype a
code that was in fact correct.
The backend half
The other side of these three routes is
myotpway/laravel-sdk.
Its README documents authorizeUsing(), the country allow-list, the per-phone
resend cooldown, the throttle limits and the error sanitisation that produces
the table above. Two of its notes change how this package behaves:
- The cooldown lives in your app's default cache store. On the
arraydriver it evaporates between requests; onfilebehind more than one node it is not shared. Either wayresend_available_instops meaning anything. Point the default cache at Redis in production. - The country allow-list defaults to
['+964']. A number outside it comes back asCountryNotAllowedbefore any request reaches the API.
SMS autofill is not included
Deliberately. Reading the code out of an incoming SMS needs platform channels, an app signature hash on Android and an entitlement dance on iOS — all of it about the device, none of it about this protocol, and all of it at odds with this package's zero-plugin, pure-Dart footprint.
It composes cleanly instead: use pinput
for the code field and smart_auth to
retrieve the code, then hand the result to session.verify(code). Nothing
here needs to know where the string came from.
Testing your app
MyOtpWay takes an httpClient, so
package:http's MockClient is enough to
drive a whole flow with no network:
final client = MyOtpWay(
baseUrl: 'https://x',
httpClient: MockClient((_) async => http.Response(
jsonEncode({
'request_id': 'req-1',
'expires_at':
DateTime.now().toUtc().add(const Duration(minutes: 5)).toIso8601String(),
'resend_available_in': 60,
}),
202,
)),
);
OtpSession reads time through
package:clock's ambient clock.now(), so
wrapping a test in fakeAsync lets async.elapse(...) drive both countdowns
without waiting in real time. Outside such a zone clock.now() is
DateTime.now(), so production behaviour is unchanged.
License
MIT.
Libraries
- my_otp_way
- A headless client for the MY-OTP-Way proxy routes.