Build a waiting list with double opt-in on your own Cloudflare account

By

Deploy Waiting Lists on your Cloudflare account, connect a form that posts email and consent, and confirm subscribers with double opt-in.

~~~

You want a waiting list on a page you already designed, with double opt-in, running in your own Cloudflare account. The form stays yours. Something else has to store the address, send the confirmation, and refuse to treat a person as confirmed until they meant to confirm.

Waiting Lists is that piece. You deploy the source yourself, so the Worker, the database, the email setup and the subscriber rows live in your Cloudflare account. The form on waitinglists.dev already uses this same software. That site is the reference installation, not a list I host for you.

This tutorial deploys it and connects the form. Two posts cover the internals I won’t rebuild here. Build scanner-safe double opt-in is why opening the email link must not confirm the address. Build idempotent double opt-in signup is why a second submit must not create a second subscriber or a second email.

What you need

You need Node.js 22.12 or newer, and a Cloudflare account. A production install uses Workers, D1, Queues, Email Service and Turnstile, on a domain you control. Cloudflare and domain charges are separate from the software.

After purchasing Waiting Lists, connect GitHub in Polar, accept the invitation to the private repository, and clone it, or download the source from Releases. Open Terminal in that folder:

npm ci
npm run check
npm test

The repository README is the Configuration and Deployment reference for the individual resource settings. Start there when the names in wrangler.jsonc don’t match your account yet.

Run it locally before you deploy

Copy the settings template and fill in your own values:

cp .dev.vars.example .dev.vars

Set the admin username and ADMIN_PASSWORD_HASH. That hash is the lowercase SHA-256 digest of a strong password. Use two different random secrets, at least 32 bytes each, for SESSION_SECRET and SUBSCRIBER_TOKEN_SECRET. Add your Turnstile keys and the hostnames allowed to open the login page.

Leave .dev.vars out of Git. It holds credentials. Then prepare the local database and start the app:

npm run db:migrate:local
npm run dev

Open the URL Astro prints and visit /login. You should get the admin sign-in. Local mode checks rendering and the database. It does not send a real confirmation email. That needs Email Service on the deployed Worker, which I cover in How to send email with Cloudflare Email Service.

Deploy it to your account

In wrangler.jsonc, replace the placeholders with your resource names and IDs. Set APP_URL to the service’s HTTPS origin. Set EMAIL_FROM, and the allowed sender binding, to the sender address you onboarded in Email Service. Put your production hostname in TURNSTILE_HOSTNAMES.

The default source still carries waitinglists.dev branding. Change the product name, the consent-version identifier, the email branding and the /privacy notice before you send a real confirmation.

Store these as Worker secrets. Don’t put them in the public vars section:

ADMIN_USERNAME
ADMIN_PASSWORD_HASH
SESSION_SECRET
SUBSCRIBER_TOKEN_SECRET
TURNSTILE_SECRET

Apply the remote database migrations and deploy:

npm run db:migrate:remote
npm run deploy

You can instead connect the repository to Cloudflare Workers Builds and use the build and deploy commands from the README. Take a D1 backup before a migration or an upgrade.

Sign in at /login on the hostname you deployed. Turnstile on this app protects that private login. The public signup endpoint is separate. It checks the origin and applies rate limits. It accepts email and consent, and it doesn’t accept a name or a profile.

Connect your form

Create a list in the dashboard. I’ll call the example Garden Planner, on a fictional site at https://garden.northstar.example. Use your product name and your real origin.

Add every origin that will post addresses. An origin is the scheme and the hostname, and the port when the site has one. www and the bare hostname are different origins. Add each one people actually use.

Open the list and copy the HTML or JavaScript it generates. The public endpoint accepts exactly two subscriber fields, email and consent. The reference form looks like this. Replace the action with the endpoint your list gives you:

<form
  method="post"
  action="https://waitinglists.dev/api/subscribe/my-product"
>
  <input type="email" name="email" required />
  <label>
    <input type="checkbox" name="consent" value="yes" required />
    I want updates about this product.
  </label>
  <button>Join the waitlist</button>
</form>

Write the consent line so it says what you will send, and link your privacy notice. Don’t put an administrator API key in this form.

Put the form on a page whose origin is in the list settings. Submit an address you control.

You should get a generic accepted response. You get the same response when that address was already confirmed. The endpoint doesn’t say whether the address is new, and it doesn’t promise the message has been delivered.

Double opt-in after you deploy Waiting ListsDeploy Waiting Lists to your Cloudflare account, create a list, allow the site origin, and post email plus consent. The address stays pending until the person opens the confirmation page and presses the button. A visit that only loads the link does not confirm them.NoYesDeploy the WorkerCreate a listAllow the siteoriginForm posts email andconsentAddress stayspendingConfirmation emailOpen the linkPressed Confirm myplace?Still pendingConfirmed subscriber

Confirm one address yourself

Open the confirmation email and follow the link. The page has a Confirm my place button. The address is confirmed only after that button sends a POST.

A scanner that only opens the link loads the page and stops there. The token behind that link is covered in the scanner-safe post. I won’t repeat the hashing here.

The link expires after 24 hours. If the address is already confirmed, it stays confirmed. If it is still pending, submitting the form again does not send a new message every time. That resend rule is the idempotent signup post.

Only then should the address show as confirmed and be included in a normal export. The dashboard also shows pending counts, and delivery states such as deferred, bounced, rejected or failed. You can resend a failed confirmation with a fresh link. Test the removal link in the same email. It deletes the address with no account.

When the product is ready, export a consent archive or a file prepared for Sendy, Kit or Mailchimp. From the project, after the CLI has a key:

npm run waitinglists -- export --format universal --output garden-planner-subscribers.csv

archive, sendy, kit and mailchimp are the other formats. The command refuses to overwrite an existing file, and it creates the new one so only your user can read it.

The key is created at /admin/agent and shown once. On a Mac, pass it from the clipboard so it doesn’t land in shell history:

pbpaste | npm run waitinglists -- configure --key-stdin --api-url https://lists.northstar.example/api/v1

Replace that URL with your service. Omit --api-url when you’re talking to the local server. npm run waitinglists -- init creates a new list, so if Garden Planner already exists, list them first with npm run waitinglists -- lists and use integration with that list’s id.

If something looks wrong

If the form reports an origin error, the page’s exact origin is missing from the list. Add it again, including www if that’s the host in the address bar.

If no confirmation email arrives, check Email Service, EMAIL_FROM and the delivery status in the dashboard. A bounce or a rejection is a delivery result. npm run dev on your laptop was never going to send that message.

If the private API returns 401, the key is invalid or revoked. Create another one at /admin/agent.

If the CLI says no list ID was supplied, this directory isn’t linked. Pass a list id, or run the command from the folder that has .waitinglists.json. Credentials for the CLI normally live in ~/.config/waitinglists/credentials.json, outside the repository.

If someone signed up and a normal export doesn’t include them, they may still be pending. Pending requests are deleted after the confirmation link expires. Confirmed records in the included implementation are deleted after 24 months. If you change that schedule, keep the cleanup job and the privacy notice saying the same thing. Treat the CSV and any D1 backup as private subscriber data.

Get Waiting Lists

Collect confirmed subscribers with your own forms, dashboard and agent CLI. Includes full MIT source for deployment in your Cloudflare account.

See Waiting Lists → · $20 including tax, with full source code.

More tutorials for my apps →

Want me to talk about your product? You can sponsor this site.