# Build a RocketVerifier integration (spec for AI coding assistants)

Paste this file, or its URL `https://docs.rocketverifier.com/build-an-integration.md`, into your AI coding assistant and say what you are building, for example: "Add a Connect RocketVerifier button to my app so users can verify their contact list." Everything needed to build the integration end to end is on this page.

RocketVerifier is an email verification API. RocketVerifier Connect is its OAuth 2.0 provider: your users click "Connect RocketVerifier", approve your app on a consent screen, and your app calls the verification API on their account and their credits. You (the developer) pay nothing.

## 0. Decide: API key or OAuth

| Situation | Use |
|---|---|
| Your own backend verifies emails for your own business | API key (`rv_live_...`) from Dashboard, API Keys. Skip to section 6. |
| Your app's users each bring their own RocketVerifier account | OAuth (RocketVerifier Connect). Follow every section. |
| An AI assistant or MCP client connects to RocketVerifier | MCP OAuth (section 9). |

Never ask your users to paste an API key into your app. Use OAuth.

## 1. Facts to hard-code

| Item | Value |
|---|---|
| Issuer | `https://app.rocketverifier.com` |
| Authorize URL | `https://app.rocketverifier.com/oauth/authorize` |
| Token URL | `https://app.rocketverifier.com/api/oauth/token` |
| Revoke URL (RFC 7009) | `https://app.rocketverifier.com/api/oauth/revoke` |
| Introspect URL (RFC 7662) | `https://app.rocketverifier.com/api/oauth/introspect` |
| Metadata (RFC 8414) | `https://app.rocketverifier.com/.well-known/oauth-authorization-server` |
| API base URL | `https://api.rocketverifier.com` (all paths below start with `/v1`) |
| Client id | `rvapp_...` (public) |
| Client secret | `rv_cs_...` (server only, shown once) |
| Access token | `rv_at_...`, valid 1 hour |
| Refresh token | `rv_rt_...`, valid 90 days, rotates on every use |
| Authorization code | single use, expires after 60 seconds |
| PKCE | `S256`, required for public clients, use it always |
| Node SDK | `npm install @rocketverifier/sdk` |
| Python SDK | `pip install rocketverifier` |

Environment variables the integration should read:

```
RV_CLIENT_ID=rvapp_...
RV_CLIENT_SECRET=rv_cs_...
RV_REDIRECT_URI=https://yourapp.com/rocketverifier/callback
```

## 2. Register the app (manual step for the human)

1. Sign in at `https://app.rocketverifier.com`, open Dashboard, Developer apps, New app.
2. Fill in name, logo, homepage, privacy policy URL and terms URL.
3. Add the exact redirect URI(s). Matching is exact: scheme, host, port, path. `http://localhost:3000/...` and `http://127.0.0.1:...` are allowed for development.
4. Pick the allowed scopes (section 3).
5. Copy the client id and the client secret. The secret is shown once.
6. Optional: set a webhook URL to receive `grant.revoked` (section 8).

New apps work immediately and show an "Unverified app" badge on the consent screen. "Submit for review" in the app settings removes the badge after approval and lists the app in the public directory.

## 3. Scopes

Request the fewest scopes your feature needs. Users see this list on the consent screen.

| Scope | Allows | Endpoints |
|---|---|---|
| `verify:single` | Verify one email at a time | `POST /v1/verify` |
| `verify:bulk` | Start bulk verification jobs | `POST /v1/verify/bulk`, `POST /v1/verify/bulk/upload` |
| `jobs:read` | Read bulk jobs and results | `GET /v1/jobs`, `GET /v1/jobs/{id}`, `/progress`, `/results`, `/download` |
| `jobs:write` | Cancel bulk jobs | `DELETE /v1/jobs/{id}` |
| `webhooks:manage` | Manage webhooks on the user's account | `/v1/webhooks/*` |
| `credits:read` | Read the credit balance | `GET /v1/credits` |
| `monitors:read` | Read deliverability monitors | `GET /v1/monitors/*` |
| `monitors:write` | Create, change and run monitors | `POST/PATCH/DELETE /v1/monitors/*` |
| `account:read` | Show who connected and their credits | `GET /v1/me` |

Typical sets:
- Verify a contact list: `verify:bulk jobs:read account:read`
- Verify each signup or chat contact as it arrives: `verify:single account:read`

## 4. The flow

```
Your app                          RocketVerifier
--------                          --------------
1. User clicks "Connect RocketVerifier"
2. Generate state + PKCE verifier, store them in the user's session
3. Redirect to /oauth/authorize  ->  user signs in, picks personal or organization
                                     account, optional monthly credit cap, clicks Allow
4. Callback ?code=...&state=...  <-  redirect back to your redirect_uri
5. Check state, POST /api/oauth/token with code + code_verifier
6. Store access + refresh token (encrypted, server side), keyed by your user
7. Call https://api.rocketverifier.com/v1/... with Authorization: Bearer rv_at_...
8. On 401 invalid_token: refresh once, retry once, persist the NEW refresh token
```

### 4.1 Authorize request

Redirect the browser to:

```
https://app.rocketverifier.com/oauth/authorize
  ?response_type=code
  &client_id=rvapp_...
  &redirect_uri=https%3A%2F%2Fyourapp.com%2Frocketverifier%2Fcallback
  &scope=verify%3Abulk%20jobs%3Aread%20account%3Aread
  &state=<random, at least 16 bytes, base64url>
  &code_challenge=<base64url(sha256(code_verifier))>
  &code_challenge_method=S256
```

Optional parameters: `resource=https://api.rocketverifier.com` (RFC 8707), `prompt=consent` (always show the consent screen).

`code_verifier`: 32 random bytes, base64url without padding (43 characters).

### 4.2 Callback

Success: `GET {redirect_uri}?code=...&state=...`

Error: `GET {redirect_uri}?error=access_denied&state=...` when the user clicks Cancel. Other values: `invalid_scope`, `invalid_request`, `server_error`. Show a friendly message and the Connect button again.

Before anything else: compare `state` with the stored value in constant time. Reject on mismatch. Delete the stored state so it cannot be replayed.

A wrong `client_id` or an unregistered `redirect_uri` never redirects: the user sees an error page on RocketVerifier. Fix the app registration.

### 4.3 Token exchange

```bash
curl -X POST https://app.rocketverifier.com/api/oauth/token \
  -u "$RV_CLIENT_ID:$RV_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$RV_REDIRECT_URI" \
  -d code_verifier="$CODE_VERIFIER"
```

Body is `application/x-www-form-urlencoded`. Client authentication, pick one:
- `client_secret_basic`: HTTP Basic with client id and secret (default, shown above)
- `client_secret_post`: `client_id` and `client_secret` form fields
- `none`: public clients (mobile, desktop, CLI, single page apps) send only `client_id` and must use PKCE. Never put a client secret in code that ships to a browser or device.

Response:

```json
{
  "access_token": "rv_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "rv_rt_...",
  "scope": "verify:bulk jobs:read account:read"
}
```

The granted `scope` can be narrower than requested. Check it before showing features.

### 4.4 Refresh

```bash
curl -X POST https://app.rocketverifier.com/api/oauth/token \
  -u "$RV_CLIENT_ID:$RV_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"
```

The response has a new access token AND a new refresh token. The old refresh token stops working immediately. Presenting an old refresh token again is treated as theft and revokes the whole connection. Rules:
- Save the new refresh token before using the new access token.
- Allow only one refresh at a time per connection (a lock or a single-flight promise).
- Refresh lazily on `401 invalid_token`, or proactively a minute before `expires_in` runs out.

### 4.5 Token errors (RFC 6749)

```json
{ "error": "invalid_grant", "error_description": "Refresh token was already used" }
```

| `error` | Meaning | What to do |
|---|---|---|
| `invalid_grant` | Code expired, reused or for another redirect URI; refresh token expired, revoked or reused | Code: restart the flow. Refresh: mark the connection as disconnected and show Connect again |
| `invalid_client` | Wrong client id or secret | Configuration bug, alert the developer |
| `invalid_request` | Missing or malformed parameter (for example a PKCE mismatch) | Bug, fix the request |
| `unauthorized_client` | App suspended or not allowed this grant type | Contact support |
| `unsupported_grant_type` | Only `authorization_code` and `refresh_token` exist | Bug |
| `invalid_scope` | Scope not in the app's allowed scopes | Update the app or the request |

### 4.6 Revoke

When the user clicks "Disconnect" in your app, or deletes their account in your app:

```bash
curl -X POST https://app.rocketverifier.com/api/oauth/revoke \
  -u "$RV_CLIENT_ID:$RV_CLIENT_SECRET" \
  -d token="$REFRESH_TOKEN" -d token_type_hint=refresh_token
```

Revoking the refresh token ends the whole connection. Then delete the stored tokens.

## 5. Calling the API with a token

Same header as an API key:

```bash
curl https://api.rocketverifier.com/v1/me -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "email": "jane@acme.com",
  "name": "Jane Doe",
  "account_type": "organization",
  "organization": { "id": "org_8f2a", "name": "Acme Inc" },
  "credits": 12400,
  "auth_type": "oauth",
  "scopes": ["verify:bulk", "jobs:read", "account:read"],
  "app": { "client_id": "rvapp_...", "name": "Your App" }
}
```

Show it in your settings page as: "Connected as jane@acme.com (Acme Inc). 12,400 credits."

Billing: every verification is charged to the connecting user, or to their organization when they connected an organization account. The user may set a monthly credit cap for your app; once reached, calls return `402`.

OAuth tokens are always live. There is no sandbox token; test against your own RocketVerifier account and its credits.

## 6. Endpoints you will use

All responses are JSON. Writes accept an `Idempotency-Key` header (any string up to 255 characters); send a UUID per logical operation so retries never double-charge. The SDKs do this for you.

### Verify one email: `POST /v1/verify` (scope `verify:single`, 1 credit)

```bash
curl -X POST https://api.rocketverifier.com/v1/verify \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"email":"user@example.com"}'
```

Key response fields: `status` (`deliverable`, `undeliverable`, `risky`, `unknown`), `sub_status` (reason, for example `mailbox_not_found`, `disposable`, `catch_all`, `role_based`), `score` (0 to 100), `catch_all_verdict` (`send_safe`, `send_with_caution`, `avoid`, or null), `domain.disposable`, `account.role`.

Suggested UI mapping: `deliverable` green "Valid", `risky` amber "Risky", `undeliverable` red "Invalid", `unknown` grey "Unknown".

### Bulk: `POST /v1/verify/bulk` (scope `verify:bulk`, 1 credit per unique email)

```json
{ "emails": ["a@example.com", "b@example.com"], "name": "Contacts 2026-11-01" }
```

Up to 100,000 emails per job. Response contains `job_id`. Then:
- `GET /v1/jobs/{job_id}/progress` (scope `jobs:read`): poll every 5 to 10 seconds, or
- register a webhook for `job.completed` (scope `webhooks:manage`) instead of polling.
- `GET /v1/jobs/{job_id}/results?page=1&limit=1000` (scope `jobs:read`): paginated per-email results with `email`, `classification` (`valid`, `invalid`, `risky`, `catch_all`, `disposable`, `unknown`), `sub_status`, `confidence_score`, `is_valid`.

### Balance: `GET /v1/credits` (scope `credits:read`)

### Account: `GET /v1/me` (scope `account:read`), see section 5.

Full reference: `https://docs.rocketverifier.com/api` and OpenAPI at `https://api.rocketverifier.com/openapi.json`.

## 7. API errors

Every API error has this shape:

```json
{ "error": { "code": "insufficient_credits", "message": "...", "details": { } } }
```

| Status | `code` | What to do |
|---|---|---|
| 401 | `invalid_token` (header `WWW-Authenticate: Bearer error="invalid_token"`) | Refresh once and retry once. If refresh returns `invalid_grant`, mark disconnected and show Connect |
| 401 | `unauthorized` | Credential missing or malformed. Bug |
| 402 | `insufficient_credits` or cap reached | Tell the user: "Your RocketVerifier account is out of credits." Link `https://app.rocketverifier.com/dashboard/billing` |
| 403 | `insufficient_scope` | Your token lacks a scope. Ask the user to reconnect with the needed scope |
| 429 | `rate_limited` | Wait `Retry-After` seconds, retry with the same `Idempotency-Key` |
| 5xx | any | Retry with backoff, same `Idempotency-Key` |

## 8. The `grant.revoked` webhook

When a user disconnects your app inside RocketVerifier (Settings, Connected apps) or a refresh token is reused, RocketVerifier POSTs to the app's webhook URL:

```json
{
  "event": "grant.revoked",
  "event_id": "evt_...",
  "timestamp": "2026-11-01T12:00:00Z",
  "data": {
    "grant_id": "...",
    "client_id": "rvapp_...",
    "user_email": "jane@acme.com",
    "account_type": "personal",
    "organization_id": null,
    "reason": "user_disconnected"
  }
}
```

Verify it before trusting it. Header `X-RocketVerifier-Signature: t=<unix seconds>,v1=<hex>` where `v1 = hex(HMAC_SHA256(app webhook secret, t + "." + raw_body))`. Compare in constant time, reject timestamps older than 5 minutes, and de-duplicate on `event_id`. The SDKs ship `verifyWebhookSignature` (Node) and `verify_webhook_signature` (Python). On a valid event, delete the stored tokens for that connection and show it as disconnected.

## 9. MCP clients (Claude, ChatGPT, custom agents)

The hosted MCP server is `https://api.rocketverifier.com/mcp`. Without credentials it answers `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.rocketverifier.com/.well-known/oauth-protected-resource"`. An MCP client then:
1. reads the protected resource metadata (RFC 9728), which names the issuer `https://app.rocketverifier.com`;
2. reads `https://app.rocketverifier.com/.well-known/oauth-authorization-server` (RFC 8414);
3. registers itself at `POST https://app.rocketverifier.com/api/oauth/register` (RFC 7591, public client, https or loopback redirect URIs);
4. runs the authorization code flow with PKCE and `resource=https://api.rocketverifier.com`;
5. calls `/mcp` with `Authorization: Bearer rv_at_...`.

End users only paste the MCP URL into their client and click Allow. API key Bearer auth on `/mcp` still works.

## 10. Reference implementation (Node.js, Express style)

```ts
import RocketVerifier, { OAuth, OAuthError, InvalidTokenError, type OAuthTokenSet } from '@rocketverifier/sdk';

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

// GET /rocketverifier/connect
app.get('/rocketverifier/connect', (req, res) => {
  const { url, state, codeVerifier } = oauth.getAuthorizeUrl({
    scopes: ['verify:bulk', 'jobs:read', 'account:read'],
  });
  req.session.rvOauth = { state, codeVerifier }; // server side session or signed httpOnly cookie
  res.redirect(url);
});

// GET /rocketverifier/callback
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');
  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 });
  await db.rocketverifierConnections.upsert(req.user.id, encrypt(tokens));
  res.redirect('/settings?rv=connected');
});

// A client per request, with automatic refresh that persists rotated tokens.
async function rvFor(userId: string) {
  const conn = await db.rocketverifierConnections.get(userId);
  if (!conn) return null;
  const tokens: OAuthTokenSet = decrypt(conn);
  return new RocketVerifier({
    accessToken: tokens.accessToken,
    refreshToken: tokens.refreshToken ?? undefined,
    oauth,
    onTokenRefresh: (next) => db.rocketverifierConnections.upsert(userId, encrypt(next)),
  });
}

// POST /contacts/verify
app.post('/contacts/verify', async (req, res) => {
  const rv = await rvFor(req.user.id);
  if (!rv) return res.status(409).json({ error: 'connect_rocketverifier' });
  try {
    const emails = await db.contacts.emailsFor(req.user.id);
    const job = await rv.batch.create({ emails, name: 'Contacts' });
    res.json({ jobId: job.id });
  } catch (err) {
    if ((err instanceof OAuthError && err.error === 'invalid_grant') || err instanceof InvalidTokenError) {
      await db.rocketverifierConnections.delete(req.user.id);
      return res.status(409).json({ error: 'connect_rocketverifier' });
    }
    throw err;
  }
});
```

Python equivalent: `from rocketverifier import RocketVerifier, OAuth`; `oauth.get_authorize_url(scopes=[...])` returns `.url`, `.state`, `.code_verifier`; `oauth.exchange_code(code=..., code_verifier=...)`; `RocketVerifier(access_token=..., refresh_token=..., oauth=oauth, on_token_refresh=save)`; `rv.get_me()`; `AsyncRocketVerifier` takes the same arguments.

A complete runnable example lives in the RocketVerifier SDKs repository at `examples/connect-nextjs` (Next.js, Connect button, callback, encrypted cookie token storage, refresh, bulk verify).

## 11. UI and copy

Button: use the official assets, do not redraw them.
- Light backgrounds: `https://docs.rocketverifier.com/brand/connect-rocketverifier-light.svg`
- Dark backgrounds: `https://docs.rocketverifier.com/brand/connect-rocketverifier-dark.svg`

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

Copy that works:
- Before connecting: "Verify your contacts with RocketVerifier. Verifications use your RocketVerifier credits."
- Connected: "Connected as jane@acme.com. 12,400 credits." with a "Disconnect" link.
- Out of credits: "Your RocketVerifier account is out of credits. Add credits to keep verifying."
- Disconnected (invalid_grant or grant.revoked): "RocketVerifier was disconnected. Connect again to keep verifying."

## 12. Security checklist

- [ ] `state` generated per request, stored server side or in a signed httpOnly cookie, compared in constant time, single use
- [ ] PKCE S256 on every authorization request
- [ ] Redirect URIs registered exactly; no open redirects on your callback
- [ ] Client secret only on the server, from an environment variable, never in a browser bundle or mobile binary
- [ ] Tokens stored server side and encrypted at rest; never in `localStorage`
- [ ] Rotated refresh token saved before use; one refresh at a time per connection
- [ ] `invalid_grant` on refresh and `grant.revoked` both mark the connection as disconnected
- [ ] `grant.revoked` signature verified on the raw body with a 5 minute tolerance
- [ ] Fewest scopes possible; granted `scope` checked after the exchange
- [ ] Tokens revoked when the user disconnects or deletes their account in your app
- [ ] `Idempotency-Key` on every write

## 13. Done when

1. Clicking Connect shows the RocketVerifier consent screen with your app name and the scopes you asked for.
2. After Allow, your settings page shows "Connected as <email>" from `GET /v1/me`.
3. Verifying contacts charges the connected account (check its credits).
4. Forcing an access token to expire (or waiting an hour) refreshes transparently and stores the new refresh token.
5. Disconnecting inside RocketVerifier (Settings, Connected apps) makes your app show "Disconnected" via `grant.revoked` or the next `invalid_grant`.

Docs: `https://docs.rocketverifier.com/oauth`. Support: `support@rocketverifier.com`.
