Unbounded

Wiki

How the pieces fit together — scripts, crons, Stripe, emails and who calls what.

Nothing in the wiki matches that.

01 Outside systems

Nine things outside this repo. If something misbehaves, it is nearly always one of these, and the table says which files are the ones talking to it.

SystemWhat it does for usWhere we talk to it
Vercel Hosts the static pages (checkout.html, success.html, /admin/*) and every serverless function under /api. Routing lives in vercel.json. The whole repo. Deploys on push: main → live, test → test.
Stripe Takes the money. One-off payment intents for the initial purchase, then an annual subscription for renewals. Sends webhooks back to us. api/lib/stripe.js, api/create-tier-payment.js, api/tier-stripe-webhook.js, api/lib/subscription-lifecycle.js, both cron endpoints, api/lib/refund-service.js. Full list in §05.
Supabase The database (accounts, orders, services, SIMs, refunds), file storage (shipping CSVs, eSIM QR codes) and the auth users behind admin passkeys. api/lib/supabase.js — every server file gets its client from here, using the service-role key. RLS blocks everything else.
Resend Sends all customer email. api/lib/email-transport.js (via api/lib/email.js), plus api/contact.js which uses the Resend SDK directly.
Slack Order notifications and alerts. Three separate mechanisms — see §09. api/lib/refund-service.js, api/midday-verification-cancelled-check.js, and the admin order actions (ship, refund, return).
Google Workspace The support mailbox. OAuth into Gmail so the portal’s Support tab can read and reply to support@ / hi@. api/lib/gmail.js and api/admin/gmail/*.
Mobile Manager / Roamit The SIM supplier. We order eSIMs from their API when stock runs low. api/esim-inventory.js — the only file that knows about them. Auto-ordering is currently switched off (see §03).
Evri Ships routers and takes returns. No API — we generate CSV rows they consume, and paste tracking numbers back in by hand. Return CSVs are built in api/lib/refund-service.js. Outbound parcels carry a tracking number typed on the order page — routers go Evri, SIMs Royal Mail Tracked, and the despatch email links to whichever it was.
GitHub Actions Runs the cancellation, verification, inventory and test keepalive schedules. SIM activation uses Vercel Cron instead. Three workflows plus the keepalive workflow, all detailed in §03.

02 Domains & environments

URLServesBranch / data
go-unbounded.co.ukRedirects to www.main
www.go-unbounded.co.ukThe marketing site, served from this repo. A launch gate currently shows coming-soon.html in its place — see docs/launch-gate.md.main
www.go-unbounded.co.uk/checkoutRewrites to checkout.html, the Signal/Flow/Capture order builder. Shut to the public until provisioning is wired — both /checkout and /checkout.html are preview-cookie only while the gate is up.main
admin.go-unbounded.co.uk/admin/index.html — this portal.main · live customer data
admin-test.go-unbounded.co.ukSame portal, pinned to the test branch.test · separate Supabase project, no real data
account.go-unbounded.co.ukThe customer portal. Same repo (portal/) and same Supabase project, but a separate Vercel project — this one does not serve it, it only links to it via PORTAL_URL. See docs/monorepo.md.main · live customer data
Same Supabase Auth, two front doors. Admins and customers are users in the same Supabase project. Admin access is only the ADMIN_EMAILS allowlist on top of a valid session — a project-wide Auth setting (e.g. turning off email/password sign-in) affects customers too. Long-form notes are in api/SECURITY.md.

03 Scheduled jobs (crons)

Most scheduling is GitHub Actions: each workflow curls an endpoint with the x-audit-key header. SIM activation is the exception — Vercel calls it directly from the schedule in vercel.json and authenticates with CRON_SECRET. In both cases the endpoint holds the logic and the scheduler holds only the clock.

Workflow fileCronCallsWhat it actually does
daily-checks.yml
job: cancellation-check
0 1 * * *
01:00 UTC daily
POST /api/daily-cancellation-check Finds services whose cancelled_at lands tomorrow and that still have a live Stripe subscription, then cancels the subscription pre-emptively so nobody gets charged the morning they leave. Cleans up rows whose subscription no longer exists in Stripe. Processes in batches of 10.
daily-checks.yml
job: verification-check
0 12 * * *
12:00 UTC daily
POST /api/midday-verification-cancelled-check The safety net for the 01:00 job. Re-checks the same set against Stripe; anything still active gets an emergency cancel, and a Slack alert fires. This is the job that shouts — if you get a “URGENT: found N active subscriptions” message, it came from here.
esim-inventory-check.yml 0 */6 * * *
00:00, 06:00, 12:00, 18:00 UTC
POST /api/esim-inventory Counts sim rows that are available + esim; if at or below the threshold (1), orders one more from Mobile Manager and inserts it. Currently a no-op: AUTO_ORDER_ENABLED is false inside api/esim-inventory.js, so it logs “auto-ordering disabled” and stops. Flip that constant to turn ordering back on (each order costs ~£1).
supabase-keepalive-test.yml 0 5 */3 * *
05:00 UTC every 3rd day
Supabase REST directly (no endpoint of ours) Pings the test Supabase project so free-tier auto-pause (~7 days idle) never kicks in. Production stays warm from the jobs above. Uses TEST_SUPABASE_URL / TEST_SUPABASE_ANON_KEY repo secrets.
vercel.json
Vercel Cron
15 * * * *
15 minutes past every hour
GET /api/process-due-activations Finds paid services whose assigned SIM has reached its activation deadline, then starts Stripe renewal billing and marks the service active in Supabase. Processes up to 100 in batches of 10. A partial failure returns HTTP 500, logs each service ID, and remains visible for retry on the Activations page.

Reading a cron expression

Five fields: minute hour day-of-month month day-of-week, always UTC (so in British Summer Time everything runs an hour later than the number suggests).

  • 0 1 * * * — minute 0 of hour 1, every day.
  • 0 */6 * * * — minute 0, every 6th hour.
  • 0 5 */3 * * — 05:00, every 3rd day of the month.
  • 0 9 * * 1 — 09:00 on Mondays (day-of-week: 0 = Sunday).
  • 15 * * * * — minute 15 of every hour.

Adding a new scheduled job

  1. Write the endpoint in api/. For a GitHub Action, protect it with verifyAuditKey(req) and a POST-only check. For Vercel Cron, compare its bearer header to a configured CRON_SECRET and fail closed if the variable is absent.
  2. Add a workflow in .github/workflows/ with a schedule: block and workflow_dispatch:, curling ${{ secrets.VERCEL_DOMAIN }}/api/your-endpoint with -H "x-audit-key: ${{ secrets.AUDIT_API_KEY }}".
  3. Merge to main — scheduled workflows only ever run from the default branch, so a cron on a feature branch will never fire.
Two gotchas worth remembering. Auth fails closed: GitHub jobs need AUDIT_API_KEY and the Vercel activation job needs CRON_SECRET. A missing value produces 401s instead of unprotected work. GitHub schedules can also drift under load; nothing here is timing-critical to the minute.

04 A tier order, end to end

Signal, Flow and Capture are the only things sold. This is the whole path from the pricing page to a SIM in someone’s hand.

What used to be here. Sections 04 and 05 described the router and eSIM purchases sold through checkout-legacy.html. That checkout was deleted, and so now is everything behind it — api/stripe-webhook.js, webhook-services.js and webhook-config.js. They were kept for a while because subscription renewals were thought to run through them. They did not: both handlers read Stripe fields that no longer exist and had been failing silently for months (see the callout in §05). Once that moved to api/lib/subscription-lifecycle.js, nothing was left.
  1. 1

    The customer builds an order

    Quantities per tier, monthly or annual, physical SIM or eSIM, and optionally a router. An eSIM order also has to say which phone — the QR email links to a device-specific setup guide, and there is no safe guess. The page will not let an eSIM order reach payment until that is answered.

    Prices come from /api/tier-prices, read out of the Stripe catalogue rather than hardcoded, so a wallet sheet can never promise an amount the invoice disagrees with.

    checkout.htmljs/checkout.jsapi/tier-prices.js
  2. 2

    The server prices it and creates the payment

    The browser sends tier keys and quantities, never prices. Everything chargeable is looked up server-side, so a tampered request can change what is bought but never what it costs. A router with no SIM is refused outright — it has nothing to connect to.

    One shape of order: a Subscription created default_incomplete, any routers added to its first invoice, and that invoice’s PaymentIntent is what the customer confirms. The order reference is minted here so the same one reaches the confirmation page and the email.

    api/create-tier-payment.jsapi/lib/tier-catalogue.js
  3. 3

    Nothing exists until the card clears

    No account, no order, no service, no SIM claimed. This is a rule with a test around it (test/payment-integrity.test.mjs): the legacy checkout wrote rows as it went, which left abandoned orders and SIMs held against payments that never completed.

  4. 4

    Stripe fires payment_intent.succeeded

    The tier webhook verifies the signature, ignores anything that is not a tier order, and provisions: the account, the order, one service per SIM (three Signal SIMs is three things the customer holds, each with its own ICCID), a claimed SIM for each, and a service per router. The order insert is the idempotency claim — a unique index on the payment intent id means a duplicate delivery loses it and stops.

    If stock runs out the webhook does not fail: the money has already moved, and the service row is the record that someone is owed a SIM. It waits on the To Do list until stock arrives, and is allocated automatically the moment it does.

    api/tier-stripe-webhook.jsapi/lib/tier-fulfilment.jsemail
  5. 5

    You fulfil it

    eSIM: To Do → eSIMs to issue, one row per QR. Mark issued sends the QR, the setup guide for the recorded device, and the activation code for anyone who cannot scan a code on the phone they are installing to.

    Physical: the order page’s Fulfillment panel. Tick what is in the envelope, add the tracking number, Mark as Posted. One email per parcel, so an order that ships in two goes gets two.

    api/admin/services/[id]/fulfil.jsapi/lib/fulfillment-service.js
  6. 6

    It renews, or it does not

    Renewals move the period forward on every service on the subscription. A failed payment flags them past_due and emails the customer once — not on each of Stripe’s retries. A cancellation ends every service on it. See §05.

    api/lib/subscription-lifecycle.js
Stock is the thing that stops an order dead. A paid order with no SIM to give it is invisible except on the To Do list. Both pools are on SIM Stock, and the free-pool figure there counts only what claim_available_sim would actually hand out — an eSIM row without a QR is not stock.

05 Everything that touches Stripe

Our code → Stripe

FileWhat it calls Stripe for
api/lib/stripe.jsThe shared client. Everything else imports getStripeClient() from here (except the two purchase endpoints, which construct new Stripe(...) themselves).
api/stripe-config.jsHands the browser the publishable key. Nothing secret.
api/create-tier-payment.jsprices.list, customers.list/create, subscriptions.create (default_incomplete) with routers on the first invoice.
api/tier-stripe-webhook.jswebhooks.constructEvent, subscriptions.retrieve, customers.retrieve.
api/lib/subscription-lifecycle.jsReads only — renewals, cancellations and failed payments are applied from the event payload.
api/daily-cancellation-check.jssubscriptions.retrieve, subscriptions.cancel — the 01:00 cron.
api/midday-verification-cancelled-check.jsSame two calls, as verification — the 12:00 cron.
api/lib/refund-service.jsrefunds.create for both eSIM and router refunds, plus subscription cancellation on the way out.

Stripe → our code

Two endpoints, each with its own signing secret. POST /api/tier-stripe-webhook (STRIPE_TIER_WEBHOOK_SECRET) provisions tier orders and handles subscription lifecycle for every subscription on the account. POST /api/stripe-webhook (STRIPE_WEBHOOK_SECRET) is the legacy handler for router and eSIM orders from the deleted checkout. Both verify the signature against the raw bytes, so body parsing is off on each.

The legacy handler’s subscription code never worked. It read invoice.subscription and subscription.current_period_start, both of which Stripe removed when billing went item-level. The first was the opening check in the invoice handler (“no subscription → skip”), so every invoice took the early return and no period was ever moved and no renewal order ever written — silently, for both product lines. The second was validated as required, so a subscription event threw before using it. Renewals, cancellations and failed payments now live in api/lib/subscription-lifecycle.js, keyed on service.stripe_subscription_id rather than on which checkout sold it, and reading the period off the subscription item where it lives now.
EventWhat we do with it
payment_intent.succeededThe big one — SIM assignment, Evri CSV, confirmation email, Slack, order → paid. See §04 step 6.
payment_intent.payment_failedOrder → failed.
invoice.payment_succeededA renewal went through: rolls current_period_end forward a year and, when the billing reason is subscription_cycle, writes a new renewal order row. Trial-setup £0 invoices have no subscription and are skipped.
invoice.payment_failedUpdates the service’s subscription status.
customer.subscription.updatedSyncs status and period dates onto the service row.
customer.subscription.deletedMarks the service’s subscription cancelled.
anything elseLogged as unhandled_event and acknowledged with a 200.
The webhook endpoint must be registered in the Stripe dashboard for each environment, pointing at that environment’s domain, with the matching signing secret in STRIPE_WEBHOOK_SECRET. A mismatched secret means every event 400s — orders stay pending, no emails, no Slack. Stripe’s dashboard shows the failures; our logs show signature_verification_failed.

06 Refunds & router returns

All refunds start on the order page in this portal (admin/order.htmlPOST /api/admin/orders/:id/refund) and are attributed to the signed-in admin. They used to be Slack buttons; that endpoint has been deleted, so an old button now 404s.

The endpoint branches on product_type — everything below lives in api/lib/refund-service.js.

eSIM — one step

  • processEsimRefund() → Stripe refund (full or partial pence amount) → refunds row completed → order refunded → service cancelled → customer email → Slack alert.

Router — three steps, because the hardware has to come back

  1. 1

    Initiate return

    initiateRouterRefund(): no money moves yet. Order → return_initiated, a pending refunds row is written, a return CSV is generated into the shipping bucket, and Slack is alerted. The order appears under To Do → “Return labels to send”.

    POST /api/admin/orders/[id]/refundGET …/return-label-csv
  2. 2

    Send the label

    You download the CSV, give it to Evri, get a return QR back, and upload it on the order page (PDF/PNG/JPEG, 8 MB max). That stores it in the shipping bucket and emails the customer their label.

    POST …/return-labelEMAIL_TRIGGERS.RETURN_LABEL_ROUTER
  3. 3

    Router arrives → complete the refund

    completeRouterRefund(): now the Stripe refund happens. Refunds row → completed, order → refunded, service → cancelled, subscription cancelled, and the “return processed” email goes out. Shows under To Do → “Refunds to confirm” until then.

    POST …/complete-returnEMAIL_TRIGGERS.RETURN_PROCESSED_ROUTER
Both paths accept a partial amount in pence, validated against the order total. An order already refunded is rejected (409), and a router order that isn’t return_initiated can’t be completed — so double-clicks can’t double-refund.

07 Emails & templates

Every customer email goes through one function: sendTemplatedEmail(TRIGGER, { to, orderReference, variables }) in api/lib/email.js. It resolves the template (your saved override if there is one, otherwise the code default), substitutes {{tokens}}, wraps it in the shared layout, and sends via Resend.

TriggerSent whenSent by
order_confirmation_routerRouter payment succeeds. Nothing sends this today — a router is bought as a tier add-on and covered by order_confirmation_tiers. Kept because the template is still edited and may be wanted again.nothing
esim_readyAn eSIM is marked issued on the To Do list — sends the QR and the setup guide for the phone the order recordedapi/admin/services/[id]/fulfil.js
esim_order_pendingeSIM order placed but no QR available. Nothing sends this today — its only caller was the deleted legacy checkout.nothing
order_shipped_routerA parcel containing a router is marked posted in the admin portalapi/lib/fulfillment-service.js
order_shipped_simA parcel of physical SIMs is marked posted. One per parcel, so an order shipped in two goes sends two.api/lib/fulfillment-service.js
payment_failedA renewal payment is declined. Sent once, on the first failure — not on each of Stripe’s retries.api/lib/subscription-lifecycle.js
refund_confirmation_esimeSIM refund processedapi/lib/refund-service.js
return_label_routerReturn label uploadedapi/lib/refund-service.js
return_processed_routerRouter return completed & refundedapi/lib/refund-service.js

Not templated

  • api/contact.js — the marketing site’s contact form, forwarded straight to support@go-unbounded.co.uk with the Resend SDK directly.
  • Support replies from the Support tab — those go out through Gmail, not Resend (§11).

Editing templates

Email Templates lists every trigger. Editing one writes a row to email_templates keyed by trigger; that row wins over the code default from then on. The table starts empty on purpose — an unedited trigger shows its code default from api/lib/email-templates.js. Reusable bits (buttons, detail boxes, QR image box) come from api/lib/email-blocks.js and are injected as {{tokens}}, which is why you’ll see tokens in the editor you don’t have to fill in yourself.

08 Slack notifications

Three separate ways we post to Slack, with three separate credentials. This is the most confusing corner of the setup, so it’s worth reading once.

CredentialUsed byPosts
SLACK_WEBHOOK_URLapi/lib/refund-service.js, the admin order actionsRefund and return notifications. The order-notification posts went with the legacy webhook.
SLACK_WEBHOOK_ALERTS_URLapi/midday-verification-cancelled-check.jsSubscription-monitor alerts from the 12:00 cron.
SLACK_WEBHOOK_URL_ALERTSapi/lib/refund-service.jsRefund / return alerts.
Those two alert variable names are not a typo in this page. SLACK_WEBHOOK_ALERTS_URL and SLACK_WEBHOOK_URL_ALERTS are genuinely two different names for the same idea, read by different files. If refund alerts work but cron alerts don’t (or the reverse), it’s almost certainly because only one of the two is set in Vercel. Worth unifying next time either file is touched.

Every Slack path is best-effort: a missing webhook URL is logged and skipped, and a failed post never fails the order or the refund.

09 Admin portal page map

Each page is a static shell; all data arrives from /api/admin/*, which is gated by requireAdmin (session cookie + ADMIN_EMAILS allowlist, re-checked every request). On a 401 the shared helper in admin/admin.js bounces you to the login page.

PageEndpointsTouches
Orders
index.html
GET /api/admin/ordersorders, account, service
To DoGET /api/admin/todoRead-only aggregate: paid-but-unshipped routers, returns awaiting a label, returns awaiting a refund
ActivationsGET /api/admin/activations, POST /api/admin/services/:id/activationservice, service_activation_event, Stripe. Shows missing SIMs, deadlines, attempts and failures; retries or extends with admin attribution.
SIM Stock
sims.html
GET/POST /api/admin/sims, DELETE /api/admin/sims/:idsim (physical stock), service via claim_available_sim
Order detail
order.html
GET /api/admin/orders/:id, then ship, refund, return-label, return-label-csv, complete-returnorders, refunds, service, Stripe, Resend, Slack, shipping bucket
Customers + detailGET /api/admin/customers, GET /api/admin/customers/:idaccount, service, orders
Email Templates + editorGET/PUT /api/admin/email-templates(/:key)email_templates
Passkeyspublic-config, session-token, passkey-metadata, record-passkey-metadataSupabase Auth, admin_passkey_metadata
Supportgmail/status, gmail/conversations(/:id), gmail/reply, gmail/disconnectgmail_connection, Gmail API
Login / Setup
login.html, setup.html
public-config, login, send-signin-link, record-passkey-metadataSupabase Auth
Wiki
wiki.html
none — it’s hand-written HTMLnothing

10 Support inbox (Gmail)

The Support tab is a thin Gmail client for the Workspace mailbox that support@go-unbounded.co.uk and hi@go-unbounded.co.uk both deliver into (they’re alternate addresses on one account, so one connection covers both).

  • Connecting: gmail/oauth-start → Google consent → gmail/oauth-callback stores the refresh token in the gmail_connection table. It’s a singleton row (id = 'default') — reconnecting overwrites it.
  • Scopes: gmail.readonly, gmail.send, gmail.modify, userinfo.email.
  • Which threads show: a Gmail search for to:support@… OR to:hi@…, newest first.
  • Replying sends through the Gmail API as the connected mailbox — it does not go through Resend, so support replies never appear in Resend logs.
  • The stored refresh token is live credentials for the mailbox. That row is service-role only, RLS on, no anon policy.

11 Admin sign-in & passkeys

Nobody types a password. You sign in with a passkey (Face ID / Touch ID / Windows Hello / security key) against Supabase Auth; first-time setup goes through an emailed sign-in link. Two things must both be true for any admin request to succeed: a valid Supabase session, and your email in ADMIN_EMAILS. The allowlist is re-checked on every request, and it fails closed — unset means nobody is an admin.

Session state lives in two HttpOnly cookies (admin_session, admin_refresh). Endpoints are same-origin on the admin subdomain, so there’s no header secret involved. The step-by-step setup guide — DNS, Vercel env vars, Supabase config, adding devices — is in admin/README.md.

12 Database tables & storage

TableHoldsWritten by
accountCustomer identity + stripe_customer_id. Upserted on email.Both purchase endpoints
serviceThe subscription-ish record: plan, product type, period dates, stripe_subscription_id, sim_id, activation state/attempts/failure, cancellation dates.Purchase endpoints, Stripe webhook, crons, refund-service, the customer and admin portals
service_activation_eventServer-only audit history for assignment, activation attempts, completion/failure, and admin deadline extensions.Activation database functions called by the webhook, hourly cron, customer portal, and admin portal
ordersOne row per payment event (initial_router, initial_esim, renewal): amount, status, addresses, payment intent, tracking number.Purchase endpoints, Stripe webhook, ship, refund-service
refundsRefund lifecycle, including return CSV path and label-sent timestamps.api/lib/refund-service.js
simSIM/eSIM stock: type, status, ICCID, QR path, activation code, supplier, assignment. Both kinds are added by hand on the SIM Stock page — physical scanned in by the handful, eSIMs one at a time with their QR.api/admin/sims.js, api/lib/sim-stock.js, api/lib/esim-stock.js, claim_available_sim
planPlans keyed by recurring price + type (the £39 annual row) and the grace period.read-only from our code
account_usersLinks a Supabase Auth user to an account for the customer portal (migrated off Stytch — see account-users-supabase-auth-migration.sql).the customer portal
email_templatesYour saved overrides of the email triggers.Admin portal
gmail_connectionSingleton OAuth refresh token for the support mailbox.api/admin/gmail/*
admin_passkey_metadataFriendly device names/dates for registered passkeys.Passkeys page

Storage buckets

  • shipping — outbound Evri CSVs (csv/evri-shipping-*.csv), return CSVs, and uploaded return labels. Handed out as signed URLs (1 hour for CSVs, 7 days for labels).
  • esim-qr-codes — the QR images emailed to eSIM customers, referenced by sim.qr_code_path, signed for 7 days.
Schema is hand-maintained in this repo as SQL files at the root (email-templates-schema.sql, gmail-connection-schema.sql, account-users-supabase-auth-migration.sql, verify-database.sql) — run them in the Supabase SQL editor. Every table has RLS on with no anon policies: the service-role key used by api/lib/supabase.js is the only way in.

13 Status values

orders.status

StatusMeansSet by
pendingRow created, payment not confirmed yet.Purchase endpoints
paidPayment succeeded. Anything physical waits here until posted.api/tier-stripe-webhook.js
shippedTracking number recorded; triggers the shipping email.Ship action
failedStripe reported the payment failed.Stripe webhook
return_initiatedRouter return started, no money moved yet.Refund action
refundedStripe refund done, service cancelled.refund-service

service

status is active or cancelled. subscription_status tracks Stripe separately and starts as active, becomes initial once the renewal subscription is created with its year-long trial, then mirrors Stripe (trialing, active, past_due, cancelled). cancelled_at is the date the service ends — which is exactly what the 01:00 and 12:00 crons scan for.

14 Environment variables

Set in Vercel (Production and Preview separately — Preview values apply to all preview deploys, not just test). Cron secrets live in GitHub repo settings instead.

VariableRead byMissing it means
SUPABASE_URL, SUPABASE_SERVICE_ROLEapi/lib/supabase.js — so, everythingNothing server-side works at all
SUPABASE_ANON_KEYapi/admin/public-config.jsAdmin sign-in can’t initialise in the browser
STRIPE_SECRET_KEYapi/lib/stripe.js + both purchase endpointsNo payments, no refunds, no cron cancellations
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYapi/stripe-config.jsCheckout page can’t mount the card field
STRIPE_TIER_WEBHOOK_SECRETapi/tier-stripe-webhook.jsEvery Stripe event rejected with a 400 → orders take the money and never provision. Note this is not interchangeable with the deleted STRIPE_WEBHOOK_SECRET: Stripe derives a separate secret per endpoint.
AUDIT_API_KEYapi/lib/auth.js (crons) and the Stripe webhook’s inventory pingAll cron jobs 401 silently
INTERNAL_API_KEYapi/lib/auth.js (verifyInternalKey)Nothing. No endpoint uses it since api/cancel-service.js was deleted; the verifier is kept for whatever replaces it.
ADMIN_EMAILSapi/lib/admin-auth.jsNobody can use this portal (fails closed by design)
RESEND_API_KEYapi/lib/email-transport.js, api/contact.jsNo customer emails; sends return null rather than throwing
PORTAL_URLapi/lib/email.jsAccount links fall back to account.go-unbounded.co.uk
ADMIN_PORTAL_URLapi/lib/refund-service.jsSlack links fall back to admin.go-unbounded.co.uk
SLACK_WEBHOOK_URLapi/lib/refund-service.jsNo refund or return posts in Slack
SLACK_WEBHOOK_ALERTS_URLapi/midday-verification-cancelled-check.jsCancellation alerts go nowhere
SLACK_WEBHOOK_URL_ALERTSapi/lib/refund-service.jsRefund/return alerts go nowhere
GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRETapi/lib/gmail.js, api/admin/gmail/oauth-start.jsSupport inbox can’t connect or refresh
MOBILE_MANAGER_USERNAME, MOBILE_MANAGER_PASSWORDapi/esim-inventory.jsAuto eSIM ordering can’t authenticate (moot while it’s disabled)
VERCEL_URL, VERCEL_ENVSet by Vercel; used to build self-referencing URLs and label environmentsFalls back to the production domain

GitHub repository secrets

VERCEL_DOMAIN (base URL the crons curl), AUDIT_API_KEY (must match Vercel’s), TEST_SUPABASE_URL, TEST_SUPABASE_ANON_KEY.

15 Something’s broken — where to look

SymptomMost likely causeCheck
Order stuck on pending, no email, no SlackStripe webhook not arriving or failing signatureStripe dashboard → Webhooks → recent deliveries; then Vercel logs for signature_verification_failed
Customer paid but got no confirmation emailResend key or a template edit that broke a tokenResend dashboard logs; Email Templates — reverting your override restores the code default
Shipping email never sent after marking shippedThe Supabase DB webhook on orders, which is configured in the Supabase dashboard rather than this repoSupabase → Database → Webhooks; the order row itself will show shipped either way
eSIM customer got “preparing your eSIM” instead of a QRNo available eSIM with a qr_code_path in stocksim table; Slack #alerts will have the ⚠️ with the order reference
Somebody got billed after cancellingThe 01:00 cron didn’t run or 401’dGitHub → Actions → Daily Subscription Checks; re-run manually. The 12:00 job exists to catch exactly this
Crons show green in GitHub but nothing happensAUDIT_API_KEY mismatch between GitHub and Vercel — curl gets a 401 and the workflow still passesThe workflow log body, and both copies of the key
Slack has gone quietOne of the three Slack credentials unset — including the two similarly-named alert URLs (§09)Vercel env vars; the code logs “No webhook URL configured” and carries on
Support tab says disconnectedThe Google refresh token was revoked (password change, scope change, 6 months idle)Reconnect from Support — it overwrites the singleton row
Locked out of this portalEmail not in ADMIN_EMAILS, or passkey lostadmin/README.md has the recovery route (emailed sign-in link → register a new passkey)

16 Keeping this page honest

This page is plain hand-written HTML in admin/wiki.html — no API, no database. It cannot go out of date on its own, which means it will go out of date unless it’s edited alongside the change. The moments that matter:

  • New scheduled job → add a row to §03.
  • New or changed Stripe event handling → §06.
  • New email trigger → §08 (and it’ll appear in the Email Templates page automatically).
  • New env var → §15, with what breaks when it’s missing — that column is the useful one.
  • New admin page or endpoint → §10.

Deeper reference lives next to the code: admin/README.md (portal setup, DNS, passkeys, Gmail OAuth) and api/SECURITY.md (trust boundaries and the reasoning behind them).