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.
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/emailswith a Bearerre_…key (sending_accessis 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:
- Normalize the address (
trim+ lowercase). - Create or look up the user (or the pending email-change row).
- Generate 32 random bytes and encode as
base64url. - 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).
- Set an expiry — commonly 24 hours for sign-up verification, shorter (for example 1–2 hours) for an email-change confirm.
- 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):
- Hash the submitted token the same way you stored it.
- Delete and return the matching unexpired row in one statement so two parallel clicks cannot both succeed.
- 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.
- 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
400withrecipient is suppressed— the address hard-bounced or complained earlier. Do not tell the user “check your inbox”; ask for a different address.400about yourfromdomain — 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; honorRetry-Afterif present.200then “I never got it” — acceptance ≠ delivery. Use the message timeline or anemail.bouncedwebhook 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 app | The 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 recipe | You 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
- Password reset links — the official link-token recipe this article follows
- Email verification codes — the official OTP recipe (complementary, not a substitute)
- Send Email API — fields, idempotency,
200= queued - Sibling posts: Magic login email links, Team invite email links
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.