Gmail Waitlist
Overview
Build a complete email waitlist that sends a Gmail notification whenever someone signs up. The entire stack runs at zero cost using GCP OAuth, a Vercel serverless function, and the Gmail API. No database required — every signup arrives as an email in your inbox.
What you get:
api/waitlist.js— Vercel serverless function (receives email, sends you a Gmail notification)- Frontend form handler — async submit with loading/error/success states
- Gmail as the "database" — search, label, and export signups using Gmail's built-in tools
Total cost: $0/month for typical waitlist volumes (Vercel free tier + Gmail API free tier).
Prerequisites
Verify these are installed before proceeding:
- Google account with Gmail
- Node.js 18+ (
node --version) - gcloud CLI (
gcloud --version; install withbrew install google-cloud-sdkon macOS) - Vercel CLI (
vercel --version; install withnpm i -g vercel) - gws CLI (
npx @googleworkspace/cli --version; install withnpm i -g @googleworkspace/cli)
Phase 1: GCP Project Setup
Create a Google Cloud project, enable the Gmail API, and configure OAuth credentials.
Inputs: Google account, desired GCP project ID Outputs: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET → save for Phase 2
Detailed walkthrough: See
references/gcp-setup.md
Steps
Create a GCP project and enable the Gmail API:
gcloud projects create YOUR_PROJECT_ID --name="Your Project Name" gcloud config set project YOUR_PROJECT_ID gcloud services enable gmail.googleapis.comConfigure the OAuth consent screen at the Google Cloud Console:
- Select External user type
- Fill in the app name, user support email, and developer email
- Add the scope
https://www.googleapis.com/auth/gmail.send - Add your Gmail address as a test user
Create OAuth client credentials:
- Navigate to Credentials → Create Credentials → OAuth client ID
- CRITICAL: Select "Desktop app" as the application type
- Name it (e.g., "Waitlist CLI")
- Copy the Client ID and Client Secret
Why Desktop App, not Web? The
gwsCLI opens a temporary local HTTP server on a random port to receive the OAuth callback. Web application credentials require pre-registered redirect URIs, which causes aredirect_uri_mismatcherror. Desktop app credentials accept anylocalhostredirect.
- Publish the app or add test users:
- Go to OAuth consent screen → click Publish App
- If you skip publishing, refresh tokens expire after 7 days and all users must be listed as test users
- Publishing removes the 7-day expiry and the test user requirement
Verify Phase 1:
Run gcloud services list --enabled --filter="config.name:gmail.googleapis.com" — expect gmail.googleapis.com in output. If missing, re-run gcloud services enable gmail.googleapis.com and check the active project with gcloud config get-value project.
Phase 2: gws CLI Authentication
Authenticate with the gws CLI to obtain a refresh token for the Gmail API.
Inputs: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET from Phase 1 Outputs: GMAIL_REFRESH_TOKEN → save for Phase 5
Detailed walkthrough: See
references/gws-auth.md
Steps
Configure
gwswith your OAuth credentials:gws auth configure \ --client-id YOUR_CLIENT_ID \ --client-secret YOUR_CLIENT_SECRETLog in with the
gmail.sendscope:gws auth login --scope gmail.sendA browser window opens. Sign in with your Gmail account and grant the
gmail.sendpermission.Export the refresh token:
gws auth exportCopy the
refresh_tokenvalue. You need this for Vercel environment variables.If
gws auth exportmasks the token value, use the extraction script:node scripts/extract-refresh-token.jsThis decrypts the credentials stored at:
- macOS:
~/Library/Application Support/gws/credentials.enc - Linux:
~/.config/gws/credentials.enc
- macOS:
Verify Phase 2:
Run gws auth export — expect a JSON object containing refresh_token. If the value is masked, run node scripts/extract-refresh-token.js and confirm a token string is printed. If both fail, re-run gws auth login --scope gmail.send.
Phase 3: Build the Backend
Create a Vercel serverless function that receives a signup email address and sends you a Gmail notification.
Inputs: Project directory, your Gmail address, product name
Outputs: api/waitlist.js, vercel.json, (optional) server.js
API format details: See
references/gmail-api.mdDeployment notes: Seereferences/vercel-deploy.md
Steps
Set up the project structure:
mkdir -p apiCreate
api/waitlist.js. Useexamples/waitlist.jsas a starting point and customize:- Replace
YOUR_EMAIL@gmail.comwith your Gmail address - Replace
[Your App]with your product name in the subject line - Adjust the timezone string if needed (default:
Asia/Shanghai)
- Replace
Create
vercel.jsonin the project root:{ "rewrites": [ { "source": "/api/waitlist", "destination": "/api/waitlist" } ] }For local development, create
server.jsusingexamples/server.jsand run:node server.jsThe local server mirrors the Vercel function behavior on
http://localhost:3000.
How the Serverless Function Works
Each request follows this flow:
- Validate — Check for a valid email in the POST body
- Token exchange — Send the refresh token to
https://oauth2.googleapis.com/tokento get a short-lived access token - Build email — Construct an RFC 2822 message, encode as base64url
- Send — POST the encoded message to
https://gmail.googleapis.com/gmail/v1/users/me/messages/send - Respond — Return
{"success": true}or an error with the appropriate HTTP status
No access token is ever stored. Each request exchanges the refresh token for a fresh access token, which is discarded after use.
Verify Phase 3:
Run node server.js and in another terminal: curl -X POST http://localhost:3000/api/waitlist -H "Content-Type: application/json" -d '{"email":"test@example.com"}' — expect {"success":true}. If ECONNREFUSED, the server is not running. If Gmail send fails, verify gws auth login succeeded in Phase 2.
Phase 4: Build the Frontend
Add a signup form to your landing page that submits to the waitlist API.
Inputs: Landing page HTML file, desired form styling Outputs: Form HTML + JavaScript handler integrated into the landing page
Steps
Add HTML for the form and success state:
<form id="waitlist-form" false"> <input type="email" id="email" placeholder="you@example.com" required /> <button type="button" Waitlist</button> </form> <div id="success" style="display: none">Thanks! You're on the list.</div>Add the JavaScript form handler. Use
examples/form-handler.jsas a reference. The handler must:- Validate the email client-side before sending
- Disable the button and show a loading label (e.g., "Joining...")
- POST
{ email }as JSON to/api/waitlist - On success: hide the form and show the success message
- On error: display an error message and re-enable the button
- Clear error styling when the user edits the input
Adapt the styling and UX to match your landing page design.
Verify Phase 4: Open the landing page in a browser, submit a test email, and confirm: (1) button shows "Joining..." during submit, (2) success message appears after submit, (3) no console errors in browser devtools. If the form submits but nothing happens, check the Network tab for the API response.
Phase 5: Deploy to Vercel
Set environment variables and deploy.
Inputs: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN from Phases 1–2 Outputs: Production URL serving the waitlist API
Full checklist: See
references/vercel-deploy.md
Steps
Add environment variables using
printf(notecho):printf '%s' 'your-client-id' | vercel env add GMAIL_CLIENT_ID production printf '%s' 'your-client-secret' | vercel env add GMAIL_CLIENT_SECRET production printf '%s' 'your-refresh-token' | vercel env add GMAIL_REFRESH_TOKEN productionCRITICAL: Use
printf '%s', notecho.echoappends a trailing newline character that corrupts OAuth tokens and causes silent authentication failures.printf '%s'outputs the exact string with no trailing characters.Deploy to production:
vercel --prodVerify the deployment:
curl -X POST https://your-project.vercel.app/api/waitlist \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com"}'Expected response:
{"success": true}Confirm the notification email arrived in your Gmail inbox.
Verify Phase 5:
Run the curl command above against your production URL — expect {"success":true} and an email in your inbox within 30 seconds. If you get 500, run vercel env ls to confirm all three env vars exist. If invalid_grant, the refresh token may be corrupted by echo — remove and re-add with printf '%s'.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
redirect_uri_mismatch |
OAuth client type is "Web application" | Delete and recreate as Desktop app |
access_denied |
App not published; email not in test users | Publish the app OR add email as test user |
invalid_grant |
Refresh token expired (7-day limit in testing mode) | Publish the app, then re-run gws auth login |
Failed to get access token |
Client ID or secret is wrong in env vars | Run vercel env ls to check; re-add if needed |
| Token value corrupted | Used echo instead of printf to set env var |
Remove env var, re-add with printf '%s' |
401 Unauthorized from Gmail API |
Access token exchange failed | Verify all three env vars match GCP credentials |
| Form submits but no email arrives | CORS blocking or wrong API URL | Check browser devtools console for errors |
gws auth export shows masked values |
CLI redacts sensitive fields | Use scripts/extract-refresh-token.js |
ECONNREFUSED in local dev |
Server not running or wrong port | Start with node server.js; default port is 3000 |
PERMISSION_DENIED from Gmail API |
Token lacks gmail.send scope |
Re-run gws auth login --scope gmail.send |
Project Structure
After completing all phases, your project contains:
your-project/
├── api/
│ └── waitlist.js # Vercel serverless function
├── vercel.json # Route rewrites
├── index.html # Landing page with signup form
├── server.js # (optional) Local dev server
└── package.json # (optional)