> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Email & SMS OTP sign-in

> Build a one-time-passcode sign-in UI: send a code to an email or phone, then verify it to authenticate the user.

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](https://docs.dynamic.xyz/docs/javascript/reference/client/create-dynamic-client.md), [Initializing the Dynamic Client](https://docs.dynamic.xyz/docs/javascript/reference/client/initialize-dynamic-client.md)).

Enable **Email** and/or **SMS** as sign-in methods in your [Dynamic Console](https://console.dynamic.xyz/dashboard/log-in-user).

## 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.

> [!NOTE]
>
> `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

> [tab] TypeScript
>
> 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.
>
> ```typescript theme={"system"}
> 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.
> });
> ```

> [tab] React
>
> `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.
>
> ```tsx theme={"system"}
> 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.

> [tab] TypeScript
>
> Build the country picker from `supportedCountries` rather than hard-coding a list, then send and verify exactly as with email.
>
> ```typescript theme={"system"}
> 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 });
> });
> ```

> [tab] React
>
> ```tsx theme={"system"}
> 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>
>   );
> }
> ```

> [!WARNING]
>
> 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).

```tsx theme={"system"}
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:

```typescript theme={"system"}
import { verifyOTP } from '@dynamic-labs-sdk/client';
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](https://docs.dynamic.xyz/docs/get-started/troubleshooting/email-otp-delivery.md) 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](https://docs.dynamic.xyz/docs/javascript/building-ui/post-auth-user-setup.md).

## See also

* [Authenticate with email](https://docs.dynamic.xyz/docs/javascript/authentication-methods/email.md) — email method reference
* [Authenticate with SMS](https://docs.dynamic.xyz/docs/javascript/authentication-methods/sms.md) — SMS method reference
* [Social sign-in & OAuth redirect handling](https://docs.dynamic.xyz/docs/javascript/building-ui/social-oauth-redirect-handling.md) — add social login alongside OTP
* [Post-login user setup](https://docs.dynamic.xyz/docs/javascript/building-ui/post-auth-user-setup.md) — what to do once the user is authenticated


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.