Skip to main content
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:
  1. 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.
  2. 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.
});

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 });
});
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 happenedHow to detectWhat to do
Wrong or expired codeverifyOTP rejects with APIErrorKeep 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 429Show a “wait a moment” message; don’t retry automatically.
Invalid email or phone numbersendEmailOTP / sendSmsOTP rejects with APIErrorKeep 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

Last modified on July 9, 2026