← Back to blog

Email Verification Links (2026): Confirm Addresses Without a Code

Send a single-use email verification link with POST /emails—app-side token like password reset, complementary to OTP codes, tracking off.

Updated November 4, 2026
Email Verification Links (2026): Confirm Addresses Without a Code

When a verification link beats a six-digit code

Proving someone owns an email address is one of the first transactional messages almost every product sends. The official Rovela recipe for that is a six-digit verification code: generate digits in your app, store a hash, send with POST /emails, and check what the user types.

Sometimes a click-to-confirm link is a better fit than typing digits:

  • Desktop sign-up — the user already has the inbox open in another tab; one click is faster than copying a code.
  • Email-change confirmation — you need a durable “yes, this new address is mine” signal, not a short OTP typed into a modal.
  • Double opt-in for a list or waitlist — the action is “confirm this subscription,” not “prove I’m at this keyboard right now.”
  • Accessibility / corporate filters — some environments make typing from a phone notification awkward; a full URL in the plain-text part still works.

Rovela does not ship a dedicated “email verification links” product or endpoint. The send path is the same as every other transactional message: POST /emails. The closest official pattern is password reset links: unguessable token, hash + short expiry in your database, raw token only in the email, redeem once. Email verification is that pattern with a different landing page — mark email_verified_at (or swap the pending address) instead of setting a password.

This article is deliberately not another OTP guide. For codes, use the verification-code docs and the existing blog posts on sending OTPs. Here the user clicks a link.

What you build vs what Rovela delivers

Your application owns:

  • Cryptographic token generation and hashing
  • Persistence (token hash, user or pending-email id, purpose, expiry, single-use consumption)
  • The absolute URL on your origin (https://app.example.com/verify-email?token=…)
  • The redeem route that validates the token and marks the address verified
  • Rate limits on “resend verification,” and server-side API calls only

Rovela delivers:

  • Accepting POST https://api.rovela.dev/emails with a Bearer re_… key (sending_access is enough)
  • Queueing, DKIM for your verified domain, retries, bounce/complaint handling, and the message log
  • Optional templates, webhooks, and suppressions — same infrastructure as OTP, reset, magic login, and invites

Do not put the API key in a browser or mobile client. Call Rovela from your backend. Plans and volume limits live on pricing; this article invents none of those numbers.

Generate and store the token (32 bytes, hash, expiry)

Follow the same entropy rules as the password-reset docs:

  1. Normalize the address (trim + lowercase).
  2. Create or look up the user (or the pending email-change row).
  3. Generate 32 random bytes and encode as base64url.
  4. Store only the SHA-256 hex hash of the token — never the raw token. Thirty-two random bytes cannot be brute-forced from their hash, so a plain SHA-256 is enough (unlike six-digit OTPs, which need a keyed HMAC because the space is tiny).
  5. Set an expiry — commonly 24 hours for sign-up verification, shorter (for example 1–2 hours) for an email-change confirm.
  6. Build https://app.acme.com/verify-email?token= + the raw token. The raw value appears only in the email URL.

Illustrative Node shape (adapt table names to your schema):

import crypto from "node:crypto"

export async function issueEmailVerification(userId, email) {
  const token = crypto.randomBytes(32).toString("base64url")
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

  await db.emailVerifications.upsert({
    userId,
    email: email.trim().toLowerCase(),
    tokenHash,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
  })

  return "https://app.acme.com/verify-email?token=" + token
}

Rate-limit resends (for example one message per address per minute) so a button cannot flood an inbox or burn sending quota. If you invalidate previous tokens on each resend, only the latest link works — that is usually what you want.

Send the link with POST /emails

Send from a verified subdomain so authentication mail keeps a separate reputation from marketing (for example security@mail.acme.com). Include both html and text. Put the full absolute URL in the text part: some corporate filters strip HTML links, and the plain-text body is the fallback that still works.

Use an Idempotency-Key (up to 256 characters). If your server times out after Rovela accepted the first request, a retry with the same key returns the original result instead of delivering two different links. A 200 from POST /emails means the message was accepted into the queue, not that it reached the inbox — check the dashboard message page or webhooks for delivery events.

const link = await issueEmailVerification(user.id, user.email)
const tokenHashPrefix = /* first 32 hex chars of the stored hash, or a UUID you generated for this send */

await fetch("https://api.rovela.dev/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.ROVELA_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": "email-verify-" + tokenHashPrefix,
  },
  body: JSON.stringify({
    from: "Acme <security@mail.acme.com>",
    to: user.email,
    subject: "Confirm your email for Acme",
    html:
      "<p>Confirm this address for your Acme account.</p>" +
      '<p><a href="' + link + '">Verify email</a></p>' +
      "<p>This link works once and expires in 24 hours. " +
      "If you did not create an account, you can ignore this email.</p>",
    text:
      "Confirm this address for your Acme account.\n\n" +
      "Verify email: " + link + "\n\n" +
      "This link works once and expires in 24 hours.",
  }),
})

Equivalent curl shape:

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: email-verify-8f3a2c71…" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Confirm your email for Acme",
    "html": "<p>Confirm this address…</p><p><a href=\"https://app.acme.com/verify-email?token=…\">Verify email</a></p>",
    "text": "Confirm this address…\n\nVerify email: https://app.acme.com/verify-email?token=…"
  }'

Redeem once and mark the address verified

On GET /verify-email?token=… (or a POST from a confirmation page):

  1. Hash the submitted token the same way you stored it.
  2. Delete and return the matching unexpired row in one statement so two parallel clicks cannot both succeed.
  3. If nothing is returned, show a generic “link invalid or expired” page — do not reveal whether the token was unknown, already used, or timed out in a way that helps an attacker.
  4. On success, set email_verified_at (or apply the pending email change), then optionally create a session or redirect to “you’re verified — continue.”
export async function redeemEmailVerification(token) {
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
  const row = await db.query(
    `DELETE FROM email_verifications
     WHERE token_hash = $1 AND expires_at > NOW()
     RETURNING user_id, email`,
    [tokenHash]
  )
  if (row.rowCount === 0) return false

  await db.users.markEmailVerified(row.rows[0].user_id, row.rows[0].email)
  return true
}

For email change, store the new address on the verification row and only write it onto the user after redeem. Send the link to the new address; until redeem succeeds, keep using the old verified address for security notices.

Turn click tracking off; keep the URL in text

Click tracking rewrites links through a tracking host. The verify URL still works, but a security-sensitive token should go straight to your app. Prefer a sending domain (or subdomain) with click tracking off for verification, reset, magic login, and invite mail — the same advice as in the password-reset example.

Always send text with the full URL. HTML-only bodies are fragile under aggressive filters.

Failure modes worth handling

  • 400 with recipient is suppressed — the address hard-bounced or complained earlier. Do not tell the user “check your inbox”; ask for a different address.
  • 400 about your from domain — the domain is not verified in this organization. Fix DNS / verification in the dashboard; that is a config problem, not a user typo.
  • 429 — rate limit or plan quota; honor Retry-After if present.
  • 200 then “I never got it” — acceptance ≠ delivery. Use the message timeline or an email.bounced webhook rather than blindly resending without an idempotency story.

OTP vs verify-link: pick deliberately

Prefer a six-digit OTP when…Prefer a verify link when…
The user is on a phone and will type into your appThe user is on desktop with the inbox open
You need “prove presence now” (login step-up)You need durable ownership (sign-up, email change)
You already follow the verification-code recipeYou already follow the password-reset link recipe

Many products ship both: OTP for login / sensitive actions, and a verify link for initial address confirmation. Same Rovela send API either way — only your token shape and landing page change.

Related patterns

Wire the token table and one POST /emails call, keep tracking off for security mail, and you have click-to-verify without inventing a special email API.