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#
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.
| Condition | Response |
|---|---|
| Secret not configured in the environment | 403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store |
| Wrong key | 403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store |
| Correct key | The probe runs |
The two failures are deliberately identical, so an outsider cannot tell a misconfigured environment from a wrong guess.
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.
| Probe | Real side effect | Safe to poll? |
|---|---|---|
/api/diag/psi | None. Reads configuration only | Yes |
/api/diag/blog-image | None without probe=1. With it, one read-only database lookup | Yes |
/api/diag/social | About 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 used | Yes |
/api/diag/subs | None. Read-only database queries | Yes |
/api/diag/websearch | One real web-search model call | No |
/api/diag/openai | One real model call, five tokens | Sparingly |
/api/diag/email | One real email is sent | No |
/api/diag/aiwrite | One real model call, up to 1,500 tokens | No |
/api/diag/stock | One real stock-photo provider search | Sparingly |
/api/diag/keywordgap | Two billable search-data provider calls | No |
/api/dbgdfs | One billable search-data provider call | No |
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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
{
"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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
{
"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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
{
"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: truemeans 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.colois the datacenter serving the visitor.proxy_colois 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: falsewith 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_basenames 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.
| Parameter | Required | Default | Notes |
|---|---|---|---|
key | Yes | — | |
to | No | delivered@resend.dev | The provider's safe test address |
batch | No | Off | Any value switches to the batch endpoint the newsletter uses |
{
"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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
{
"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"
}| Field | Meaning |
|---|---|
parseable | Whether the returned text parsed as JSON. A false here explains a "Generation failed" in the app |
stop_reason | max_tokens here means the token budget is the constraint, not the prompt |
error_type / error | Present 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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
q | No | office |
{
"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.
| Parameter | Required | Default | Notes |
|---|---|---|---|
key | Yes | — | |
probe | No | Off | probe=1 also reports the stored operator token's state |
{
"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:
blocker | Meaning |
|---|---|
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: 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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
email | No | — |
{
"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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
target1 | No | nike.com |
target2 | No | adidas.com |
{
"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: 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.
| Parameter | Required | Default |
|---|---|---|
key | Yes | — |
{
"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"
}| Field | Meaning |
|---|---|
oauth_base | The base every callback address is built from |
redirect_base_env_set | Whether SOCIAL_REDIRECT_BASE overrides that base |
token_storage | Whether connected customers' tokens are encrypted at rest |
configured | Both a client id and a client secret resolved. false is what the customer sees as Setup needed |
client_id | The 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_env | The variable name that resolved, or null |
accepted_id_envs, accepted_secret_envs | Every name the platform accepts, in the order they are tried |
redirect_uri | The exact callback address to register with the provider |
config_id_env, accepted_config_id_envs | Facebook and Instagram only: which Login for Business configuration variable resolved, and the names accepted |
sends | config_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_pages | LinkedIn 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_url | What the connect flow requests, and where it sends the customer |
credentials.result | valid, 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.detail | What 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_app | Facebook 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.
| Parameter | Required | Default | Notes |
|---|---|---|---|
key | Yes | — | |
endpoint | Yes | — | Provider path, for example dataforseo_labs/google/domain_rank_overview/live |
target | No | — | Added to the request body when present |
keyword | No | — | Added when present |
keywords | No | — | Comma-separated, split into an array |
date_from, date_to | No | — | Added when present |
language_name | No | English | Sent only for Labs, Keywords Data and SERP endpoints |
location_code | No | 2840 | Same |
limit, depth | No | — | Added as numbers when present |
{
"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 }
}| HTTP | Body | Cause |
|---|---|---|
| 400 | {"error":"missing endpoint"} | No endpoint parameter |
| 500 | {"error":"no credentials"} | Provider credentials are not configured |
| 500 | {"error":"<message>"} | The call threw |
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:
| Symptom | Probe first | Then |
|---|---|---|
| Search-data tools return zeros | /api/diag/keywordgap | Read 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 endpoint | Compare items_count with what the interface shows |
| PageSpeed results fail or throttle | /api/diag/psi | Check totalLoaded |
| Newsletter or invite emails never arrive | /api/diag/email | Then /api/diag/email?batch=1 |
| Subscribers exist but do not receive a send | /api/diag/subs | Confirm the embed and the studio use the same site |
| Blog generation fails | /api/diag/aiwrite | Check parseable and stop_reason |
| Fact-check or originality fail | /api/diag/websearch | Check the tool version and model in the hint |
| The Analyst AI assistant fails for one country | /api/diag/openai | Read region_blocked, then compare colo with proxy_colo |
| Blog images fail | /api/diag/blog-image?probe=1 | Read blocker, then the token note |
| A social connection is blocked | /api/diag/social | Compare redirect_uri with the provider's allow-list |
A channel row reads Setup needed | /api/diag/social | Read configured and accepted_id_envs for that platform |
| LinkedIn company Pages do not appear in the account list | /api/diag/social | Read org_pages |
| A provider says the app, client id or secret is invalid | /api/diag/social | Read credentials.result and credentials.detail for that platform |
| Only a few people can connect Facebook or Instagram | /api/diag/social | Read credentials.meta_app.note and missing_for_publishing |
| Stock photos return nothing | /api/diag/stock | Check 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?
Thanks — feedback noted for the docs team.