Skip to content
Metric VaultHelp Center
Open app

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.

WhereWhat it holdsSurvives a deploy?
wrangler.tomlBindings only: D1, KV, R2, Workers AI. Committed to the repositoryYes, it is the deploy input
Cloudflare Pages project metricvaultai → Settings → Environment variablesEvery API key, every secret, every tunable stringYes. wrangler.toml never touches them
.dev.vars at the repository rootThe same names, local values only. Git-ignoredLocal only, never deployed
D1 table platform_configRuntime tunables changed from the admin consoleYes, 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

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.

BindingTypeIdentifierPurposeMissing-value behavior
MONITOR_DBD1metricvault-monitor, id 91cb3504-650c-4633-9e25-fc4c07a95698Almost all persistent state: billing, usage, Library, caches, telemetry, integrationsFails hard. monRequireDB throws MONITOR_DB not bound. Wrangler may need a redeploy.
QUICKVIEW_CACHEKV08fb0f9f15d7424bbf4d672b7b491814Declared for the extension quickview cacheUnused. 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_MEDIAR2metricvault-blog-mediaBlog images: uploads and AI-generated covers/api/diag/blog-image reports blocker: "media_not_configured (BLOG_MEDIA R2 bucket not bound)"
AIWorkers 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
ASSETSPagesautomaticStatic file serving in advanced modeNot configurable
Warning

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

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 trigger
  • POST /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:

#LocationHow
1Cloudflare Pages project metricvaultai → Settings → Environment variablesThe dashboard
2The cron-worker Workercd cron-worker && wrangler secret put MV_INTERNAL_SECRET
3GitHub repository secrets, for the backup schedulerRepository → 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:

WhereWhat you see
GitHub Actions cron workflow::error::Cron trigger returned HTTP 401 (expected 200).
cron-worker manual GETHTTP 502 with {"ok":false,"status":401,...}
Any /api/diag/* probe403 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#

NamePurposeFail mode
ADMIN_EMAILSComma-separated extra admin addresses, appended to the seeded list albertdbrown85@gmail.com and metricvaulttestacc@gmail.comOptional. Absence means only the seeded addresses plus rows in the admin_users table are admins
BLOG_CONSOLE_TOKENConsole token for the standalone blog platform's /v1/admin/* API. Never reaches the browserRequired for blog platform provisioning and proxying
TOKEN_SECRETNot used. It appears only in a comment. Google OAuth tokens are stored base64-obfuscated, not encryptedNone

Data providers and AI#

NamePurposeWhat breaks without itFails
DATAFORSEO_LOGIN + DATAFORSEO_PASSWORDHTTP Basic auth to api.dataforseo.com/v3/*, the real SEO data layer behind most toolsFetchers 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 scrapeOpen in most tools, closed on those routes
OPENAI_API_KEYThe GPT calls behind /api/premium-ai, /api/analyst and the tier-2 jobTwo 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_URLOverrides the OpenAI base URL. The default is a Cloudflare AI Gateway URL chosen so egress leaves from an OpenAI-supported regionOptional. Set it to https://api.openai.com/v1 to bypass the gatewayOpen
ANTHROPIC_API_KEYClaude: blog writer, fact-check, humanize, titles, alt text, translations, web search, Growth Actions503 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_MODELOverrides the default Claude model for the generic helper. Default claude-sonnet-5Falls back to the defaultOpen
GEMINI_API_KEYGemini 1.5 Flash provider for AI-visibility measurementThe 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_KEYPerplexity provider, plus fact-check and citation lookups503 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_KEYAlternative translation provider. A key ending :fx selects https://api-free.deepl.com, otherwise https://api.deepl.comFalls back to Workers AI. If neither is present /api/translate 503sOpen, given AI
PSI_API_KEY, PSI_API_KEY_2, PSI_API_KEY_3, PSI_API_KEY_4Google PageSpeed Insights keys, rotated for quota headroomOn 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_KEYStock-photo searchFalls back to Openverse automaticallyOpen
RESEND_API_KEYAll transactional and newsletter emaildispatchAlerts 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_EMAILSender address for blog and newsletter mail. Default Metric Vault Blog <blog@metricvaultai.com>Default usedOpen
MVB_PUBLIC_API_BASEPublic API base advertised to embedded blogs. Default https://metricvaultai.comDefault usedOpen
MVB_WEB_SEARCH_TOOLAnthropic web-search tool id. Default web_search_20260209Default usedOpen
MVB_IMAGE_BUDGET_MSWall-clock budget for blog image generation. Default 75000Default usedOpen
BLOG_PLATFORM_URLBase URL of the standalone blog platform. Default https://sales-85-corp-blogs.brownene3.workers.devDefault usedOpen

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.

VariableFallback chain
AI_TEXT_MODELclaude-opus-4-8 - the base default for most blog and article calls
AI_CHECK_MODELAI_TEXT_MODEL then claude-opus-4-8
AI_EXCERPT_MODELAI_TEXT_MODEL then claude-haiku-4-5
AI_HUMANIZE_MODELAI_TEXT_MODEL then claude-opus-4-8
AI_FACTCHECK_MODELAI_TEXT_MODEL then claude-opus-4-8
AI_TITLES_MODELAI_TEXT_MODEL then claude-opus-4-8
AI_LINKS_MODELAI_TRANSLATE_MODEL then claude-haiku-4-5
AI_TRANSLATE_MODELReferenced only as the fallback for AI_LINKS_MODEL
AI_ALT_MODELclaude-haiku-4-5

Billing#

NamePurposeFail mode
STRIPE_SECRET_KEYEvery Stripe REST call: checkout, portal, billing syncmvStripeApi 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_SECRETHMAC-SHA256 verification of POST /api/stripe/webhook over ${timestamp}.${rawBody}, with a 300-second replay toleranceFails 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.

VariableFallback price id
STRIPE_PRICE_STARTER_MONTHLYprice_1TdWGqLldgJv5lq6D4ssnIZn
STRIPE_PRICE_STARTER_ANNUALprice_1TdWKKLldgJv5lq6SgqxClRJ
STRIPE_PRICE_PRO_MONTHLYprice_1TdWLaLldgJv5lq6ysAhbHB8
STRIPE_PRICE_PRO_ANNUALprice_1TdWM5LldgJv5lq66gHi9WAq
STRIPE_PRICE_AGENCY_MONTHLYprice_1TdWNQLldgJv5lq63yA9Hcli
STRIPE_PRICE_AGENCY_ANNUALprice_1TdWOGLldgJv5lq6rIumUMxf
STRIPE_PRICE_ENT_MONTHLYprice_1TdWOnLldgJv5lq6lUauQTIx
STRIPE_PRICE_ENT_ANNUALprice_1TdWPHLldgJv5lq6lAIco4oP

mvStripePriceToPlan also maps retired price ids, so a SKU rename never downgrades an existing subscriber.

Authentication#

NamePurposeFail mode
SUPABASE_URLSupabase project base. A hardcoded default (https://cnjycxhmnepvqsuyrwem.supabase.co) is used at four call sitesDefault used
SUPABASE_SERVICE_KEYService-role key for analysis_history reads and writessaveToSupabase returns silently. /api/history returns 500 { "error": "History not configured" }
SUPABASE_ANON_KEYThe 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 JWTHardcoded anon key used
GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRETGoogle 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.

PlatformClient id names, in orderClient secret names, in orderScopes
LinkedInSOCIAL_LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_IDSOCIAL_LINKEDIN_CLIENT_SECRET, LINKEDIN_CLIENT_SECRETopenid profile email w_member_social
XSOCIAL_X_CLIENT_ID, X_CLIENT_ID, TWITTER_CLIENT_IDSOCIAL_X_CLIENT_SECRET, X_CLIENT_SECRET, TWITTER_CLIENT_SECRETtweet.read tweet.write users.read offline.access
FacebookSOCIAL_FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_ID, META_APP_IDSOCIAL_FACEBOOK_CLIENT_SECRET, FACEBOOK_CLIENT_SECRET, META_APP_SECRETpublic_profile,pages_show_list,pages_manage_posts,pages_read_engagement
InstagramSOCIAL_INSTAGRAM_CLIENT_ID, INSTAGRAM_CLIENT_ID, META_APP_IDSOCIAL_INSTAGRAM_CLIENT_SECRET, INSTAGRAM_CLIENT_SECRET, META_APP_SECRETpublic_profile,instagram_basic,instagram_content_publish,pages_show_list
TikTokSOCIAL_TIKTOK_CLIENT_ID, TIKTOK_CLIENT_ID, TIKTOK_CLIENT_KEYSOCIAL_TIKTOK_CLIENT_SECRET, TIKTOK_CLIENT_SECRETuser.info.basic,video.publish,video.upload
ThreadsSOCIAL_THREADS_CLIENT_ID, THREADS_CLIENT_ID, META_APP_IDSOCIAL_THREADS_CLIENT_SECRET, THREADS_CLIENT_SECRET, META_APP_SECRETthreads_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)#

NamePurposeDefault
AI_IMAGE_PROVIDERDoes 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_KEYStatic API key path. When set, the token keep-alive job is skipped-
HIGGSFIELD_ACCESS_TOKENPre-minted access token-
HIGGSFIELD_REFRESH_TOKENSeed refresh token. Rotates on every use-
HIGGSFIELD_CLIENT_IDOAuth client id for the token family-
HIGGSFIELD_TOKEN_URLToken endpoint overridebuilt-in
HIGGSFIELD_MCP_URLMCP endpoint overridebuilt-in
HIGGSFIELD_IMAGE_TOOLMCP tool namegenerate_image
HIGGSFIELD_IMAGE_MODELModel idnano_banana_pro
HIGGSFIELD_IMAGE_LABELLabel recorded on the produced assetnano_banana_pro
HIGGSFIELD_RESOLUTIONOutput resolution2k
HIGGSFIELD_TIMEOUT_MSPoll deadline for one generation45000

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

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#

NameKindUsed byConsequence if missing
CLOUDFLARE_API_TOKENRepository secretdeploy.ymlThe deploy step fails and nothing ships
CLOUDFLARE_ACCOUNT_IDRepository secretdeploy.ymlThe deploy step fails. It also supplies the account id that Pages config cannot hold
MV_INTERNAL_SECRETRepository secretcron.ymlThe 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_URLRepository variablecron.ymlFalls 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:

text
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_SECRET

Deliberately 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.

KeyEffect
cache_days_defaultDefault 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_planMinimum 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.

ClaimReality
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 bindingNothing reads it. The D1 table quickview_cache is used instead
TOKEN_SECRET appears in the codebaseOnly in a comment. Nothing reads it

See also

Was this article helpful?