Recipe
Signup verification email
Send a one-time-code (OTP) + a click-to-confirm link the moment a user signs up. This is the single highest-value email your app sends — if it doesn't land, your activation funnel breaks.
Pattern
- User submits signup form with email + password
- Your app generates a 6-digit OTP + a signed verification token
- Your app POSTs to
/api/v1/transactional/sendwith both - User receives email, clicks link OR enters OTP
- Your app verifies + marks the user's email as confirmed
Template
Store the template server-side once and reference by ID — keeps your app code clean and lets you A/B subject lines without a redeploy.
curl -X POST "$SENDBOLT_API_URL/api/v1/templates" \
-H "Authorization: Bearer $SENDBOLT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "signup-verification",
"subject": "Confirm your {{.AppName}} account",
"preheader_text": "One click and you are in.",
"body_text": "Hi {{.FirstName}},\n\nClick to confirm: {{.VerifyURL}}\n\nOr enter this code: {{.OTP}}\n\nIf you did not sign up, ignore this email.",
"body_html": "<p>Hi {{.FirstName}},</p><p><a href=\"{{.VerifyURL}}\">Confirm your email</a></p><p>Or enter code <strong>{{.OTP}}</strong></p>"
}'Send
// In your signup handler, after creating the User row:
const otp = String(Math.floor(100000 + Math.random() * 900000)); // 6 digits
const verifyToken = await signVerifyToken({ userId, exp: nowPlus(30 * 60) });
const verifyURL = `https://acme.com/verify?t=${verifyToken}`;
await fetch(`${process.env.SENDBOLT_API_URL}/api/v1/transactional/send`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SENDBOLT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: user.email,
template_id: process.env.SB_TEMPLATE_SIGNUP_VERIFICATION,
template_vars: {
FirstName: user.firstName,
AppName: "Acme",
VerifyURL: verifyURL,
OTP: otp,
},
bounce_risk_check_enabled: true,
}),
});
// Persist the OTP + hash for the /verify endpoint to check later
await db.signupVerifications.insert({ userId, otpHash: hash(otp), expiresAt: nowPlus(30 * 60) });Defensive checks
- Pre-flight bounce risk — set
bounce_risk_check_enabled: true. SendBolt will reject disposable-domain signups (mailinator, guerrillamail, etc.) withskipped_high_riskbefore they hit your funnel. - OTP expiry — 30 minutes is the sweet spot. Shorter frustrates legitimate users; longer increases replay-attack window.
- Rate limit — cap signup attempts per IP at 5/hour server-side. Otherwise a script kiddie sends 10k verification emails to a hard-bounced address and tanks your reputation.
- Resend cooldown— show a 60-second “resend disabled” state on the “didn't get it?” button. Prevents both user-side frustration loops and abuse.
Common questions
What if the recipient's domain doesn't accept mail?
The response carries status: "skipped_high_risk" + bounce_risk_reason. Your signup endpoint should surface “That email address doesn't look valid” to the user and not create the User row. Otherwise you accrue un-activatable accounts.
How long until the email arrives?
Typical: 2-8 seconds end-to-end (your app → SendBolt → Gmail). 99th percentile is under 30 seconds. If your UI says “check your email” and the user is staring at an empty inbox after 60s, the most common causes are:
- Greylisting (corporate domains) — usually clears in 5 min
- Gmail Promotions tab (see troubleshooting)
- SPF/DKIM/DMARC misalignment — check DNS
Should I include the OTP if I'm using a click-to-confirm link?
Yes. Belt and suspenders. Some users copy-paste the code from their phone into the desktop browser; some click the link and it opens in a webview that doesn't share auth state with the main browser. Including both costs you nothing.
Should the link be one-time-use?
Yes. Invalidate the token + OTP the moment either is consumed. Otherwise the email forwarder pattern (user forwards your confirmation to support because they think something's wrong) leaks an account-takeover primitive.
What about the welcome email?
Send it from your /verify endpoint after you mark the user confirmed — see Welcome email.