Sync & Settings

Stripe connection, sync health, and owner access

Stripe not connected

Add the Stripe secret and webhook signing secret in More → Cloud → Secrets, then run the initial import below. The server functions, database schema, and this UI are already wired for live data.

Sync status
Stripe is the source of truth; the database is a synchronized reporting copy.
Last full reconciliation

—

Last webhook update

—

Sync state

—

Account / mode

—

Webhook updates are near real time, with hourly reconciliation as a fallback. The hourly job runs server-side even when no browser is open.

Stripe connection
Read-only, restricted credential. Never create, edit, or charge in Stripe.
Secret name (server-side)STRIPE_SECRET_KEY
Webhook signing secretSTRIPE_WEBHOOK_SECRET
Webhook endpointPOST /api/stripe-webhook (implemented)
Required eventscustomer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.paused, customer.subscription.resumed, customer.updated, customer.deleted, product.created, product.updated, product.deleted, price.created, price.updated, price.deleted, customer.discount.created, customer.discount.updated, customer.discount.deleted, invoice.payment_succeeded, invoice.payment_failed
API versionPinned to the project's Stripe API version (see Stripe SDK)
The webhook verifies Stripe signatures against the raw request body, validates the account and live/test mode, persists events durably before acknowledging, deduplicates by event id, processes changes with an updated_at guard against out-of-order delivery, and requires no owner session. Credentials live only in server-side secret storage.
Initial import
Owner-only. Imports every page of subscriptions (all statuses), all subscription items, and required customer / product / price / discount data.

The import is idempotent (reruns upsert by Stripe ID, no duplicates), paginates within runtime limits, and handles rate limits with bounded retries. A partial import is never marked successful — the sync state stays failed until a full run completes.

Requires STRIPE_SECRET_KEY to be configured first.
Hourly reconciliation
A real server-side scheduled job, not a browser timer.

The endpoint POST /api/sync is implemented and protected by a CRON_SECRET bearer token. It reuses the real full-import implementation, prevents overlapping runs via a durable lease, paginates within runtime limits, handles rate limits with bounded backoff, never marks a partial import successful, and records start/completion/counts/failures in sync_jobs.

Scheduler setup

This project deploys to a Cloudflare Workers runtime. Configure an hourly Cron Trigger that sends an authenticated request to the endpoint — for example a worker scheduled handler or an external scheduler (Google Cloud Scheduler / cron-job.org) issuing:

curl -X POST https://<your-domain>/api/sync \
  -H "Authorization: Bearer $CRON_SECRET"

Add CRON_SECRET in More → Cloud → Secrets (a long random string). The secret is never placed in a URL or in frontend code.

Owner access
Only approved owners can view data. Owners cannot self-approve or edit their own role.

No owners

Bootstrap the first owner below.

Approve a new owner

Approving an owner requires that person to have already signed up at the sign-in page (email/password). No self-service sign-up grants dashboard access.

First-owner bootstrap (one-time)

Until at least one owner exists, no one can view data or approve owners. To create the very first owner:

  1. Sign up at the sign-in page using your owner email and password (email/password auth).
  2. Open More → Cloud in this builder and run the one-time bootstrap SQL below against the database (it inserts your user as the first owner). Replace the email with the exact one you signed up with.
  3. Sign in again — you will now have owner access and can approve additional owners from this page.
-- Run once in More → Cloud (SQL editor) to bootstrap the first owner.
-- Replace the email with the address you signed up with.
INSERT INTO public.owners (user_id, email, approved_by, approved_at)
SELECT u.id, u.email, NULL, now()
FROM auth.users u
WHERE lower(u.email) = lower('YOUR_OWNER_EMAIL@example.com')
ON CONFLICT (user_id) DO NOTHING;

Only the very first owner is created this way. Every subsequent owner is approved by an existing owner from this page — users can never approve themselves.