Internal endpoints
The operator-only routes protected by MV_INTERNAL_SECRET, what each one does, and why they fail closed with 503 when the secret is not set.
Last updated 2026-08-06
Summary#
A small set of routes are not for customers at all. They drive background work, run reconciliation, and complete operator sign-ins. They are protected by a single shared secret, MV_INTERNAL_SECRET, and the check fails closed: if the secret is not configured in the environment, every one of them refuses every call with HTTP 503. This page lists each route, its contract, and the operational rules around the secret.
Warning: These endpoints bypass the ordinary per-account gates. Anyone holding the secret can trigger platform-wide work. Treat it as a production credential of the highest sensitivity.
Overview#
Why they exist#
The site is deployed as a Cloudflare Pages project. Pages never invokes a Worker's scheduled() handler, because cron triggers are a Workers-only feature. The Worker does define scheduled(), and it would run four jobs if it were ever called, but on Pages it is not.
Background work therefore has to be pushed rather than pulled. An external scheduler calls a secured HTTP endpoint, and the Worker runs whatever is due. That endpoint is POST /api/cron/run.
The same secret then serves two further needs: an internal HTTP handoff that gives a long article job its own execution budget, and a small number of operator actions that must never be reachable from a customer session.
The two gate shapes#
There are two implementations of the check, and they answer differently. Knowing which is which saves a confusing debugging session.
| Gate | Accepts | Missing secret | Wrong secret |
|---|---|---|---|
| Standard internal gate | Header x-mv-internal-secret, or ?key= on a GET | 503 {"error":"Internal jobs are not configured (MV_INTERNAL_SECRET is not set)."} | 401 {"error":"Unauthorized internal call"} |
| Diagnostic gate | ?key= only | 403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store | 403 {"ok":false,"error":"forbidden"} |
The standard gate protects /api/cron/run and /api/billing/sync. The diagnostic gate protects /api/dbgdfs, everything under /api/diag/, and the operator blog sign-in. The diagnostic gate is deliberately uniform so it leaks nothing: an unset secret and a wrong key look identical from outside.
The secret must be set in three places#
MV_INTERNAL_SECRET must hold the same value in all three:
| Location | How to set it |
|---|---|
| The Pages project environment | Settings → Environment variables |
| The standalone cron Worker | cd cron-worker && wrangler secret put MV_INTERNAL_SECRET |
| The repository secrets, for the backup scheduler | Repository settings → Secrets → MV_INTERNAL_SECRET |
If AI article generation suddenly returns 503, or background jobs stop running, a missing or mismatched value here is by far the most likely cause.
POST /api/cron/run#
| Property | Value |
|---|---|
| Methods | POST and GET |
| Authentication | Header x-mv-internal-secret, or ?key= |
| Query parameter | ?job=<name> runs one job. Omit it to run all |
| Response | Immediate, while the jobs continue in the background |
| Idempotent | Yes. Jobs self-throttle in the database |
The job registry#
| Job | What it does | Its own cadence |
|---|---|---|
monitor | Runs due Competitor Monitor checks and dispatches alerts | Each monitored URL's interval, 1 to 48 hours |
schedules | Advances due scheduled reports, up to 20 per run | Each schedule's next run time |
rank_alerts | Re-checks enabled rank alerts, up to 30 per run | Every 24 hours per alert |
tier2 | Measures AI visibility for tracked brands, up to 5 per run | About every 22 hours |
crawl | Advances any site crawl still running, one bounded slice per pass | Every pass |
sweep_orphans | Clears rows left behind by a deleted website: rank alerts and scheduled reports that would still spend and still send | Each run |
purge_results | Deletes saved tool results past the 90-day retention window | Each run |
purge_watchtower | Bounds the Competitor Monitor tables, in chunks, so a backlog drains over several runs | Each run |
sweep_caches | Deletes expired rows from the nine cache tables. Each keeps its own TTL; chunked, so a backlog drains over several runs | Each run |
higgsfield | Refreshes the image provider's operator token before it expires | Each run |
social_schedule | Publishes queued social posts whose time has arrived, up to 25 per run | Each run |
blog_schedule | Flips scheduled blog posts to published once their time has passed | Each run |
cms_schedule | Publishes CMS posts (WordPress, Ghost, Webflow) whose scheduled time has arrived | Each run |
billing_sync | Reconciles stored plans against live Stripe subscriptions | Each run |
google_sync | Pulls Search Console data day by day into gsc_daily, bounded per run | Each run |
ga4_sync | Pulls Google Analytics 4 into ga4_daily, bounded per run | Each run |
gsc_inspect | Checks index status for the top pages of each property, 10 per run against Google's 2,000/day cap | Each run |
gsc_vitals | Collects Core Web Vitals field data for the site and its busiest pages | Each run |
backlink_watch | Diffs the referring-domain set for each website, weekly, and records which domains were gained and lost | Each run |
Each job is wrapped individually, so one failing cannot abort the others. The handler returns immediately and lets the work continue in the background, exactly as a native cron trigger would.
Requests#
# Run everything that is due
curl -sS -X POST https://metricvaultai.com/api/cron/run \
-H "x-mv-internal-secret: $MV_INTERNAL_SECRET"
# Run one job
curl -sS -X POST "https://metricvaultai.com/api/cron/run?job=rank_alerts" \
-H "x-mv-internal-secret: $MV_INTERNAL_SECRET"
# GET form, handy from a browser
curl -sS "https://metricvaultai.com/api/cron/run?key=$MV_INTERNAL_SECRET"Responses#
| HTTP | Body | Meaning |
|---|---|---|
| 200 | {"ok":true,"triggered":["monitor","schedules",…],"at":"2026-08-06T09:00:00.000Z"} | Jobs were started. triggered lists them |
| 400 | {"error":"Unknown job: <name>","valid":["monitor","schedules","rank_alerts","tier2","crawl","sweep_orphans","purge_results","purge_watchtower","sweep_caches","higgsfield","social_schedule","blog_schedule","cms_schedule","billing_sync","google_sync","ga4_sync","gsc_inspect","gsc_vitals","backlink_watch"]} | The ?job= name is not in the registry |
| 401 | {"error":"Unauthorized internal call"} | The secret did not match |
| 503 | {"error":"Internal jobs are not configured (MV_INTERNAL_SECRET is not set)."} | The secret is not configured |
Important: A 200 means the jobs were started, not that they finished or succeeded. The response is sent before the work completes by design. Judge success from the admin job console and the error console, not from this response. See Background jobs console and Error log and resolution.
The schedulers#
| Driver | Cadence | Role |
|---|---|---|
The standalone cron Worker in cron-worker/ | Every 15 minutes | Primary. A real Worker, so cron triggers work. Deployed separately with cd cron-worker && wrangler deploy |
The repository workflow .github/workflows/cron.yml | Hourly, at 17 minutes past | Backup. Catches up if the primary is undeployed |
Running both is harmless: the jobs decide their own real cadence, and calling the endpoint more often than anything is due simply does nothing.
The cron Worker also answers a plain GET as a manual health check, forwarding the secret and returning the Pages response. It holds no business logic and no database binding on purpose.
The workflow can be run by hand from the Actions tab, optionally naming a single job in its job input.
Removed: POST /api/article-process#
This route, together with POST /api/article-start and GET /api/article-status, was removed on 2026-09-01. Nothing in the product called it. The Article Writer runs through POST /api/premium-ai like every other dashboard tool.
It is documented here because the reason it was removed is a rule worth keeping:
An internal secret authorises the call, not the caller.
/api/article-start took a request from anyone — it ran no identity check — and then fetched /api/article-process on the platform's own behalf, presenting MV_INTERNAL_SECRET. That synthetic request was built fresh, so it carried no Authorization header. By the time the AI quota check ran, the only address left anywhere in the call was the one the original, anonymous caller had typed into the request body. The gate accepted it, and the generation was billed to whichever customer the body named.
The secret was doing its job the whole time. It proved the call came from inside the platform, which was true. It could not prove anything about who asked for it, and nothing else in the chain was carrying that.
So: when a handoff needs to run work on a user's behalf, forward something the receiver can verify — the caller's own bearer token, or a signed claim minted after the identity check. A field in the request body is not an identity, and an internal secret does not turn it into one.
POST /api/billing/sync#
| Property | Value |
|---|---|
| Method | POST |
| Authentication | Standard internal gate |
| Also runs as | The billing_sync background job |
| Purpose | Reconciles stored plan entitlements against live Stripe subscriptions |
This is the backstop for the Stripe webhook. It pages through subscriptions 100 at a time, up to 40 pages, so it covers up to 4,000 subscriptions in one run. A subscription counts as entitling when its status is active, trialing or past_due.
Two safety properties matter:
- Revocations only follow a complete scan. If the scan does not finish, the run grants only and reports
revoke_skipped: "incomplete scan — granted only, no revocations". - Only Stripe-owned records are revoked. A plan granted manually or comped by support is never touched.
curl -sS -X POST https://metricvaultai.com/api/billing/sync \
-H "x-mv-internal-secret: $MV_INTERNAL_SECRET"Response, pretty-printed:
{
"ok": true,
"scanned": 812,
"granted": 3,
"unchanged": 806,
"revoked": 2,
"skipped_no_email": 1,
"errors": [],
"scan_complete": true
}| HTTP | Body | Meaning |
|---|---|---|
| 200 | The summary above | The run completed |
| 200 | {"ok":false,"errors":["STRIPE_SECRET_KEY is not set"]} | Billing is not configured in this environment |
| 500 | {"ok":false,"error":"sync failed"} or the underlying message | The run threw |
GET /api/blog/higgsfield/signin and /callback#
| Property | Value |
|---|---|
| Method | GET |
| Authentication | ?key=<MV_INTERNAL_SECRET>, diagnostic-style gate |
| Failure | Plain text 403 Forbidden with Cache-Control: no-store |
The blog's AI image provider is authorised as one platform-wide operator session, not per customer. There is a single stored credential row shared by every account. That is exactly why this flow is gated on the operator secret rather than on a customer session: a customer must never be able to reach it.
The sign-in route performs dynamic client registration, generates a PKCE verifier and challenge, stores a random state server-side, and redirects to the provider. States older than 15 minutes are pruned. The callback consumes the single-use state, exchanges the code, stores the tokens, and renders an HTML result page.
Related: the higgsfield background job keeps that token alive rather than racing live user requests, and /api/diag/blog-image?probe=1 reports the stored token's state without refreshing it. See Diagnostic endpoints.
Diagnostic routes#
Every route under /api/diag/ plus /api/dbgdfs sits behind the diagnostic gate. They are documented in full in Diagnostic endpoints, including which ones spend real money when called.
Operational notes#
Rotating the secret#
Rotation is a coordinated change across three systems, and there is no overlap window: the Worker compares against exactly one value.
- Generate a new random value of at least 32 characters.
- Set it in the Pages project environment and redeploy or wait for propagation.
- Set it on the cron Worker with
wrangler secret put MV_INTERNAL_SECRET. - Set it in the repository secrets.
- Verify with a manual call:
curl -sS -X POST https://metricvaultai.com/api/cron/run -H "x-mv-internal-secret: $NEW"should return{"ok":true,…}.
Between steps 2 and 4 the backup scheduler will fail its calls with 401. That is expected and harmless, because the primary scheduler covers the window and the jobs are idempotent.
What breaks if it is unset#
| Symptom | Cause |
|---|---|
| Competitor Monitor stops detecting changes | /api/cron/run returns 503, so no job ever runs |
| Rank alerts stop firing | Same |
| Scheduled reports never advance | Same |
| Plans drift from Stripe after a missed webhook | billing_sync never runs |
Every diagnostic returns {"ok":false,"error":"forbidden"} | The diagnostic gate fails closed |
Verifying the whole chain#
# 1. The Pages endpoint accepts the secret
curl -sS -X POST https://metricvaultai.com/api/cron/run \
-H "x-mv-internal-secret: $MV_INTERNAL_SECRET"
# 2. One job in isolation
curl -sS -X POST "https://metricvaultai.com/api/cron/run?job=monitor" \
-H "x-mv-internal-secret: $MV_INTERNAL_SECRET"
# 3. The diagnostic gate agrees with the same value
curl -sS "https://metricvaultai.com/api/diag/psi?key=$MV_INTERNAL_SECRET"If step 1 returns 503, the Pages environment variable is missing. If it returns 401, the value you sent does not match the one deployed. If steps 1 and 3 disagree, you have two different values in play.
Do not add a cron trigger to the Pages configuration#
Adding a [triggers] crons block to the Pages project's wrangler.toml will not work. Cron is a Workers feature, and this is a Pages project. The standalone cron Worker exists precisely because that is true.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.