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

  1. User submits signup form with email + password
  2. Your app generates a 6-digit OTP + a signed verification token
  3. Your app POSTs to /api/v1/transactional/send with both
  4. User receives email, clicks link OR enters OTP
  5. 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.MP_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.) with skipped_high_risk before 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.