One-time-passcode (OTP) sign-in is the most common non-wallet login: you send the user a short code by email or SMS, they type it back, and they’re signed in. This page shows how to build that UI end to end for both channels.
Prerequisites
Before this: create and initialize a Dynamic client (see Creating a Dynamic Client, Initializing the Dynamic Client).
Enable Email and/or SMS as sign-in methods in your Dynamic dashboard.
How OTP sign-in works
The flow is always two steps, which map directly onto two UI states:
- Request a code — collect the user’s email or phone number and call
sendEmailOTP / sendSmsOTP. This returns an OTPVerification object (it holds a verificationUUID that ties the code to this attempt). Show the code-entry screen.
- Verify the code — collect the code the user received and call
verifyOTP with the OTPVerification from step 1. On success the user is authenticated and the SDK stores the session.
verifyOTP handles both sign-in and adding a verified email/phone to an already–signed-in user. It picks the right path from the current session — you call the same function either way.
Email OTP
Drive two screens from the OTPVerification returned by sendEmailOTP: an email form, then a code-entry form. Here they’re wired to plain DOM elements.import { sendEmailOTP, verifyOTP } from '@dynamic-labs-sdk/client';
import type { OTPVerification } from '@dynamic-labs-sdk/client';
// Two forms in your markup: one to collect the email, one to enter the code.
const emailForm = document.querySelector<HTMLFormElement>('#email-form')!;
const codeForm = document.querySelector<HTMLFormElement>('#code-form')!;
let verification: OTPVerification;
// Screen 1: send the code, then reveal the code-entry screen.
emailForm.addEventListener('submit', async (event) => {
event.preventDefault();
const email = new FormData(emailForm).get('email') as string;
verification = await sendEmailOTP({ email });
emailForm.hidden = true;
codeForm.hidden = false;
});
// Screen 2: verify the code the user typed to sign them in.
codeForm.addEventListener('submit', async (event) => {
event.preventDefault();
const verificationToken = new FormData(codeForm).get('code') as string;
await verifyOTP({ otpVerification: verification, verificationToken });
// The user is now authenticated — render your app.
});
useSendEmailOTP returns the OTPVerification object as its data. Use its presence to switch from the email screen to the code-entry screen, and pass it to useVerifyOTP to finish.import { useSendEmailOTP, useVerifyOTP, useUser } from '@dynamic-labs-sdk/react-hooks';
import { useState } from 'react';
function EmailSignIn() {
const { data: user } = useUser();
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const {
mutate: sendCode,
data: otpVerification,
reset,
isPending: isSending,
} = useSendEmailOTP();
const { mutate: verifyCode, isPending: isVerifying, error } = useVerifyOTP();
if (user) return <p>Signed in as {user.email}</p>;
// Code-entry screen — shown once a code has been sent
if (otpVerification) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
verifyCode({ otpVerification, verificationToken: code });
}}
>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="Enter the 6-digit code"
/>
<button type="submit" disabled={isVerifying}>
{isVerifying ? 'Verifying…' : 'Verify'}
</button>
<button type="button" onClick={() => reset()}>
Use a different email
</button>
{error && <p role="alert">That code didn't work. Check it and try again.</p>}
</form>
);
}
// Email screen
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendCode({ email });
}}
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
<button type="submit" disabled={isSending}>
{isSending ? 'Sending…' : 'Email me a code'}
</button>
</form>
);
}
SMS OTP
SMS is the same two-step flow, plus a country code. sendSmsOTP takes an ISO country code ('US', 'GB', 'BR', …) and the phone number without the country dial-in prefix. The SDK exports supportedCountries so you can build a picker instead of hard-coding a list.
Build the country picker from supportedCountries rather than hard-coding a list, then send and verify exactly as with email.import { sendSmsOTP, verifyOTP, supportedCountries } from '@dynamic-labs-sdk/client';
import type { OTPVerification, CountryCode } from '@dynamic-labs-sdk/client';
// supportedCountries maps an ISO code to its dial-in code and display name:
// supportedCountries.US -> { code: '1', name: 'United States' }
const countrySelect = document.querySelector<HTMLSelectElement>('#country')!;
for (const iso of Object.keys(supportedCountries) as CountryCode[]) {
const option = document.createElement('option');
option.value = iso;
option.textContent = `${supportedCountries[iso].name} (+${supportedCountries[iso].code})`;
countrySelect.append(option);
}
// Two forms in your markup: one for the phone number, one for the code.
const phoneForm = document.querySelector<HTMLFormElement>('#phone-form')!;
const codeForm = document.querySelector<HTMLFormElement>('#code-form')!;
let verification: OTPVerification;
// Screen 1: send the code. Pass the number WITHOUT the dial-in prefix —
// the selected country code already supplies it.
phoneForm.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(phoneForm);
verification = await sendSmsOTP({
isoCountryCode: data.get('country') as CountryCode,
phoneNumber: data.get('phone') as string,
});
// Reveal your code-entry screen here.
});
// Screen 2: verify identically to email.
codeForm.addEventListener('submit', async (event) => {
event.preventDefault();
const verificationToken = new FormData(codeForm).get('code') as string;
await verifyOTP({ otpVerification: verification, verificationToken });
});
import { useSendSmsOTP, useVerifyOTP } from '@dynamic-labs-sdk/react-hooks';
import { supportedCountries } from '@dynamic-labs-sdk/client';
import type { CountryCode } from '@dynamic-labs-sdk/client';
import { useState } from 'react';
// Build the picker options once, sorted by country name.
const countryOptions = (Object.keys(supportedCountries) as CountryCode[]).sort(
(a, b) => supportedCountries[a].name.localeCompare(supportedCountries[b].name),
);
function SmsSignIn() {
const [isoCountryCode, setIsoCountryCode] = useState<CountryCode>('US');
const [phoneNumber, setPhoneNumber] = useState('');
const [code, setCode] = useState('');
const { mutate: sendCode, data: otpVerification, reset } = useSendSmsOTP();
const { mutate: verifyCode, isPending: isVerifying } = useVerifyOTP();
if (otpVerification) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
verifyCode({ otpVerification, verificationToken: code });
}}
>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="Enter the code"
/>
<button type="submit" disabled={isVerifying}>Verify</button>
<button type="button" onClick={() => reset()}>Back</button>
</form>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendCode({ isoCountryCode, phoneNumber });
}}
>
<select
value={isoCountryCode}
onChange={(e) => setIsoCountryCode(e.target.value as CountryCode)}
>
{countryOptions.map((iso) => (
<option key={iso} value={iso}>
{supportedCountries[iso].name} (+{supportedCountries[iso].code})
</option>
))}
</select>
<input
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="Phone number"
/>
<button type="submit">Text me a code</button>
</form>
);
}
Pass the phone number without the country dial-in prefix — the country code you select already supplies it. Sending +1 (or 1) as part of phoneNumber for a US number will produce an unreachable number.
Resend and expiry
Codes expire after a few minutes, so give users a way to request a new one. Re-run the same send call — it returns a fresh OTPVerification that replaces the previous one. Add a short cooldown so users can’t spam the button (and to stay within the rate limits below).
import { useSendEmailOTP } from '@dynamic-labs-sdk/react-hooks';
import { useEffect, useState } from 'react';
const RESEND_COOLDOWN_SECONDS = 30;
function ResendButton({ email }: { email: string }) {
const { mutate: sendCode } = useSendEmailOTP();
const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
useEffect(() => {
if (secondsLeft === 0) return;
const timer = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(timer);
}, [secondsLeft]);
return (
<button
disabled={secondsLeft > 0}
onClick={() => {
sendCode({ email });
setSecondsLeft(RESEND_COOLDOWN_SECONDS);
}}
>
{secondsLeft > 0 ? `Resend in ${secondsLeft}s` : 'Resend code'}
</button>
);
}
Handling errors
The send (sendEmailOTP / sendSmsOTP) and verify (verifyOTP) calls reject with an APIError (from @dynamic-labs-sdk/client/core) when the backend rejects the request. Don’t treat every rejection the same — the response you show depends on which case it is:
| What happened | How to detect | What to do |
|---|
| Wrong or expired code | verifyOTP rejects with APIError | Keep the user on the code-entry screen so they can re-enter it or request a new one. |
| Too many attempts (rate limit) | APIError with status 429 | Show a “wait a moment” message; don’t retry automatically. |
| Invalid email or phone number | sendEmailOTP / sendSmsOTP rejects with APIError | Keep the user on the entry screen and prompt them to correct it. |
Branch on the error so a wrong code keeps the user on the code screen, while a rate limit tells them to wait:
import { APIError } from '@dynamic-labs-sdk/client/core';
try {
await verifyOTP({ otpVerification: verification, verificationToken: code });
} catch (error) {
// showCodeError is your implementation.
if (error instanceof APIError && error.status === 429) {
showCodeError('Too many attempts. Wait a moment before trying again.');
} else {
// Wrong or expired code — keep the user on the code screen so they can
// re-enter it or request a new one.
showCodeError('That code is invalid or has expired. Try again or resend.');
}
}
Email verification is rate limited to 3 attempts per 10 minutes per email address to protect deliverability. Surface a clear “try again shortly” message rather than retrying automatically. If users report missing emails, see the Email OTP delivery troubleshooting guide.
After verification
Once verifyOTP resolves, the user is authenticated. In React, useUser() now returns the user; in TypeScript, read the session with client.user. New users often need onboarding, MFA enrollment, or an embedded wallet before entering your app — handle that next in Post-login user setup.
See also