Skip to content
Metric VaultHelp Center
Open app

Diagnostic endpoints

Every /api/diag/ probe and the raw data-provider debug route, what each one checks, what it costs to call, and how to read the output.

Last updated 2026-09-14

Summary#

Ten operator routes exist to answer one question quickly: is this dependency actually working right now, in this environment? Each probe exercises a real upstream rather than reporting a cached opinion, so a green result means the thing genuinely works. Every one of them is gated on MV_INTERNAL_SECRET supplied as ?key=, and several spend real money each time they are called.

Overview#

The gate#

text
GET https://metricvaultai.com/api/diag/<probe>?key=<MV_INTERNAL_SECRET>

Every route under /api/diag/ and the route /api/dbgdfs share one blanket check, applied before the individual handlers run. It accepts the secret only as the key query parameter, never as a header, and it fails closed.

ConditionResponse
Secret not configured in the environment403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store
Wrong key403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store
Correct keyThe probe runs

The two failures are deliberately identical, so an outsider cannot tell a misconfigured environment from a wrong guess.

Note

Note: Some probes carry a source comment describing them as safe to call unauthenticated. That is historical. The blanket gate came later and overrides it. Every diagnostic requires the key today.

Responses are pretty-printed JSON with Access-Control-Allow-Origin: *. Methods are not restricted; a GET is the usual form.

Cost and side effects#

Read this before pointing a monitor at any of these.

ProbeReal side effectSafe to poll?
/api/diag/psiNone. Reads configuration onlyYes
/api/diag/blog-imageNone without probe=1. With it, one read-only database lookupYes
/api/diag/socialAbout a dozen unauthenticated requests to the six platforms: token exchanges with a code that cannot exist, Meta app and TikTok client-credentials tokens, and LinkedIn's authorization page. Nothing is created and no customer token is usedYes
/api/diag/subsNone. Read-only database queriesYes
/api/diag/websearchOne real web-search model callNo
/api/diag/openaiOne real model call, five tokensSparingly
/api/diag/emailOne real email is sentNo
/api/diag/aiwriteOne real model call, up to 1,500 tokensNo
/api/diag/stockOne real stock-photo provider searchSparingly
/api/diag/keywordgapTwo billable search-data provider callsNo
/api/dbgdfsOne billable search-data provider callNo

None of them consume customer credits. The cost lands on the platform's provider bills instead.


GET /api/diag/psi#

Reports which PageSpeed Insights keys are loaded, without revealing them.

ParameterRequiredDefault
keyYes
json
{
  "keys": [
    { "name": "PSI_API_KEY",   "loaded": true,  "length": 39, "prefix": "AIza..." },
    { "name": "PSI_API_KEY_2", "loaded": true,  "length": 39, "prefix": "AIza..." },
    { "name": "PSI_API_KEY_3", "loaded": false, "length": 0,  "prefix": null },
    { "name": "PSI_API_KEY_4", "loaded": false, "length": 0,  "prefix": null }
  ],
  "totalLoaded": 2
}

Only the first four characters of a key are echoed, which is enough to tell two keys apart without disclosing either. Use this when PageSpeed results are failing or rate-limited: more loaded keys means more provider headroom.


GET /api/diag/websearch#

Runs one real web-search model call and reports whether it succeeded.

ParameterRequiredDefault
keyYes
json
{
  "anthropic_key": true,
  "tool": "web_search_20260209",
  "model": "claude-opus-4-8",
  "ok": true,
  "sources_found": 3,
  "reply": "https://www.anthropic.com"
}

On failure it returns ok: false, the exact upstream error, and a hint naming the two environment variables that select the tool version and the model. This is the probe to run when the blog's fact-check or originality checks fail: a wrong tool version shows up here immediately and unambiguously.


GET /api/diag/openai#

Runs one real five-token completion against OpenAI and reports whether the call could leave from a region OpenAI serves.

Most of the platform's AI copy, and the whole Analyst AI assistant, run on OpenAI rather than on Claude. /api/diag/websearch therefore reports a perfectly healthy system while every assistant message fails, which is exactly what happened to a tester in Venezuela. Run this one for anything that uses the assistant.

ParameterRequiredDefault
keyYes
json
{
  "openai_key": true,
  "colo": "CCS",
  "visitor_country": "VE",
  "egress_via": "us-pinned proxy",
  "direct_base": "https://gateway.ai.cloudflare.com/v1/.../openai/chat/completions",
  "openai_base_url_set": false,
  "proxy_colo": "IAD",
  "proxy_measures": "egress (accurate)",
  "proxy_pinned_ok": true,
  "ok": true,
  "status": 200,
  "reply": "ok"
}

Read it in this order:

  • region_blocked: true means OpenAI refused on geography. That is a routing fault on the platform side and never anything the user did. It appears in the app as a message saying the provider refused the request because of the region it was sent from.
  • colo is the datacenter serving the visitor. proxy_colo is where the call to OpenAI actually leaves from. They are supposed to differ: the visitor stays close to home and only the AI call travels.
  • proxy_pinned_ok: false with the two colos equal means the pin is not being applied and the proxy is running in the same place as the caller, which solves nothing.
  • direct_base names the fallback door, not where the call leaves from. A Cloudflare AI Gateway address there does not mean the egress is US: the gateway runs at the edge like everything else.

GET /api/diag/email#

Sends one real email through the email provider and reports exactly what the provider said.

ParameterRequiredDefaultNotes
keyYes
toNodelivered@resend.devThe provider's safe test address
batchNoOffAny value switches to the batch endpoint the newsletter uses
json
{
  "resend_key": true,
  "from": "Metric Vault Blog <blog@metricvaultai.com>",
  "to": "delivered@resend.dev",
  "mode": "single (/emails — used by the welcome email)",
  "ok": true,
  "status": 200,
  "id": "9f1c8b7e-3a24-4d5f-9c11-0a7b2e6d4f83"
}

With no key configured it returns ok: false and RESEND_API_KEY is not set in this environment. On a provider rejection it returns the provider's own message plus a hint about verifying the sender domain, which is the usual cause.

Test both modes. The welcome email uses the single endpoint and the newsletter uses the batch endpoint, and they can fail independently.


GET /api/diag/aiwrite#

Mirrors the blog article writer's exact model call, including model, effort and response schema, and returns the raw upstream error when it fails.

ParameterRequiredDefault
keyYes
json
{
  "anthropic_key": true,
  "model": "claude-opus-4-8",
  "status": 200,
  "ok": true,
  "reply_snippet": "{\"title\":\"A Short Guide to Better Coffee\",\"excerpt\":\"...",
  "parseable": true,
  "stop_reason": "end_turn"
}
FieldMeaning
parseableWhether the returned text parsed as JSON. A false here explains a "Generation failed" in the app
stop_reasonmax_tokens here means the token budget is the constraint, not the prompt
error_type / errorPresent instead of the success fields when the call was rejected

With no key configured it returns {"anthropic_key": false, "ok": false, "error": "ANTHROPIC_API_KEY not set"}.


GET /api/diag/stock#

Runs a real stock-photo search through whichever provider is configured.

ParameterRequiredDefault
keyYes
qNooffice
json
{
  "pexels_key": true,
  "provider": "pexels",
  "result_count": 15,
  "error": null,
  "http_status": 200
}

provider reports what was actually used. Without a Pexels key the search falls back to Openverse, so a working search with pexels_key: false is a configuration finding, not a failure.


GET /api/diag/blog-image#

Reports whether AI image generation can work. There is one provider, Higgsfield (nano_banana_pro); the Workers AI fallback was removed, so when Higgsfield is not configured nothing else is tried.

ParameterRequiredDefaultNotes
keyYes
probeNoOffprobe=1 also reports the stored operator token's state
json
{
  "provider_env": "(unset)",
  "selected_provider": "higgsfield (nano_banana_pro) only; fallback removed",
  "higgsfield_configured": true,
  "r2_bucket_bound": true,
  "anthropic_key": true,
  "blocker": null,
  "higgsfield_token": {
    "has_credential": true,
    "cached_access_valid": false,
    "access_expires_at": 1754500000000,
    "note": "no valid cached access token — the next generation will attempt a refresh; if the refresh token is expired/revoked it will fail until HIGGSFIELD_REFRESH_TOKEN is renewed"
  }
}

blocker is the useful field. It is null when generation should work, or one of:

blockerMeaning
higgsfield_not_configured (set HIGGSFIELD_API_KEY, or HIGGSFIELD_REFRESH_TOKEN + HIGGSFIELD_CLIENT_ID, or HIGGSFIELD_ACCESS_TOKEN)The image provider has no credential, so generation cannot run
media_not_configured (BLOG_MEDIA R2 bucket not bound)Images can be generated but not stored
Important

Important: The probe=1 check is deliberately read-only. The image provider rotates its refresh token on every use, so exercising the credential here could invalidate the live one. The probe reports the stored state rather than testing it.


GET /api/diag/subs#

Lists every blog site with its owner and per-site subscriber counts. This exists because newsletter delivery has one recurring failure mode: the public blog embed and the studio's selected site must be the same site, or a send reaches nobody who actually signed up.

ParameterRequiredDefault
keyYes
emailNo
json
{
  "ok": true,
  "note": "The public /blog embed and the Studio send-site must share one site_id. Match the embed data-site to the site whose owner_email is your Studio login.",
  "sites": [
    {
      "site_id": "s_9f2a",
      "name": "Acme Insights",
      "slug": "acme-insights",
      "owner_email": "owner@acme.example",
      "subscribers": { "confirmed": 412, "pending": 0, "unsubscribed": 19, "total": 431 },
      "published_posts": 34,
      "posts_newsletter_sent": 28
    }
  ]
}

Adding ?email=someone@example.com appends an email_lookup object showing which sites that address is subscribed to and with what status. This route exposes owner addresses and subscriber counts, which is exactly why it carries its own second key check on top of the blanket gate.

With the database unbound it returns {"ok": false, "error": "MONITOR_DB not bound"}.


GET /api/diag/keywordgap#

Runs the same search-data calls that the Gap Finder tool uses and surfaces the raw provider status codes, so an all-zero result can be traced to its real cause.

ParameterRequiredDefault
keyYes
target1Nonike.com
target2Noadidas.com
json
{
  "creds_present": true,
  "target1": "nike.com",
  "target2": "adidas.com",
  "domain_intersection": {
    "http_status": 200,
    "api_status_code": 20000,
    "task_status_code": 20000,
    "cost": 0.0102,
    "result_present": true,
    "items_count": 20,
    "total_count": 18422
  },
  "domain_rank_overview_t1": { "http_status": 200, "task_status_code": 20000, "items_count": 0 },
  "diagnosis": "Intersection returned 20 items (total_count 18422) — backend has data; if the UI shows 0 the issue is client-side",
  "ok": true
}

The diagnosis field does the interpretation for you. It distinguishes a provider task error such as insufficient funds or a missing product subscription from a genuinely empty intersection, and from a backend that has data while the interface shows none.

Warning

Warning: This probe makes two billable provider calls every time it is called. Never poll it.

Without credentials it returns {"creds_present": false, "ok": false, "error": "DATAFORSEO_LOGIN/PASSWORD not set in this environment"}.


GET /api/diag/social#

Reports, for each of the six social platforms, whether OAuth credentials are configured, which environment variable name resolved, the exact redirect URI the connect flow will send, whether a Meta Login for Business configuration is in use, and the scopes requested. For LinkedIn it also checks, live, whether LinkedIn will accept the company Page scopes. It then asks each configured platform, live, whether it accepts the credentials, and for the Meta app reports which publishing permissions Meta does not list as approved. It never returns a secret value.

This is the probe to run when a customer reports a channel row reading Setup needed: that row is configured: false here, and the entry names every environment variable the platform accepts.

ParameterRequiredDefault
keyYes
json
{
  "oauth_base": "https://metricvaultai.com",
  "redirect_base_env_set": false,
  "token_storage": "…",
  "platforms": {
    "linkedin": {
      "label": "LinkedIn",
      "configured": true,
      "client_id": "78xxxxxxxxxxxx",
      "client_id_env": "SOCIAL_LINKEDIN_CLIENT_ID",
      "client_secret_env": "SOCIAL_LINKEDIN_CLIENT_SECRET",
      "accepted_id_envs": ["SOCIAL_LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_ID"],
      "accepted_secret_envs": ["SOCIAL_LINKEDIN_CLIENT_SECRET", "LINKEDIN_CLIENT_SECRET"],
      "redirect_uri": "https://metricvaultai.com/api/social/callback/linkedin",
      "config_id_env": null,
      "accepted_config_id_envs": null,
      "sends": "scope",
      "org_pages": "not approved by LinkedIn - request the Community Management API product",
      "scopes": "…",
      "auth_url": "…",
      "credentials": {
        "result": "valid",
        "detail": "LinkedIn accepted the client id and the redirect_uri. It checks the secret only when a real connection finishes."
      }
    }
  },
  "summary": "4 of 6 platforms configured; credentials accepted by 4, refused by 0, not checkable for 0"
}
FieldMeaning
oauth_baseThe base every callback address is built from
redirect_base_env_setWhether SOCIAL_REDIRECT_BASE overrides that base
token_storageWhether connected customers' tokens are encrypted at rest
configuredBoth a client id and a client secret resolved. false is what the customer sees as Setup needed
client_idThe client id that resolved. It is public by design, since every authorize URL carries it. Match it against the app in the provider's developer portal
client_id_env, client_secret_envThe variable name that resolved, or null
accepted_id_envs, accepted_secret_envsEvery name the platform accepts, in the order they are tried
redirect_uriThe exact callback address to register with the provider
config_id_env, accepted_config_id_envsFacebook and Instagram only: which Login for Business configuration variable resolved, and the names accepted
sendsconfig_id (Login for Business) when a configuration id is set, otherwise scope. With a configuration, Meta grants that configuration's permission set and ignores scopes
org_pagesLinkedIn only. available - company Pages will be offered on the next connect, not approved by LinkedIn - request the Community Management API product, could not check: <reason>, or not checked when no LinkedIn client id is set
scopes, auth_urlWhat the connect flow requests, and where it sends the customer
credentials.resultvalid, rejected or unknown, from asking the platform. Present only on configured platforms. unknown means the platform could not be reached or answered in a shape not seen before
credentials.detailWhat the answer proves. For X, TikTok, Facebook and Instagram, valid proves the id and the secret. For LinkedIn it proves the client id and the redirect_uri, and for Threads the client id; those two check the secret only when a real connection finishes. A LinkedIn rejected naming the redirect_uri means that address is not registered on the app
credentials.meta_appFacebook and Instagram only: app_name, people_with_a_role (a count, never who), approved_permissions, missing_for_publishing, a note when a publishing permission is missing, and login_config
summary<n> of 6 platforms configured; credentials accepted by <a>, refused by <r>, not checkable for <u>

The redirect_uri is the field to reach for first. A mismatch between that value and the one registered with the provider is the usual cause of a blocked or rejected connection. Copy it verbatim into the provider's allow-list. See Connecting social accounts.

When every platform reads valid and customers still cannot connect Facebook or Instagram, read credentials.meta_app. An empty approved_permissions with a note means Meta has not approved the Page and Instagram permissions in App Review, so only the people with a role on the Meta app can connect, and everyone else is turned away on Meta's own screen. login_config reading Meta would not read configuration is not a fault on its own: the app token the check uses may not be allowed to read Login for Business configurations.

When a Facebook or Instagram connect is refused because the login returned no Page or Instagram account, read sends and config_id_env for that platform. If both platforms resolve the same configuration variable, one of them is asking Meta with the other product's permission set.

The instagram entry always describes the Facebook-login route, even when MV_INSTAGRAM_LOGIN switches connects to Instagram's own login.


GET /api/dbgdfs#

A raw probe against any search-data provider endpoint, returning item counts and the first and last item so you can see the actual shape of a response.

ParameterRequiredDefaultNotes
keyYes
endpointYesProvider path, for example dataforseo_labs/google/domain_rank_overview/live
targetNoAdded to the request body when present
keywordNoAdded when present
keywordsNoComma-separated, split into an array
date_from, date_toNoAdded when present
language_nameNoEnglishSent only for Labs, Keywords Data and SERP endpoints
location_codeNo2840Same
limit, depthNoAdded as numbers when present
json
{
  "endpoint": "dataforseo_labs/google/domain_rank_overview/live",
  "body": { "target": "example.com", "language_name": "English", "location_code": 2840 },
  "result_count": 1,
  "items_count": 1,
  "first_item": { },
  "last_item": { },
  "result0_keys": ["se_type", "location_code", "language_code", "items", "items_count"],
  "result0_meta": { "date_from": null, "date_to": null, "items_count": 1, "target": "example.com", "total_count": null }
}
HTTPBodyCause
400{"error":"missing endpoint"}No endpoint parameter
500{"error":"no credentials"}Provider credentials are not configured
500{"error":"<message>"}The call threw
Warning

Warning: Each call is a real, billable provider request. Use it to answer a specific question, then stop.


A triage order that works#

When something is broken and you do not yet know what, this sequence narrows it fastest:

SymptomProbe firstThen
Search-data tools return zeros/api/diag/keywordgapRead diagnosis. A task code of 40000 or above is a provider-side account problem
A specific tool's numbers look wrong/api/dbgdfs with that tool's endpointCompare items_count with what the interface shows
PageSpeed results fail or throttle/api/diag/psiCheck totalLoaded
Newsletter or invite emails never arrive/api/diag/emailThen /api/diag/email?batch=1
Subscribers exist but do not receive a send/api/diag/subsConfirm the embed and the studio use the same site
Blog generation fails/api/diag/aiwriteCheck parseable and stop_reason
Fact-check or originality fail/api/diag/websearchCheck the tool version and model in the hint
The Analyst AI assistant fails for one country/api/diag/openaiRead region_blocked, then compare colo with proxy_colo
Blog images fail/api/diag/blog-image?probe=1Read blocker, then the token note
A social connection is blocked/api/diag/socialCompare redirect_uri with the provider's allow-list
A channel row reads Setup needed/api/diag/socialRead configured and accepted_id_envs for that platform
LinkedIn company Pages do not appear in the account list/api/diag/socialRead org_pages
A provider says the app, client id or secret is invalid/api/diag/socialRead credentials.result and credentials.detail for that platform
Only a few people can connect Facebook or Instagram/api/diag/socialRead credentials.meta_app.note and missing_for_publishing
Stock photos return nothing/api/diag/stockCheck which provider answered

If every probe is green and the product is still failing, the fault is in the application layer rather than a dependency. Move to the error console described in Error log and resolution and the troubleshooting path in Diagnosing a problem.

See also

Was this article helpful?