Environment variables and secrets
Every environment variable and secret the worker reads - what it is for, where it must be set, what breaks without it, and whether the gate fails open or closed.
Last updated 2026-09-14
Summary#
_worker.js reads its entire configuration from env. There is no config file in the repository that holds a live value, and there is no build step that injects one. Everything is either a binding declared in wrangler.toml, an environment variable or secret set on the Cloudflare Pages project, or a row in the platform_config D1 table that can be changed without a deploy.
This page enumerates all three, states what breaks when each is absent, and marks whether the code fails closed (refuses the request) or fails open (degrades, falls back, or silently skips). Read the fail mode before you remove anything: several gates deliberately refuse rather than guess, and one of them returns a 503 that looks like an outage.
Overview#
Four places configuration lives.
| Where | What it holds | Survives a deploy? |
|---|---|---|
wrangler.toml | Bindings only: D1, KV, R2, Workers AI. Committed to the repository | Yes, it is the deploy input |
Cloudflare Pages project metricvaultai → Settings → Environment variables | Every API key, every secret, every tunable string | Yes. wrangler.toml never touches them |
.dev.vars at the repository root | The same names, local values only. Git-ignored | Local only, never deployed |
D1 table platform_config | Runtime tunables changed from the admin console | Yes, it is data |
Fail closed means the request is refused with an explicit error rather than proceeding with a weaker assumption. The four gates that fail closed are MV_INTERNAL_SECRET, STRIPE_WEBHOOK_SECRET, ANTHROPIC_API_KEY on the Claude-backed routes, and PERPLEXITY_API_KEY on fact-check and citations. Each one guards something where guessing would be worse than stopping. Everything else degrades.
Warning: .dev.vars is ignored through the glob .dev.vars*, not the bare filename. A .dev.vars.bak-crlf backup once slipped past a bare rule and was committed with every live API key in it. Never widen that rule.
Bindings (declared in wrangler.toml)#
wrangler.toml is in Pages format: name = "metricvaultai", pages_build_output_dir = "dist", compatibility_date = "2024-01-01". account_id is not supported in Pages config and comes from the CLOUDFLARE_ACCOUNT_ID GitHub secret instead.
| Binding | Type | Identifier | Purpose | Missing-value behavior |
|---|---|---|---|---|
MONITOR_DB | D1 | metricvault-monitor, id 91cb3504-650c-4633-9e25-fc4c07a95698 | Almost all persistent state: billing, usage, Library, caches, telemetry, integrations | Fails hard. monRequireDB throws MONITOR_DB not bound. Wrangler may need a redeploy. |
QUICKVIEW_CACHE | KV | 08fb0f9f15d7424bbf4d672b7b491814 | Declared for the extension quickview cache | Unused. No code reads it. The quickview cache is the D1 table quickview_cache, because KV bindings on this Pages project reset on each deploy |
BLOG_MEDIA | R2 | metricvault-blog-media | Blog images: uploads and AI-generated covers | /api/diag/blog-image reports blocker: "media_not_configured (BLOG_MEDIA R2 bucket not bound)" |
AI | Workers AI | [ai] binding = "AI" | Result translation (@cf/meta/m2m100-1.2b). Not used for blog images | /api/translate returns 503 unless DEEPL_API_KEY is set |
ASSETS | Pages | automatic | Static file serving in advanced mode | Not configurable |
Warning: A binding to a non-existent R2 bucket fails the deploy itself. Create the bucket first with wrangler r2 bucket create metricvault-blog-media.
Important: Do not add a [triggers] crons block to wrangler.toml. This project deploys to Pages, and Pages never invokes scheduled(). See Background jobs and scheduling.
MV_INTERNAL_SECRET — the one that must be set in three places#
MV_INTERNAL_SECRET is the shared secret that separates operator and machine-to-machine calls from customer traffic. It protects:
POST /api/cron/run— the background-job triggerPOST /api/billing/sync— the Stripe reconciliation job- Every
/api/diag/*probe and/api/dbgdfs GET /api/blog/higgsfield/signin— the operator image-provider re-auth
It must be set to the same value in three places:
| # | Location | How |
|---|---|---|
| 1 | Cloudflare Pages project metricvaultai → Settings → Environment variables | The dashboard |
| 2 | The cron-worker Worker | cd cron-worker && wrangler secret put MV_INTERNAL_SECRET |
| 3 | GitHub repository secrets, for the backup scheduler | Repository → Settings → Secrets → MV_INTERNAL_SECRET |
It fails closed, by design. mvVerifyInternalSecret returns
- 503
{ "error": "Internal jobs are not configured (MV_INTERNAL_SECRET is not set)." }when the variable is absent, and - 401
{ "error": "Unauthorized internal call" }when the supplied value does not match.
Diagnostic routes have their own gate and return 403 { "ok": false, "error": "forbidden" } when the secret is unset or wrong.
The 503 symptom. Background jobs are the loudest place this shows up. The cron Worker posts to /api/cron/run with the header x-mv-internal-secret; if the secret is unset the receiver 503s and no scheduled job runs at all — Competitor Monitor stops detecting changes, rank alerts stop firing, scheduled reports never advance. The code deliberately refuses to fall back to a derivable value, because a guessable fallback would make the endpoint effectively public.
Secondary symptoms of a mismatch, in order of how often they are noticed:
| Where | What you see |
|---|---|
| GitHub Actions cron workflow | ::error::Cron trigger returned HTTP 401 (expected 200). |
cron-worker manual GET | HTTP 502 with {"ok":false,"status":401,...} |
Any /api/diag/* probe | 403 forbidden |
| Cron worker with no secret of its own | { "ok": false, "error": "MV_INTERNAL_SECRET is not set on this cron worker. Run: wrangler secret put MV_INTERNAL_SECRET" } |
Other operator variables#
| Name | Purpose | Fail mode |
|---|---|---|
ADMIN_EMAILS | Comma-separated extra admin addresses, appended to the seeded list albertdbrown85@gmail.com and metricvaulttestacc@gmail.com | Optional. Absence means only the seeded addresses plus rows in the admin_users table are admins |
BLOG_CONSOLE_TOKEN | Console token for the standalone blog platform's /v1/admin/* API. Never reaches the browser | Required for blog platform provisioning and proxying |
TOKEN_SECRET | Not used. It appears only in a comment. Google OAuth tokens are stored base64-obfuscated, not encrypted | None |
Data providers and AI#
| Name | Purpose | What breaks without it | Fails |
|---|---|---|---|
DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD | HTTP Basic auth to api.dataforseo.com/v3/*, the real SEO data layer behind most tools | Fetchers return null and callers report "not configured". /api/benchmarks 503s with Real benchmarks require DataForSEO credentials. Originality 503s with Originality checking is not configured (DataForSEO credentials are not set). code not_configured. /api/dbgdfs 500s with { "error": "no credentials" }. Rank alerts fall back to a DuckDuckGo HTML scrape | Open in most tools, closed on those routes |
OPENAI_API_KEY | The GPT calls behind /api/premium-ai, /api/analyst and the tier-2 job | Two routes return 500 { "error": "OpenAI API key not configured" }; most helpers return null. The admin Jobs panel shows tier-2 as enabled: false with the note OPENAI_API_KEY not set — this job is a no-op. | Mixed |
OPENAI_BASE_URL | Overrides the OpenAI base URL. The default is a Cloudflare AI Gateway URL chosen so egress leaves from an OpenAI-supported region | Optional. Set it to https://api.openai.com/v1 to bypass the gateway | Open |
ANTHROPIC_API_KEY | Claude: blog writer, fact-check, humanize, titles, alt text, translations, web search, Growth Actions | 503 with code ai_not_configured and one of AI is not configured (ANTHROPIC_API_KEY missing)., AI is not configured. Set the ANTHROPIC_API_KEY secret., or AI translation is not configured (ANTHROPIC_API_KEY is missing). | Closed |
ANTHROPIC_MODEL | Overrides the default Claude model for the generic helper. Default claude-sonnet-5 | Falls back to the default | Open |
GEMINI_API_KEY | Gemini 1.5 Flash provider for AI-visibility measurement | The engine is dropped from the measured set and the UI says Not measured: … Add API keys (GEMINI_API_KEY / PERPLEXITY_API_KEY / ANTHROPIC_API_KEY) in Cloudflare env vars to include them. | Open |
PERPLEXITY_API_KEY | Perplexity provider, plus fact-check and citation lookups | 503 Fact-checking is not configured (the Perplexity key is not set). code not_configured; 503 Perplexity API key not configured. | Closed on those two routes, open elsewhere |
DEEPL_API_KEY | Alternative translation provider. A key ending :fx selects https://api-free.deepl.com, otherwise https://api.deepl.com | Falls back to Workers AI. If neither is present /api/translate 503s | Open, given AI |
PSI_API_KEY, PSI_API_KEY_2, PSI_API_KEY_3, PSI_API_KEY_4 | Google PageSpeed Insights keys, rotated for quota headroom | On exhaustion: Google PageSpeed daily quota hit. Free quota is 25k requests/day per project. Add PSI_API_KEY_2/_3/_4 (each from a separate Google Cloud project) in Cloudflare Pages → Settings → Environment Variables to multiply capacity. Hit /api/diag/psi to verify keys loaded. | Open, degraded |
PEXELS_API_KEY | Stock-photo search | Falls back to Openverse automatically | Open |
RESEND_API_KEY | All transactional and newsletter email | dispatchAlerts returns early and mail is silently not sent. /api/diag/email reports RESEND_API_KEY is not set in this environment. | Open, and silent |
MVB_FROM_EMAIL | Sender address for blog and newsletter mail. Default Metric Vault Blog <blog@metricvaultai.com> | Default used | Open |
MVB_PUBLIC_API_BASE | Public API base advertised to embedded blogs. Default https://metricvaultai.com | Default used | Open |
MVB_WEB_SEARCH_TOOL | Anthropic web-search tool id. Default web_search_20260209 | Default used | Open |
MVB_IMAGE_BUDGET_MS | Wall-clock budget for blog image generation. Default 75000 | Default used | Open |
BLOG_PLATFORM_URL | Base URL of the standalone blog platform. Default https://sales-85-corp-blogs.brownene3.workers.dev | Default used | Open |
Per-task Claude model overrides#
All optional. Each falls back through a chain, so setting only AI_TEXT_MODEL moves most of them at once.
| Variable | Fallback chain |
|---|---|
AI_TEXT_MODEL | claude-opus-4-8 - the base default for most blog and article calls |
AI_CHECK_MODEL | AI_TEXT_MODEL then claude-opus-4-8 |
AI_EXCERPT_MODEL | AI_TEXT_MODEL then claude-haiku-4-5 |
AI_HUMANIZE_MODEL | AI_TEXT_MODEL then claude-opus-4-8 |
AI_FACTCHECK_MODEL | AI_TEXT_MODEL then claude-opus-4-8 |
AI_TITLES_MODEL | AI_TEXT_MODEL then claude-opus-4-8 |
AI_LINKS_MODEL | AI_TRANSLATE_MODEL then claude-haiku-4-5 |
AI_TRANSLATE_MODEL | Referenced only as the fallback for AI_LINKS_MODEL |
AI_ALT_MODEL | claude-haiku-4-5 |
Billing#
| Name | Purpose | Fail mode |
|---|---|---|
STRIPE_SECRET_KEY | Every Stripe REST call: checkout, portal, billing sync | mvStripeApi throws STRIPE_SECRET_KEY is not set with mvCode = "stripe_not_configured". runStripeBillingSync returns { ok: false, errors: ["STRIPE_SECRET_KEY is not set"] }. The admin customer view reports stripe.configured = false |
STRIPE_WEBHOOK_SECRET | HMAC-SHA256 verification of POST /api/stripe/webhook over ${timestamp}.${rawBody}, with a 300-second replay tolerance | Fails closed. 503 { "error": "Webhook is not configured (STRIPE_WEBHOOK_SECRET is not set).", "code": "webhook_not_configured" }. A bad signature returns 400 { "error": "Invalid signature" }. An unverified webhook could set anyone to any plan, so an unconfigured secret must never mean "trust it". The reconcile job keeps entitlements correct until the secret is added |
Price ids are env-first with hardcoded fallbacks, so checkout keeps working if a variable is missing or empty.
| Variable | Fallback price id |
|---|---|
STRIPE_PRICE_STARTER_MONTHLY | price_1TdWGqLldgJv5lq6D4ssnIZn |
STRIPE_PRICE_STARTER_ANNUAL | price_1TdWKKLldgJv5lq6SgqxClRJ |
STRIPE_PRICE_PRO_MONTHLY | price_1TdWLaLldgJv5lq6ysAhbHB8 |
STRIPE_PRICE_PRO_ANNUAL | price_1TdWM5LldgJv5lq66gHi9WAq |
STRIPE_PRICE_AGENCY_MONTHLY | price_1TdWNQLldgJv5lq63yA9Hcli |
STRIPE_PRICE_AGENCY_ANNUAL | price_1TdWOGLldgJv5lq6rIumUMxf |
STRIPE_PRICE_ENT_MONTHLY | price_1TdWOnLldgJv5lq6lUauQTIx |
STRIPE_PRICE_ENT_ANNUAL | price_1TdWPHLldgJv5lq6lAIco4oP |
mvStripePriceToPlan also maps retired price ids, so a SKU rename never downgrades an existing subscriber.
Authentication#
| Name | Purpose | Fail mode |
|---|---|---|
SUPABASE_URL | Supabase project base. A hardcoded default (https://cnjycxhmnepvqsuyrwem.supabase.co) is used at four call sites | Default used |
SUPABASE_SERVICE_KEY | Service-role key for analysis_history reads and writes | saveToSupabase returns silently. /api/history returns 500 { "error": "History not configured" } |
SUPABASE_ANON_KEY | The apikey header when verifying a caller's access token at /auth/v1/user. Chain: SUPABASE_ANON_KEY then SUPABASE_SERVICE_KEY then a hardcoded public anon JWT | Hardcoded anon key used |
GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET | Google Search Console and Analytics OAuth | /auth/google/start returns 503 as plain text: Google OAuth is not yet configured. Set GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET in Cloudflare Pages env vars. Elsewhere the connect affordance is hidden |
See Authentication and authorization flow for how these combine into the five authentication mechanisms.
Social publishing OAuth#
Six platforms. Each accepts several variable names, tried in order, so a secret works whether it was added with the SOCIAL_ prefix or bare, and the Meta apps can share one pair.
| Platform | Client id names, in order | Client secret names, in order | Scopes |
|---|---|---|---|
SOCIAL_LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_ID | SOCIAL_LINKEDIN_CLIENT_SECRET, LINKEDIN_CLIENT_SECRET | openid profile email w_member_social | |
| X | SOCIAL_X_CLIENT_ID, X_CLIENT_ID, TWITTER_CLIENT_ID | SOCIAL_X_CLIENT_SECRET, X_CLIENT_SECRET, TWITTER_CLIENT_SECRET | tweet.read tweet.write users.read offline.access |
SOCIAL_FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_ID, META_APP_ID | SOCIAL_FACEBOOK_CLIENT_SECRET, FACEBOOK_CLIENT_SECRET, META_APP_SECRET | public_profile,pages_show_list,pages_manage_posts,pages_read_engagement | |
SOCIAL_INSTAGRAM_CLIENT_ID, INSTAGRAM_CLIENT_ID, META_APP_ID | SOCIAL_INSTAGRAM_CLIENT_SECRET, INSTAGRAM_CLIENT_SECRET, META_APP_SECRET | public_profile,instagram_basic,instagram_content_publish,pages_show_list | |
| TikTok | SOCIAL_TIKTOK_CLIENT_ID, TIKTOK_CLIENT_ID, TIKTOK_CLIENT_KEY | SOCIAL_TIKTOK_CLIENT_SECRET, TIKTOK_CLIENT_SECRET | user.info.basic,video.publish,video.upload |
| Threads | SOCIAL_THREADS_CLIENT_ID, THREADS_CLIENT_ID, META_APP_ID | SOCIAL_THREADS_CLIENT_SECRET, THREADS_CLIENT_SECRET, META_APP_SECRET | threads_basic,threads_content_publish |
SOCIAL_REDIRECT_BASE overrides the OAuth callback base without a redeploy. The default is https://metricvaultai.com for any *.metricvaultai.com or *.metricvaultai.pages.dev host; otherwise the request's own origin is used. The callback shape is <base>/api/social/callback/<platform>. GET /api/diag/social prints the exact redirect_uri each platform will send, which is the value that must be whitelisted with the provider.
WEBFLOW_CLIENT_ID and SHOPIFY_CLIENT_ID are checked for presence only. The publish path is hard-disabled and always returns { "ok": false, "enabled": false, "status": "draft", "error": "Publishing is not enabled yet. Once your admin finishes connecting a platform, drafts are pushed as unpublished (never live)." }.
Higgsfield (AI image provider)#
| Name | Purpose | Default |
|---|---|---|
AI_IMAGE_PROVIDER | Does not choose anything. Blog images always come from Higgsfield, and there is no fallback provider. /api/diag/blog-image only echoes this value as provider_env | - |
HIGGSFIELD_API_KEY | Static API key path. When set, the token keep-alive job is skipped | - |
HIGGSFIELD_ACCESS_TOKEN | Pre-minted access token | - |
HIGGSFIELD_REFRESH_TOKEN | Seed refresh token. Rotates on every use | - |
HIGGSFIELD_CLIENT_ID | OAuth client id for the token family | - |
HIGGSFIELD_TOKEN_URL | Token endpoint override | built-in |
HIGGSFIELD_MCP_URL | MCP endpoint override | built-in |
HIGGSFIELD_IMAGE_TOOL | MCP tool name | generate_image |
HIGGSFIELD_IMAGE_MODEL | Model id | nano_banana_pro |
HIGGSFIELD_IMAGE_LABEL | Label recorded on the produced asset | nano_banana_pro |
HIGGSFIELD_RESOLUTION | Output resolution | 2k |
HIGGSFIELD_TIMEOUT_MS | Poll deadline for one generation | 45000 |
A provider counts as configured when HIGGSFIELD_API_KEY, or both HIGGSFIELD_REFRESH_TOKEN and HIGGSFIELD_CLIENT_ID, or HIGGSFIELD_ACCESS_TOKEN is present.
Warning: Higgsfield rotates its refresh token on every renewal and has reuse detection. If two applications share one token family, whichever renews first invalidates the other permanently. This is why /api/diag/blog-image is read-only and never calls the refresh path, and why tests/higgsfield-auth.mjs performs a fresh dynamic client registration so Metric Vault has its own client id.
GitHub Actions#
| Name | Kind | Used by | Consequence if missing |
|---|---|---|---|
CLOUDFLARE_API_TOKEN | Repository secret | deploy.yml | The deploy step fails and nothing ships |
CLOUDFLARE_ACCOUNT_ID | Repository secret | deploy.yml | The deploy step fails. It also supplies the account id that Pages config cannot hold |
MV_INTERNAL_SECRET | Repository secret | cron.yml | The workflow prints ::error::MV_INTERNAL_SECRET repo secret is not set. Add it and match it in Cloudflare Pages env. and exits 1 |
CRON_TARGET_URL | Repository variable | cron.yml | Falls back to https://metricvaultai.com |
.dev.vars — local values#
.dev.vars sits at the repository root, is read by wrangler pages dev, and is git-ignored. The names present locally today are:
DATAFORSEO_LOGIN, DATAFORSEO_PASSWORD, OPENAI_API_KEY, ANTHROPIC_API_KEY,
GEMINI_API_KEY, PERPLEXITY_API_KEY, PSI_API_KEY, SUPABASE_URL,
SUPABASE_ANON_KEY, MV_INTERNAL_SECRET, BLOG_CONSOLE_TOKEN, BLOG_PLATFORM_URL,
AI_IMAGE_PROVIDER, HIGGSFIELD_ACCESS_TOKEN, HIGGSFIELD_REFRESH_TOKEN,
HIGGSFIELD_CLIENT_ID, LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET,
X_CLIENT_ID, X_CLIENT_SECRET, FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET,
INSTAGRAM_CLIENT_ID, INSTAGRAM_CLIENT_SECRET, THREADS_CLIENT_ID,
THREADS_CLIENT_SECRET, TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRETDeliberately not present locally, so those features cannot be exercised without adding them yourself: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SUPABASE_SERVICE_KEY, RESEND_API_KEY, PEXELS_API_KEY, DEEPL_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, ADMIN_EMAILS.
Runtime configuration that is not an environment variable#
Some behavior is tunable without a deploy, through the D1 table platform_config. Rows are read through mvConfigAll, cached in memory, and the cache is invalidated on write.
| Key | Effect |
|---|---|
cache_days_default | Default cache lifetime in days for any tool without its own override |
cache_days:<tool> | Per-tool cache lifetime override. Resolution order is per-tool, then default, then the caller's built-in |
tool_off:<type> | 1 disables one tool. /api/premium-ai then returns 503 { "error": "This tool is temporarily unavailable. Please try again shortly." } |
suspended:<email> | 1 suspends an account. Gated calls return 403 with code account_suspended |
ai_reco_min_plan | Minimum plan for AI recommendations. Default pro |
ai_reco_quota:<plan> | Monthly AI-recommendation allowance for that plan. Defaults are starter 30, pro 100, agency 400, enterprise 2000, unlimited uncapped |
Only an owner can write these. A non-owner receives 403 { "error": "Only an owner can change config." }, and every write is recorded in the admin audit log as config.set or config.delete. The customer-facing view of this screen is Runtime configuration.
Known discrepancies#
Recorded so nobody re-derives them from a stale comment.
| Claim | Reality |
|---|---|
A comment in wrangler.toml says /api/translate "fails open and returns the source text when env.AI is absent" | It returns 503 Translation not configured. Enable the [ai] Workers AI binding or set a DEEPL_API_KEY secret. It does short-circuit and return the input unchanged when the source and target languages are the same |
QUICKVIEW_CACHE is declared as a KV binding | Nothing reads it. The D1 table quickview_cache is used instead |
TOKEN_SECRET appears in the codebase | Only in a comment. Nothing reads it |
See also
Was this article helpful?
Thanks — feedback noted for the docs team.