This recipe adds RocketVerifier Connect to an existing web app. Your users click Connect RocketVerifier, approve your app once, and from then on your app verifies their contacts on their own RocketVerifier credits. You never see their password or an API key, and you are never billed.

Both versions below do the same five things: start the connection, handle the callback, verify one contact, verify a list as a bulk job, and handle a disconnect. For the reasoning behind each step, read Email verification in your SaaS with OAuth.

Before you start

Register the app in Dashboard, Developer apps, New app. Add the exact redirect URI you will use, for example http://localhost:3000/rocketverifier/callback in development. Give the app these scopes:

  • verify:single to verify one contact at a time
  • verify:bulk and jobs:read to verify a whole list as one job
  • account:read to show who connected and their credit balance

Then set three environment variables on your server:

export RV_CLIENT_ID=rvapp_xxxxxxxxxxxx
export RV_CLIENT_SECRET=rv_cs_xxxxxxxxxxxxxxxxxxxxxxxx
export RV_REDIRECT_URI=http://localhost:3000/rocketverifier/callback

The client secret is shown once. Keep it on the server only.

Node.js (Express)

npm install @rocketverifier/sdk express express-session
// rocketverifier.js
import express from 'express';
import session from 'express-session';
import RocketVerifier, {
  OAuth,
  OAuthError,
  InvalidTokenError,
  InsufficientCreditsError,
  verifyWebhookSignature,
} from '@rocketverifier/sdk';
import { db, encrypt, decrypt } from './db.js'; // your storage and encryption helpers

const oauth = new OAuth({
  clientId: process.env.RV_CLIENT_ID,
  clientSecret: process.env.RV_CLIENT_SECRET,
  redirectUri: process.env.RV_REDIRECT_URI,
});

const SCOPES = ['verify:single', 'verify:bulk', 'jobs:read', 'account:read'];
const app = express();

// The webhook needs the raw body for its signature, so register it before express.json().
app.post('/rocketverifier/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const ok = verifyWebhookSignature(req.body, req.get('X-RocketVerifier-Signature'), process.env.RV_APP_WEBHOOK_SECRET);
  if (!ok) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  if (event.event === 'grant.revoked') {
    // Matches the account recorded at connect time (see the callback).
    await db.rvConnections.deleteByAccount(event.data.user_email, event.data.organization_id);
  }
  res.sendStatus(200);
});

app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));

// 1. The Connect button links here.
app.get('/rocketverifier/connect', (req, res) => {
  const { url, state, codeVerifier } = oauth.getAuthorizeUrl({ scopes: SCOPES });
  req.session.rvOauth = { state, codeVerifier };
  res.redirect(url);
});

// 2. RocketVerifier sends the user back here.
app.get('/rocketverifier/callback', async (req, res) => {
  const saved = req.session.rvOauth;
  delete req.session.rvOauth;
  if (req.query.error) return res.redirect('/settings?rv=cancelled'); // access_denied on Cancel
  if (!saved || req.query.state !== saved.state) return res.status(400).send('Invalid state');

  const tokens = await oauth.exchangeCode({ code: String(req.query.code), codeVerifier: saved.codeVerifier });
  const me = await new RocketVerifier({ accessToken: tokens.accessToken }).getMe();
  await db.rvConnections.upsert(req.user.id, encrypt(tokens), {
    rvEmail: me.email,
    rvOrganizationId: me.organization?.id ?? null, // grant.revoked identifies the connection by these
  });
  res.redirect('/settings?rv=connected');
});

// A client for one user. Refreshes expired access tokens and saves the rotated pair.
async function rvFor(userId) {
  const row = await db.rvConnections.get(userId);
  if (!row) return null;
  const tokens = decrypt(row);
  return new RocketVerifier({
    accessToken: tokens.accessToken,
    refreshToken: tokens.refreshToken,
    oauth,
    onTokenRefresh: (next) => db.rvConnections.upsert(userId, encrypt(next)),
  });
}

// Turns "connection is gone" and "out of credits" into responses your UI understands.
async function withRv(req, res, fn) {
  const rv = await rvFor(req.user.id);
  if (!rv) return res.status(409).json({ error: 'connect_rocketverifier' });
  try {
    return await fn(rv);
  } catch (err) {
    if ((err instanceof OAuthError && err.error === 'invalid_grant') || err instanceof InvalidTokenError) {
      await db.rvConnections.delete(req.user.id);
      return res.status(409).json({ error: 'connect_rocketverifier' });
    }
    if (err instanceof InsufficientCreditsError) {
      return res.status(402).json({ error: 'rocketverifier_out_of_credits' });
    }
    throw err;
  }
}

// 3. Show who is connected.
app.get('/rocketverifier/status', (req, res) =>
  withRv(req, res, async (rv) => {
    const me = await rv.getMe();
    res.json({ connectedAs: me.email, credits: me.credits });
  }),
);

// 4. Verify one contact, for example when it is created.
app.post('/contacts/:id/verify', (req, res) =>
  withRv(req, res, async (rv) => {
    const contact = await db.contacts.get(req.user.id, req.params.id);
    const result = await rv.verify(contact.email);
    await db.contacts.update(contact.id, { emailStatus: result.status, emailSubStatus: result.subStatus });
    res.json({ status: result.status, subStatus: result.subStatus });
  }),
);

// 5. Verify a whole list as one bulk job (up to 100,000 addresses).
app.post('/contacts/verify-all', (req, res) =>
  withRv(req, res, async (rv) => {
    const emails = await db.contacts.emailsFor(req.user.id);
    const job = await rv.batch.create({ emails, name: 'All contacts' });
    res.json({ jobId: job.id, creditsToUse: job.creditsToUse });
  }),
);

// Collect results once the job has finished.
app.post('/contacts/verify-all/:jobId/apply', (req, res) =>
  withRv(req, res, async (rv) => {
    const job = await rv.batch.wait(req.params.jobId);
    if (job.status !== 'completed') return res.status(409).json({ status: job.status });
    for await (const r of rv.batch.iterateResults(job.id)) {
      await db.contacts.updateByEmail(req.user.id, r.email, { emailClass: r.classification, emailSubStatus: r.subStatus });
    }
    res.json({ updated: job.totalEmails });
  }),
);

// Disconnect from your side too.
app.post('/rocketverifier/disconnect', async (req, res) => {
  const row = await db.rvConnections.get(req.user.id);
  if (row) {
    const { refreshToken } = decrypt(row);
    if (refreshToken) await oauth.revoke(refreshToken, 'refresh_token');
    await db.rvConnections.delete(req.user.id);
  }
  res.redirect('/settings?rv=disconnected');
});

app.listen(3000);

batch.wait polls every 5 seconds for up to 5 minutes. For large lists, register a job.completed webhook on the user’s account (scope webhooks:manage) instead of holding a request open.

Python (Flask)

pip install rocketverifier flask
# rocketverifier_app.py
import json
import os

from flask import Flask, abort, jsonify, redirect, request, session
from rocketverifier import (
    InsufficientCreditsError,
    InvalidTokenError,
    OAuth,
    OAuthError,
    RocketVerifier,
    verify_webhook_signature,
)

from myapp.db import db, encrypt, decrypt, current_user  # your storage, encryption and auth helpers

oauth = OAuth(
    client_id=os.environ["RV_CLIENT_ID"],
    client_secret=os.environ["RV_CLIENT_SECRET"],
    redirect_uri=os.environ["RV_REDIRECT_URI"],
)
SCOPES = ["verify:single", "verify:bulk", "jobs:read", "account:read"]

app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]


def save_tokens(user_id, tokens):
    db.rv_connections.upsert(user_id, encrypt({
        "access_token": tokens.access_token,
        "refresh_token": tokens.refresh_token,
    }))


# 1. The Connect button links here.
@app.get("/rocketverifier/connect")
def connect():
    auth = oauth.get_authorize_url(scopes=SCOPES)
    session["rv_oauth"] = {"state": auth.state, "code_verifier": auth.code_verifier}
    return redirect(auth.url)


# 2. RocketVerifier sends the user back here.
@app.get("/rocketverifier/callback")
def callback():
    saved = session.pop("rv_oauth", None)
    if request.args.get("error"):
        return redirect("/settings?rv=cancelled")  # access_denied on Cancel
    if not saved or request.args.get("state") != saved["state"]:
        abort(400, "Invalid state")
    tokens = oauth.exchange_code(code=request.args["code"], code_verifier=saved["code_verifier"])
    me = RocketVerifier(access_token=tokens.access_token).get_me()
    save_tokens(current_user().id, tokens)
    # grant.revoked identifies the connection by the RocketVerifier account email and organization.
    db.rv_connections.set_account(current_user().id, me.email, (me.organization or {}).get("id"))
    return redirect("/settings?rv=connected")


def rv_for(user_id):
    row = db.rv_connections.get(user_id)
    if row is None:
        return None
    tokens = decrypt(row)
    return RocketVerifier(
        access_token=tokens["access_token"],
        refresh_token=tokens["refresh_token"],
        oauth=oauth,
        on_token_refresh=lambda new: save_tokens(user_id, new),  # refresh tokens rotate
    )


def with_rv(fn):
    user_id = current_user().id
    rv = rv_for(user_id)
    if rv is None:
        return jsonify(error="connect_rocketverifier"), 409
    try:
        return fn(rv)
    except (InvalidTokenError, OAuthError) as err:
        if isinstance(err, OAuthError) and err.error != "invalid_grant":
            raise
        db.rv_connections.delete(user_id)
        return jsonify(error="connect_rocketverifier"), 409
    except InsufficientCreditsError:
        return jsonify(error="rocketverifier_out_of_credits"), 402


# 3. Show who is connected.
@app.get("/rocketverifier/status")
def status():
    def run(rv):
        me = rv.get_me()
        return jsonify(connected_as=me.email, credits=me.credits)
    return with_rv(run)


# 4. Verify one contact.
@app.post("/contacts/<contact_id>/verify")
def verify_contact(contact_id):
    def run(rv):
        contact = db.contacts.get(current_user().id, contact_id)
        result = rv.verify(contact.email)
        db.contacts.update(contact.id, email_status=result.status, email_sub_status=result.sub_status)
        return jsonify(status=result.status, sub_status=result.sub_status)
    return with_rv(run)


# 5. Verify a whole list as one bulk job, then apply the results.
@app.post("/contacts/verify-all")
def verify_all():
    def run(rv):
        emails = db.contacts.emails_for(current_user().id)
        job = rv.batch.create(emails=emails, name="All contacts")
        return jsonify(job_id=job.id, credits_to_use=job.credits_to_use)
    return with_rv(run)


@app.post("/contacts/verify-all/<job_id>/apply")
def apply_results(job_id):
    def run(rv):
        job = rv.batch.wait(job_id)
        if job.status != "completed":
            return jsonify(status=job.status), 409
        for r in rv.batch.iter_results(job.id):
            db.contacts.update_by_email(current_user().id, r.email, email_class=r.classification, email_sub_status=r.sub_status)
        return jsonify(updated=job.total_emails)
    return with_rv(run)


# grant.revoked: the user disconnected inside RocketVerifier.
@app.post("/rocketverifier/webhook")
def webhook():
    raw = request.get_data()
    if not verify_webhook_signature(raw, request.headers.get("X-RocketVerifier-Signature"), os.environ["RV_APP_WEBHOOK_SECRET"]):
        abort(400)
    event = json.loads(raw)
    if event["event"] == "grant.revoked":
        data = event["data"]
        db.rv_connections.delete_by_account(data["user_email"], data["organization_id"])
    return "", 200


# Disconnect from your side too.
@app.post("/rocketverifier/disconnect")
def disconnect():
    user_id = current_user().id
    row = db.rv_connections.get(user_id)
    if row is not None:
        refresh_token = decrypt(row)["refresh_token"]
        if refresh_token:
            oauth.revoke(refresh_token, "refresh_token")
        db.rv_connections.delete(user_id)
    return redirect("/settings?rv=disconnected")

For an async stack (FastAPI, Starlette), AsyncRocketVerifier takes the same arguments and exposes the same methods as coroutines.

The Connect button

Use the official button image so users recognize it:

<a href="/rocketverifier/connect">
  <img src="https://docs.rocketverifier.com/brand/connect-rocketverifier-light.svg"
       alt="Connect RocketVerifier" width="248" height="48">
</a>

A dark version is at connect-rocketverifier-dark.svg in the same folder. Next to it, say who pays: “Verifications use your RocketVerifier credits.” Once connected, replace the button with the /rocketverifier/status result, for example “Connected as jane@acme.com, 12,400 credits”, and a Disconnect link.

What to show for each result

Single verifications return status and sub_status; bulk results return classification and sub_status. A simple mapping for a contacts table:

Result Label Suggested action
deliverable / valid Valid Keep
risky / risky, catch_all Risky Keep, but check sub_status (for example role_based or catch_all)
undeliverable / invalid, disposable Invalid Exclude from sends
unknown / unknown Unknown Retry later. Not charged

The glossary explains each sub_status, and the free email verifier is useful for spot checks while you build.

Getting listed

Once the integration works end to end, including disconnect, click Submit for review in the app settings. Approved apps get a Verified badge on the consent screen and a page in the RocketVerifier app directory.