Wiki
How the pieces fit together — scripts, crons, Stripe, emails and who calls what.
Contents
- 01Outside systems
- 02Domains & environments
- 03Scheduled jobs (crons)
- 04A tier order, end to end
- 05Everything that touches Stripe
- 06Refunds & router returns
- 07Emails & templates
- 08Slack notifications
- 09Admin portal page map
- 10Support inbox (Gmail)
- 11Admin sign-in & passkeys
- 12Database tables & storage
- 13Status values
- 14Environment variables
- 15Something’s broken — where to look
- 16Keeping this page honest
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.
| System | What it does for us | Where 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
| URL | Serves | Branch / data |
|---|---|---|
go-unbounded.co.uk | Redirects to www. | main |
www.go-unbounded.co.uk | The 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/checkout | Rewrites 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.uk | Same portal, pinned to the test branch. | test · separate Supabase project, no real data |
account.go-unbounded.co.uk | The 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 |
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 file | Cron | Calls | What it actually does |
|---|---|---|---|
daily-checks.ymljob: 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.ymljob: 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.jsonVercel 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
- Write the endpoint in
api/. For a GitHub Action, protect it withverifyAuditKey(req)and a POST-only check. For Vercel Cron, compare its bearer header to a configuredCRON_SECRETand fail closed if the variable is absent. - Add a workflow in
.github/workflows/with aschedule:block andworkflow_dispatch:, curling${{ secrets.VERCEL_DOMAIN }}/api/your-endpointwith-H "x-audit-key: ${{ secrets.AUDIT_API_KEY }}". - Merge to
main— scheduled workflows only ever run from the default branch, so a cron on a feature branch will never fire.
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.
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
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
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
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
Stripe fires
payment_intent.succeededThe 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
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
It renews, or it does not
Renewals move the period forward on every service on the subscription. A failed payment flags them
past_dueand 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
claim_available_sim would actually hand out — an
eSIM row without a QR is not stock.
05 Everything that touches Stripe
Our code → Stripe
| File | What it calls Stripe for |
|---|---|
api/lib/stripe.js | The shared client. Everything else imports getStripeClient() from here (except the two purchase endpoints, which construct new Stripe(...) themselves). |
api/stripe-config.js | Hands the browser the publishable key. Nothing secret. |
api/create-tier-payment.js | prices.list, customers.list/create, subscriptions.create (default_incomplete) with routers on the first invoice. |
api/tier-stripe-webhook.js | webhooks.constructEvent, subscriptions.retrieve, customers.retrieve. |
api/lib/subscription-lifecycle.js | Reads only — renewals, cancellations and failed payments are applied from the event payload. |
api/daily-cancellation-check.js | subscriptions.retrieve, subscriptions.cancel — the 01:00 cron. |
api/midday-verification-cancelled-check.js | Same two calls, as verification — the 12:00 cron. |
api/lib/refund-service.js | refunds.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.
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.
| Event | What we do with it |
|---|---|
payment_intent.succeeded | The big one — SIM assignment, Evri CSV, confirmation email, Slack, order → paid. See §04 step 6. |
payment_intent.payment_failed | Order → failed. |
invoice.payment_succeeded | A 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_failed | Updates the service’s subscription status. |
customer.subscription.updated | Syncs status and period dates onto the service row. |
customer.subscription.deleted | Marks the service’s subscription cancelled. |
| anything else | Logged as unhandled_event and acknowledged with a 200. |
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.html → POST /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) →refundsrowcompleted→ orderrefunded→ servicecancelled→ customer email → Slack alert.
Router — three steps, because the hardware has to come back
-
1
Initiate return
initiateRouterRefund(): no money moves yet. Order →return_initiated, apendingrefunds row is written, a return CSV is generated into theshippingbucket, and Slack is alerted. The order appears under To Do → “Return labels to send”.POST /api/admin/orders/[id]/refundGET …/return-label-csv -
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
shippingbucket and emails the customer their label.POST …/return-labelEMAIL_TRIGGERS.RETURN_LABEL_ROUTER -
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
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.
| Trigger | Sent when | Sent by |
|---|---|---|
order_confirmation_router | Router 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_ready | An eSIM is marked issued on the To Do list — sends the QR and the setup guide for the phone the order recorded | api/admin/services/[id]/fulfil.js |
esim_order_pending | eSIM order placed but no QR available. Nothing sends this today — its only caller was the deleted legacy checkout. | nothing |
order_shipped_router | A parcel containing a router is marked posted in the admin portal | api/lib/fulfillment-service.js |
order_shipped_sim | A 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_failed | A renewal payment is declined. Sent once, on the first failure — not on each of Stripe’s retries. | api/lib/subscription-lifecycle.js |
refund_confirmation_esim | eSIM refund processed | api/lib/refund-service.js |
return_label_router | Return label uploaded | api/lib/refund-service.js |
return_processed_router | Router return completed & refunded | api/lib/refund-service.js |
Not templated
api/contact.js— the marketing site’s contact form, forwarded straight tosupport@go-unbounded.co.ukwith 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.
| Credential | Used by | Posts |
|---|---|---|
SLACK_WEBHOOK_URL | api/lib/refund-service.js, the admin order actions | Refund and return notifications. The order-notification posts went with the legacy webhook. |
SLACK_WEBHOOK_ALERTS_URL | api/midday-verification-cancelled-check.js | Subscription-monitor alerts from the 12:00 cron. |
SLACK_WEBHOOK_URL_ALERTS | api/lib/refund-service.js | Refund / return alerts. |
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.
| Page | Endpoints | Touches |
|---|---|---|
Ordersindex.html | GET /api/admin/orders | orders, account, service |
| To Do | GET /api/admin/todo | Read-only aggregate: paid-but-unshipped routers, returns awaiting a label, returns awaiting a refund |
| Activations | GET /api/admin/activations, POST /api/admin/services/:id/activation | service, service_activation_event, Stripe. Shows missing SIMs, deadlines, attempts and failures; retries or extends with admin attribution. |
SIM Stocksims.html | GET/POST /api/admin/sims, DELETE /api/admin/sims/:id | sim (physical stock), service via claim_available_sim |
Order detailorder.html | GET /api/admin/orders/:id, then ship, refund, return-label, return-label-csv, complete-return | orders, refunds, service, Stripe, Resend, Slack, shipping bucket |
| Customers + detail | GET /api/admin/customers, GET /api/admin/customers/:id | account, service, orders |
| Email Templates + editor | GET/PUT /api/admin/email-templates(/:key) | email_templates |
| Passkeys | public-config, session-token, passkey-metadata, record-passkey-metadata | Supabase Auth, admin_passkey_metadata |
| Support | gmail/status, gmail/conversations(/:id), gmail/reply, gmail/disconnect | gmail_connection, Gmail API |
Login / Setuplogin.html, setup.html | public-config, login, send-signin-link, record-passkey-metadata | Supabase Auth |
Wikiwiki.html | none — it’s hand-written HTML | nothing |
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-callbackstores the refresh token in thegmail_connectiontable. 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
| Table | Holds | Written by |
|---|---|---|
account | Customer identity + stripe_customer_id. Upserted on email. | Both purchase endpoints |
service | The 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_event | Server-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 |
orders | One row per payment event (initial_router, initial_esim, renewal): amount, status, addresses, payment intent, tracking number. | Purchase endpoints, Stripe webhook, ship, refund-service |
refunds | Refund lifecycle, including return CSV path and label-sent timestamps. | api/lib/refund-service.js |
sim | SIM/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 |
plan | Plans keyed by recurring price + type (the £39 annual row) and the grace period. | read-only from our code |
account_users | Links 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_templates | Your saved overrides of the email triggers. | Admin portal |
gmail_connection | Singleton OAuth refresh token for the support mailbox. | api/admin/gmail/* |
admin_passkey_metadata | Friendly 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 bysim.qr_code_path, signed for 7 days.
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
| Status | Means | Set by |
|---|---|---|
| pending | Row created, payment not confirmed yet. | Purchase endpoints |
| paid | Payment succeeded. Anything physical waits here until posted. | api/tier-stripe-webhook.js |
| shipped | Tracking number recorded; triggers the shipping email. | Ship action |
| failed | Stripe reported the payment failed. | Stripe webhook |
| return_initiated | Router return started, no money moved yet. | Refund action |
| refunded | Stripe 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.
| Variable | Read by | Missing it means |
|---|---|---|
SUPABASE_URL, SUPABASE_SERVICE_ROLE | api/lib/supabase.js — so, everything | Nothing server-side works at all |
SUPABASE_ANON_KEY | api/admin/public-config.js | Admin sign-in can’t initialise in the browser |
STRIPE_SECRET_KEY | api/lib/stripe.js + both purchase endpoints | No payments, no refunds, no cron cancellations |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | api/stripe-config.js | Checkout page can’t mount the card field |
STRIPE_TIER_WEBHOOK_SECRET | api/tier-stripe-webhook.js | Every 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_KEY | api/lib/auth.js (crons) and the Stripe webhook’s inventory ping | All cron jobs 401 silently |
INTERNAL_API_KEY | api/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_EMAILS | api/lib/admin-auth.js | Nobody can use this portal (fails closed by design) |
RESEND_API_KEY | api/lib/email-transport.js, api/contact.js | No customer emails; sends return null rather than throwing |
PORTAL_URL | api/lib/email.js | Account links fall back to account.go-unbounded.co.uk |
ADMIN_PORTAL_URL | api/lib/refund-service.js | Slack links fall back to admin.go-unbounded.co.uk |
SLACK_WEBHOOK_URL | api/lib/refund-service.js | No refund or return posts in Slack |
SLACK_WEBHOOK_ALERTS_URL | api/midday-verification-cancelled-check.js | Cancellation alerts go nowhere |
SLACK_WEBHOOK_URL_ALERTS | api/lib/refund-service.js | Refund/return alerts go nowhere |
GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET | api/lib/gmail.js, api/admin/gmail/oauth-start.js | Support inbox can’t connect or refresh |
MOBILE_MANAGER_USERNAME, MOBILE_MANAGER_PASSWORD | api/esim-inventory.js | Auto eSIM ordering can’t authenticate (moot while it’s disabled) |
VERCEL_URL, VERCEL_ENV | Set by Vercel; used to build self-referencing URLs and label environments | Falls 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
| Symptom | Most likely cause | Check |
|---|---|---|
Order stuck on pending, no email, no Slack | Stripe webhook not arriving or failing signature | Stripe dashboard → Webhooks → recent deliveries; then Vercel logs for signature_verification_failed |
| Customer paid but got no confirmation email | Resend key or a template edit that broke a token | Resend dashboard logs; Email Templates — reverting your override restores the code default |
| Shipping email never sent after marking shipped | The Supabase DB webhook on orders, which is configured in the Supabase dashboard rather than this repo | Supabase → Database → Webhooks; the order row itself will show shipped either way |
| eSIM customer got “preparing your eSIM” instead of a QR | No available eSIM with a qr_code_path in stock | sim table; Slack #alerts will have the ⚠️ with the order reference |
| Somebody got billed after cancelling | The 01:00 cron didn’t run or 401’d | GitHub → Actions → Daily Subscription Checks; re-run manually. The 12:00 job exists to catch exactly this |
| Crons show green in GitHub but nothing happens | AUDIT_API_KEY mismatch between GitHub and Vercel — curl gets a 401 and the workflow still passes | The workflow log body, and both copies of the key |
| Slack has gone quiet | One 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 disconnected | The Google refresh token was revoked (password change, scope change, 6 months idle) | Reconnect from Support — it overwrites the singleton row |
| Locked out of this portal | Email not in ADMIN_EMAILS, or passkey lost | admin/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).