Error codes
Every error code and HTTP status the Metric Vault API returns, with the exact message, what caused it, and what to do about it.
Last updated 2026-08-06
Summary#
This is the complete error catalogue for the Metric Vault HTTP API. It covers the machine-readable code values, the HTTP statuses they arrive with, the exact message text, the cause, and the fix. Support engineers can match a customer's screenshot to a row here; developers can build retry logic from the "Retry" column without guessing.
Overview#
The two error shapes#
Most of the API returns a flat object:
{ "error": "Human-readable message" }Gated failures add a machine-readable code and, where it helps, structured context:
{
"error": "You've used all 500 premium reports on the PRO plan this month. Light tools (keyword research, SERP, content, technical audits) keep working. Quota resets on September 1, 2026 (in 26 days). Upgrade to continue using premium reports now.",
"code": "quota_exceeded",
"plan": "pro",
"used": 500,
"quota": 500,
"cost": 6,
"reset_at": "2026-09-01T00:00:00.000Z",
"reset_date": "September 1, 2026",
"days_until_reset": 26,
"upgrade_url": "https://metricvaultai.com/index.html#pricing"
}The blog sub-router uses a nested shape instead:
{ "error": { "code": "forbidden", "message": "Your blog role does not allow this action (postWrite)." } }Tip: Branch on code when it is present and fall back to error when it is not. Never match on the message text: messages are localised in the interface and their wording can change, while codes are stable.
Reading a response#
erroris always a string, except on the blog sub-router where it is an object.codeis present only on gated failures. Its absence is normal.- Some endpoints report failure with HTTP
200. Those are listed separately below; checkokbefore you check the status.
HTTP status codes#
| Status | Meaning in this API | Retry? |
|---|---|---|
200 | Success, or a soft failure carrying ok: false. Check the body | Not applicable |
201 | Created. Blog site and taxonomy creation only | No |
301 | Permanent redirect. Legacy paths only | Follow it |
400 | The request was wrong: missing field, bad value, unsupported type | No, fix the request |
401 | Not authenticated: missing, invalid, expired or revoked credential | No, fix the credential |
403 | Authenticated but not allowed: wrong plan, wrong role, suspended account, wrong secret | No, change the entitlement |
404 | The resource does not exist, or does not belong to the caller | No |
405 | Method not allowed. Blog site console only | No |
409 | Conflict: already invited, or a newsletter already sent | No, or resend with force |
410 | Gone. A share link that has passed its expiry | No |
413 | Payload too large. Blog media import only | No, send a smaller file |
429 | Rate limited or out of credits | Yes, after the stated window |
500 | An unhandled error inside the handler, or an upstream failure | Once |
502 | An upstream fetch failed. Responsive analyzer and growth actions | Once |
503 | A dependency is unavailable or unconfigured, and the gate failed closed | Yes, with backoff |
504 | An upstream fetch timed out. Responsive analyzer preview only | Once |
Note: The blog sub-router deliberately rewrites 502 to 503 before responding, because the platform replaces a Worker 502 body with its own plain-text page and the JSON error would be lost.
Machine-readable error codes#
Authentication and entitlement#
code | HTTP | Message | Cause | Fix | Retry |
|---|---|---|---|---|---|
auth_required | 401 | Please sign in to run this. | A metered call arrived with no identifiable account | Sign in, or send user_email | No |
auth_required | 401 | Please sign in to use recommendations. | Same, from the AI recommendations gate | Sign in | No |
account_suspended | 403 | This account is suspended. Please contact support. | An administrator suspended the account | Contact support@metricvaultai.com | No |
upgrade_required | 403 | This feature requires the <Tier> plan. Your account is on <Plan>. Upgrade to unlock it. | The account's plan is below the feature's minimum | Upgrade. required_plan names the tier | No |
upgrade_required | 403 | This tool needs a paid plan. Free includes the 10 technical SEO tools; upgrade to Pro to unlock the rest. | A Free account ran a tool that costs credits | Upgrade to a paid plan | No |
upgrade_required | 403 | Team invites require the Pro plan or higher. Your account is on Starter. | The plan has no team seats | Upgrade to Pro or above | No |
upgrade_required | 403 | Team seat limit reached (<cap> members on the <Plan> plan). Upgrade for more seats. | Every seat is taken. Pending invites occupy a seat | Remove a member or upgrade | No |
reco_upgrade_required | 403 | AI recommendations are available on the <Plan> plan and above. Your account is on <Plan>. Upgrade to turn any result into a step-by-step action plan. | Get Recommendations requires Pro or above by default | Upgrade | No |
forbidden | 403 | Your blog role does not allow this action (<capability>). | The blog role lacks that capability | Ask the site owner to raise your role | No |
forbidden | 403 | Only the account owner can create blog sites. / Only the account owner can change blog settings. | A member attempted an owner-only blog action | Ask the owner | No |
forbidden | 403 | {"ok":false,"error":"forbidden"} | A diagnostic route was called without the correct ?key= | Supply the internal secret | No |
no_session | 401 | Sign in required | The blog API could not resolve an actor | Send user_email, x-mv-user or ?user_email= | No |
Quotas and rate limits#
code | HTTP | Message | Cause | Fix | Retry |
|---|---|---|---|---|---|
quota_exceeded | 429 | You've used all <N> premium reports on the <PLAN> plan this month. … | The month's credit allowance is spent | Wait for reset_date, or upgrade | After reset |
quota_exceeded | 429 | Premium reports aren't included on the <PLAN> plan. Light tools (keyword research, SERP, content, technical audits) stay free. Upgrade to a paid plan to run premium reports. | The plan has a zero credit allowance | Upgrade | No |
reco_quota_exceeded | 429 | You've used all <N> AI recommendations on the <PLAN> plan this month. Your quota resets on <date> (<when>). Upgrade to get more right away. | The monthly recommendations allowance is spent | Wait for the reset, or upgrade | After reset |
hourly_rate_limit | 429 | Hourly fair-use limit reached (100 light-tool calls/hour). This protects our infrastructure from abuse while keeping your monthly usage unlimited. Please wait ~<N> minutes for the next hourly reset. | More than 100 light-tool calls in one clock hour | Wait reset_in_minutes | Yes |
Quota responses also carry plan, used, quota, cost, reset_at, reset_date, days_until_reset and upgrade_url. Rate-limit responses carry limit, used and reset_in_minutes. See Rate limits and quotas.
Dependency and configuration failures#
Every one of these means the platform refused rather than guessed. They are transient in the first three cases and operational in the rest.
code | HTTP | Message | Cause | Fix | Retry |
|---|---|---|---|---|---|
quota_check_failed | 503 | Usage check temporarily unavailable, please retry. | The usage lookup failed. The gate fails closed | Retry with backoff | Yes |
plan_check_failed | 503 | Plan check temporarily unavailable, please retry. | The plan lookup failed. The gate fails closed | Retry with backoff | Yes |
seat_check_failed | 503 | Could not verify your team seat limit, please retry. | The seat count could not be read | Retry | Yes |
stripe_not_configured | 503 | Billing is not configured yet. | The billing secret is not set in this environment | Operator action | No |
webhook_not_configured | 503 | Webhook is not configured (STRIPE_WEBHOOK_SECRET is not set). | The webhook signing secret is missing | Operator action | No |
portal_not_configured | 503 | The Stripe billing portal has no configuration, and it could not be created automatically: <detail> | The billing portal has no configuration and auto-creation failed | Operator action | No |
no_customer | 400 | No subscription found for this account yet. | The address has never had a subscription | Subscribe first | No |
not_configured | 503 | Originality checking is not configured (DataForSEO credentials are not set). | The search-data credentials are missing | Operator action | No |
not_configured | 503 | Fact-checking is not configured (the Perplexity key is not set). | The research provider key is missing | Operator action | No |
ai_unavailable | 503 | Fact-checking is temporarily unavailable (no AI provider responded). | Claim extraction failed on every provider | Retry later | Yes |
ai_not_configured | 503 | AI translation is not configured (ANTHROPIC_API_KEY is missing). | The model key is missing | Operator action | No |
email_not_configured | 503 | Email sending is not configured yet (RESEND_API_KEY is missing). | The email provider key is missing | Operator action | No |
media_not_configured | 503 | Image storage is not set up yet (the BLOG_MEDIA R2 bucket is not bound). | The object store is not bound | Operator action | No |
send_failed | 503 | The email provider did not accept the messages. Please try again. | The email provider rejected the batch | Retry | Yes |
Input validation#
code | HTTP | Message | Cause | Fix |
|---|---|---|---|---|
too_short | 400 | Paste at least a couple of sentences to check. | Fewer than 25 words submitted to originality or fact-check | Send more text |
no_sentences | 400 | No checkable sentences were found in this text. | No extractable sentences in the input | Send prose, not fragments |
bad_request | 400 | site_id is required | A blog write arrived without a site | Send site_id or the x-mv-site header |
bad_request | 400 | user_email and member_email are required | A blog role change was missing fields | Send both |
bad_request | 400 | Cannot set a member's role to 'owner'. | Ownership is intrinsic and not assignable | Use editor or author |
bad_locale | 400 | Provide a language code like es, fr, de, or ja. | The locale did not match a two-letter code with optional region | Send a valid code |
same_locale | 400 | The post is already in that language. | Translation target equals the source | Pick another locale |
not_published | 400 | Publish the post before sending it to subscribers. | A newsletter was requested for a draft | Publish first |
no_subscribers | 400 | No confirmed subscribers to send to yet. | The site has no confirmed subscribers | Collect subscribers first |
no_recipients | 400 | None of the selected addresses are confirmed subscribers of this website. | The supplied address list matched nobody | Check the addresses |
create_failed | 400 | Name required / Author name required | A taxonomy item was created without a name | Send a name |
bad_form | 400 | Expected multipart/form-data | A media upload was not multipart | Use multipart |
no_file | 400 | No file uploaded | The multipart body had no image or file part | Attach the file |
bad_type | 400 | Use PNG, JPG, WEBP, or GIF | Unsupported image type | Convert the image |
too_large | 400 | Max 5MB | A media upload exceeded 5 MB | Compress the image |
too_large | 413 | Image is larger than 15MB | A media import by URL exceeded 15,000,000 bytes | Use a smaller source |
blocked_host | 400 | That image host is not allowed | The import URL resolved to a blocked or private host | Use a public host |
subscribe_failed | 400 | Please enter a valid email address | The subscription address failed validation | Correct the address |
bad_method | 405 | Method not allowed | An unsupported method on the blog site console | Use GET, POST or PATCH |
Not found and conflict#
code | HTTP | Message | Cause | Fix |
|---|---|---|---|---|
not_found | 404 | That person is not a member of your team. | A blog role change targeted a non-member | Invite them first |
not_found | 404 | No such post | Unknown post id | Check the id |
not_found | 404 | No route for <METHOD> /api/blog/<rest> | Unknown blog sub-route | Check the path |
not_found | 404 | No public route <rest> | Unknown public blog sub-route | Check the path |
site_not_found | 404 | No such site | The site id or slug does not exist | Check the identifier |
post_not_found | 404 | No such post | The public post slug or id does not exist, or the post is not live | Publish it, or check the slug |
already_sent | 409 | This post was already emailed to subscribers on <YYYY-MM-DD>. | The newsletter was already delivered | Send again with force if intended |
Errors without a code#
The public API and key management#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 401 | Missing API key. Send header: Authorization: Bearer mv_live_... | No Authorization header, or a different scheme | Send the header |
| 401 | Invalid or revoked API key | Unknown, truncated or revoked key | Verify the key, or create a new one |
| 400 | Provide a JSON body: { "url": "example.com" } | Neither url nor domain was present | Send one of them |
| 401 | Unauthorized | The session token on a /api/keys/* call was missing, expired, or belongs to an unconfirmed address | Sign in again and take a fresh token |
| 400 | Key limit reached (5 active keys). Revoke one first. | Five active keys already exist | Revoke one |
| 400 | id required | Revoke was called without an id | Take the id from the list response |
Tools#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | Missing type | /api/tools was called with no type | Send a tool type |
| 400 | Missing url | /api/tools was called with no url | Send a URL |
| 400 | Invalid tool type: <type> | The type is not one of the 18 allowed values | Use a listed type |
| 400 | Invalid URL | The URL could not be parsed | Send a valid absolute or bare URL |
| 400 | Only http(s) URLs are allowed | A non-HTTP scheme was supplied | Use http or https |
| 400 | That host is not allowed | The host resolved to a private, loopback, link-local or reserved address | Point at a public host |
| 400 | Invalid analysis type: <type> | An unknown type reached the AI tool endpoint | Use a supported type |
| 503 | This tool is temporarily unavailable. Please try again shortly. | An operator has switched that tool off | Wait, or contact support |
| 500 | Failed to parse AI response | The model returned output that could not be parsed. The first 500 characters are echoed as raw | Run again |
The AI provider's own failures are rewritten into five friendly messages before they reach you: a region message asking you to run again, a rate-limit message asking you to wait ten seconds, a quota-exhausted message, an authentication message pointing at support, and a generic AI request failed. Please try again.
Real-data tools with nothing to report#
Thirty tools are marked as real-data tools and never fabricate numbers. When the provider has no data they return HTTP 200 with:
{ "data": { "noDataAvailable": true, "message": "No measured data was found for \"<query>\". This is a real-data tool, so Metric Vault shows nothing here rather than fabricating a number. Check the spelling, or try a more established domain, keyword or topic." } }This is a successful response, not an error. Handle noDataAvailable explicitly.
Billing#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | Invalid plan or billing period selected. | Checkout could not resolve a price from plan and billing | Send a valid pair |
| 400 | Invalid signature | The Stripe webhook signature did not verify, or was more than 300 seconds old | Check the signing secret and clock |
| 400 | unreadable body / invalid JSON | The webhook body could not be read or parsed | Resend |
| 500 | DB unavailable | The database binding was missing during a webhook. Returned so Stripe retries | Operator action |
Team#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | user_email and member_email required | A required field was missing | Send both |
| 400 | You can't invite yourself. | The invite address equals the inviter | Invite someone else |
| 400 | A member cannot be invited as 'owner'. | Ownership is not assignable | Use another role |
| 409 | Already invited or this person is already a member. | A membership row already exists | Nothing to do |
| 404 | Invalid invite — token may have expired or email does not match. | The token and address pair did not match a row | Ask for a fresh invite |
Monitoring, alerts, schedules, sharing#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | That URL host is not allowed | The monitored URL resolved to a private or reserved address | Use a public URL |
| 400 | Webhook host is not allowed | A webhook URL resolved to a private or reserved address | Use a public endpoint |
| 404 | not found | The row does not exist or belongs to another account | Check the id |
| 400 | user_email, domain, keyword required | A rank alert was created without its required fields | Send all three |
| 400 | user_email, workflow_key, target required | A schedule was created without its required fields | Send all three |
| 400 | user_email and title required | An editorial item was created without a title | Send a title |
| 400 | user_email and html_content required | A share link was created with no report | Send the rendered HTML |
| 400 | Report too large — max 2MB | The report HTML exceeded 2,000,000 characters | Share a smaller report |
| 400 | step_key required | A workflow failure was reported without its step | Send step_key |
| 500 | MONITOR_DB not bound. Wrangler may need a redeploy. | The database binding is missing in this deployment | Operator action |
Branding#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | Logo too large — keep it under 500KB | The logo data URL exceeded 520,000 characters | Use a smaller image |
| 400 | Colors must be hex format (e.g. #7c5cfc) | A color was not a 3 to 8 digit hex value | Send hex |
Translation#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | Too many strings (max 100 per request) | More than 100 strings in one call | Split the batch |
| 400 | Payload too large (max 20000 chars per request) | Combined text exceeded 20,000 characters | Split the batch |
| 400 | Unsupported or missing target language | The target was absent or not one of the 20 supported languages | Send a supported code |
| 400 | No text provided | Neither text nor texts was present | Send text |
| 503 | Translation not configured. Enable the [ai] Workers AI binding or set a DEEPL_API_KEY secret. | No translation provider is available | Operator action |
Translation otherwise fails open: if a provider errors, the source string is returned unchanged rather than the request failing.
Responsive analyzer#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 400 | Enter a valid website address, for example example.com | The address could not be parsed | Correct the address |
| 502 | That site took too long to respond. Try again, or check the address. | The fetch timed out | Retry |
| 502 | Could not load that site. It may be offline, blocking automated requests, or the address may be wrong. | The fetch failed | Check the site |
| 502 | That site returned HTTP <n>. Check the address and try again. | The target returned an error status | Check the address |
| 502 | That page returned almost no HTML, so there is nothing to analyze. It may be a redirect or an app shell. | The response body was effectively empty | Point at a rendered page |
| 502 | The AI response was cut off before it finished. Please run the analysis again. | The model response was truncated | Run again |
| 502 | Could not read the AI response. Please run the analysis again. | The model response could not be parsed | Run again |
| 400 | Type a question first. | The follow-up chat was empty | Send a message |
The preview proxy is the one part of the API that answers with a styled HTML card instead of JSON, using 400 Invalid address, 403 Not allowed, 504 Timed out, 502 Cannot be previewed and 415 Not a web page.
Article queue#
| HTTP | Message | Cause | Fix |
|---|---|---|---|
| 503 | Job queue unavailable (D1 binding missing) | The database binding is missing | Operator action |
| 500 | Failed to start article job: <detail> | The job row could not be written | Retry |
| 400 | Missing job id | Status was polled with no id | Send ?id= |
| 404 | Job not found or expired. Please re-submit. | Jobs are pruned after one hour | Start a new job |
| 400 | Missing jobId or body | The internal processing call was incomplete | Internal only |
Administration#
Every /api/admin/* route answers a failed authorisation with HTTP 403 and {"error":"Unauthorized"}, not 401. Beyond that:
| HTTP | Message | Cause |
|---|---|---|
| 401 | token required | /api/admin/data received no token |
| 403 | Only an owner can manage admins. | Owner-only route called by an admin role |
| 403 | Only an owner can manage customers. | As above |
| 403 | Only an owner can change config. | As above |
| 403 | Only an owner can run jobs. | As above |
| 403 | Only an owner can clear the cache. | As above |
| 403 | Only an owner can change cache settings. | As above |
| 403 | Only an owner can moderate posts. | As above |
| 403 | Only an owner can disconnect a channel. | As above |
| 400 | A valid email is required. | A customer or admin action had no valid address |
| 400 | You cannot suspend or demote your own account. | Self-protection guard |
| 400 | This is a protected owner account. | The target is a seeded owner |
| 400 | You cannot remove your own account. | Self-protection guard |
| 400 | Unknown action. / Unknown job. / Unknown plan. | An unrecognized action, job or plan name |
| 400 | days must be between 1 and 365 | Cache lifetime out of range |
| 400 | invalid status | Blog moderation status was not draft, trash or published |
Internal and diagnostic routes#
| HTTP | Body | Cause |
|---|---|---|
| 503 | {"error":"Internal jobs are not configured (MV_INTERNAL_SECRET is not set)."} | The internal secret is not configured. The check fails closed |
| 401 | {"error":"Unauthorized internal call"} | The supplied secret did not match |
| 403 | {"ok":false,"error":"forbidden"} | A diagnostic route was called without a matching ?key= |
| 400 | {"error":"Unknown job: <name>","valid":[…]} | /api/cron/run?job= named a job that does not exist |
See Internal endpoints and Diagnostic endpoints.
Failures that arrive with HTTP 200#
Three places report failure inside a successful response. Check the body.
| Endpoint | Shape | Meanings |
|---|---|---|
/api/library/save-run | {"ok":false,"reason":"<reason>"} | missing_fields, no_db, unserialisable, data_too_large (payload over 1,600,000 characters), failed |
/api/library/saved/html | {"ok":false} or {"ok":false,"reason":"too_large"} | The HTML exceeded 900,000 characters |
/api/publish/push | {"ok":false,…} | WordPress publishes. The other five refuse with a reason: Medium closed its publishing API, and Ghost, Webflow and Shopify are not built yet. No connection returns No WordPress site is connected to this account yet. |
/api/library/saved/check also always returns 200, with {"found":false} when nothing matches. That is not an error.
Fail-open and fail-closed behavior#
Knowing which way a gate falls tells you whether a 503 is safe to retry.
| Gate | On dependency failure | Result |
|---|---|---|
| Plan check | Closed | 503 plan_check_failed. Nothing runs, nothing is charged |
| Credit quota check | Closed | 503 quota_check_failed. Nothing runs, nothing is charged |
| Recommendations quota | Closed | 503 quota_check_failed or 503 plan_check_failed |
| Team seat check | Closed | 503 seat_check_failed |
| Stripe webhook signature | Closed | 503 webhook_not_configured when the secret is unset |
| Internal secret | Closed | 503 when unset, 401 when wrong |
| Hourly rate limiter | Open | A limiter failure allows the call rather than blocking it |
| Translation providers | Open | The source string is returned untranslated |
| Notification preferences | Open | All four preferences default to on |
| Activity logging | Open | Logging never fails a request |
Retry guidance#
| Situation | Action |
|---|---|
503 with plan_check_failed or quota_check_failed | Retry with exponential backoff. Nothing was charged |
429 with hourly_rate_limit | Sleep for reset_in_minutes, then retry |
429 with quota_exceeded | Do not retry. Wait for reset_date or upgrade |
500 from a model call | Retry once. A second failure is worth reporting |
502 or 504 from the responsive analyzer | Retry once |
401 or 403 | Never retry. Fix the credential or the entitlement |
400 | Never retry. Fix the request |
See also
Was this article helpful?
Thanks — feedback noted for the docs team.