> ## Documentation Index
> Fetch the complete documentation index at: https://www.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](/javascript/reference/client/create-dynamic-client), [Initializing the Dynamic Client](/javascript/reference/client/initialize-dynamic-client)).

Enable **Email** and/or **SMS** as sign-in methods in your [Dynamic dashboard](https://app.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.
</Note>

## Email OTP

<Tabs>
  <Tab title="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>

  <Tab title="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>
      );
    }
    ```
  </Tab>
</Tabs>

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

<Tabs>
  <Tab title="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>

  <Tab title="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>
      );
    }
    ```
  </Tab>
</Tabs>

<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.
</Warning>

## 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 { 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](/overview/troubleshooting/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](/javascript/building-ui/post-auth-user-setup).

## See also

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