otp_flutter 0.3.1
otp_flutter: ^0.3.1 copied to clipboard
Phone and email verification for Flutter, over the native otp.com SDKs. Requires iOS 15 and Android API 26.
otp.com Flutter SDK #
Verifies a phone number or an email address with a one-time code. The channel is chosen by your account routing, so you pass the recipient and nothing else.
This package is a bridge over the native otp.com SDKs, not a re-implementation. The screen your user sees is the platform's own: UIKit-backed SwiftUI on iOS, Compose on Android.
Requires iOS 15 and Android API 26. iOS 15 is the floor for the SwiftUI the drop-in screen is
written in. Android 26 is not Flutter's own floor: the Android SDK's security floor is hardware-backed
key attestation, which is only guaranteed from 8.0, so a project that builds against plain Flutter can
still fail to build against this package until minSdk is raised.
Before you start #
You need two keys, and they are not interchangeable.
- Sign in at panel.otp.com. If you do not have an account yet, create one; it takes a minute and comes with sandbox credit.
- Open your app, then API Keys, and create two:
- a publishable key (
otp_pk_live_…) for this SDK, which goes in your app; - a server key (
otp_live_…) for your own backend, which never leaves it.
- a publishable key (
- While you are integrating, use the
otp_pk_test_…andotp_test_…pair instead. Sandbox sends no real messages and costs nothing.
The publishable key is meant to be readable: it ships inside your app, it is scoped to that one app, and it can only start and answer verifications. It cannot read a recipient and it cannot exchange a verification, which is why the server key exists and why it stays on your server.
Install #
flutter pub add otp_flutter
Both Swift Package Manager and CocoaPods work on iOS, and nothing has to be added to your app's Podfile: the plugin's podspec already carries what CocoaPods needs from a static binary dependency.
Then set the two Android SDK levels in android/app/build.gradle.kts:
android {
compileSdk = 37
defaultConfig {
minSdk = 26
}
}
Flutter's own defaults are lower on both, so the app will fail to build until they are raised. The reason for 26 is the one above: hardware-backed key attestation is only guaranteed from API 26. The reason for 37 is that the Android SDK this package wraps compiles against API 37, and Gradle refuses to build an app that compiles against less than a library it depends on.
Use it #
Configure once, at launch:
import 'package:otp_flutter/otp_flutter.dart';
await Otp.configure(publishableKey: 'otp_pk_test_…');
Then run a verification. This presents a screen, sends the code, takes the user's input, and resolves when it is done:
final verification = await Otp.verify('+14155552671');
If your flow has no phone field yet, let the SDK collect it:
final verification = await Otp.verifyCollecting(RecipientKind.phone);
Pass a locale to override the device's; the message and the screen always follow the same one, so they never disagree:
final verification = await Otp.verify('+14155552671', locale: 'tr-TR');
The one thing to get right #
verification.token is the result. Nothing else is.
Send it to your own backend, which exchanges it with your server key:
POST https://api.otp.com/api/v1/verifications/exchange
Authorization: Bearer otp_live_…
{ "verification_token": "…" }
A success read off a device you do not control proves nothing by itself, so the token is the whole security model. Your backend knows nothing until it makes that call.
Resuming #
On the WhatsApp channel the code is not sent until the user messages us, which means they leave your app and the OS may kill it while they are away. Call this when your app resumes and they come back to the screen they left:
final verification = await Otp.resumeInterrupted();
if (verification != null) {
// finish the sign-in
}
It resolves with null when there was nothing in flight, which includes a verification that expired while they were away and one they closed without answering.
Your own screen #
The drop-in screen has no view-slot API, because a screen assembled from someone else's slots is worse than one you wrote. If you want a different screen, build it on the same calls the drop-in uses:
final pending = await Otp.start('+14155552671');
pending.codeLength; // how many boxes to draw, and it is not always 6
pending.expiresAt; // count down from this
pending.resendAvailableAt; // null means it can never be resent, not "resend now"
pending.handoffUrl; // WhatsApp only: open this, the code follows
final outcome = await Otp.submit(pending.id, entered);
switch (outcome) {
case CodeMatched():
// outcome.verification.token
break;
case CodeRejected():
// a wrong code is an outcome, not an error
outcome.attemptsRemaining;
}
After a restart you can pick a verification back up without having stored anything yourself:
final inFlight = await Otp.interrupted(); // makes no request
if (inFlight != null) {
final pending = await Otp.resume(inFlight.otpId);
}
Errors #
Every call in this package throws an OtpException and nothing else. Read kind to decide what to
do, and keep message for your logs: it is written for you, not for your user, and it is not
translated.
try {
final verification = await Otp.verify(recipient);
} on OtpException catch (error) {
switch (error.kind) {
case OtpErrorKind.cancelled:
break; // the user closed the screen
case OtpErrorKind.rateLimited:
wait(error.retryAfterSeconds);
break;
case OtpErrorKind.validationFailed:
showYourOwnFieldError();
break;
default:
log(error);
}
}
kind can be a value your build has never heard of, and arrives as OtpErrorKind.unknown when it is:
the list grows server-side and an app already on a phone cannot be updated to match. The same is true
of OtpChannel and OtpStatus, for the same reason: adding a case to a Dart enum breaks an
exhaustive switch in an app already written, so a channel or status this build was never told about
arrives as unknown instead of a new case. error.type carries the API's own name for the failure,
which is what tells two failures of the same kind apart.
Device integrity #
The native SDK registers a hardware-backed key on first use, with App Attest on iOS and Keystore attestation on Android, and signs every send with it. That is what stops a publishable key lifted out of your bundle from being used outside your app.
You configure nothing for this. Two consequences worth knowing:
- Neither an iOS simulator nor an Android emulator can produce a proof. Where a proof is
required, sends from them are refused with kind
OtpErrorKind.deviceProofRejected. Test that path on hardware. - A live publishable key requires a production App Attest build on iOS. A build installed from
Xcode attests in the development environment, which only a sandbox key accepts. This is the single
most common "the SDK is broken" report and it is not a bug: whether a proof is required follows the
key, so a sandbox key (
otp_pk_test_…) never requires one and a simulator is fine while you integrate, but the last thing to test before going live is a real device with a production build and a live key.
License #
This package is Apache-2.0, and the native SDKs it bridges are not. Those ship as compiled binaries under otp.com's commercial licence, which is why the boundary is worth stating: what you may fork and redistribute freely is the glue, not the SDK underneath.
Support #
Docs and status: otp.com. Anything else: info@otp.com.