# Current plan and limits
Source: https://metamanager.dev/docs/api-reference/endpoints/current-plan-and-limits
/api-reference/openapi.json get /entitlements
Reports the caller's plan. Anonymous without a session cookie.
# Explain one issue code
Source: https://metamanager.dev/docs/api-reference/endpoints/explain-one-issue-code
/api-reference/openapi.json get /api/issues/{code}
# Health check
Source: https://metamanager.dev/docs/api-reference/endpoints/health-check
/api-reference/openapi.json get /health
# Inspect a URL
Source: https://metamanager.dev/docs/api-reference/endpoints/inspect-a-url
/api-reference/openapi.json get /inspect
Fetch a page, read its metadata, resolve its previews and report its issues.
# List every issue MetaManager checks for
Source: https://metamanager.dev/docs/api-reference/endpoints/list-every-issue-metamanager-checks-for
/api-reference/openapi.json get /api/issues
The rule catalogue: code, title, severity, importance and score weight. Reachable with any key, including Free.
# API overview
Source: https://metamanager.dev/docs/api-reference/overview
Base URL, authentication, and the shape of every response.
```text theme={null}
https://metamanager.dev
```
## Three endpoints
| Endpoint | Purpose |
| :------------------ | :------------------------------- |
| `GET /inspect` | The checker. Everything is here. |
| `GET /entitlements` | Your current plan and limits. |
| `GET /health` | Liveness. |
`/inspect` takes no credentials. See [authentication](/docs/getting-started/authentication)
for what a session adds and why there are no API keys yet.
## Responses are one document
There are no jobs, no polling and no webhooks. A check is a single synchronous
request that returns the whole report — score, metadata, all eight previews and
every issue.
Typical responses are a few hundred milliseconds; a slow target can take up to
the 15-second fetch timeout.
## Two fields are a contract
`issues[].code` and the top-level `error` are stable identifiers. New values get
added; existing ones are not renamed or removed without deprecation, and a guard
test in the codebase makes breaking one deliberate rather than accidental.
Everything else — `title`, `description`, `message`, `basis` — is prose written
for people and may be reworded. Do not match on it.
## Errors do not carry reports
```json theme={null}
{ "error": "http_error", "message": "the site returned HTTP 404", "upstream_status": 404 }
```
An error response has no `score`, `metadata` or `previews`. Check `error` first.
[Every code](/docs/operations/errors).
## Rate limits are on every response
```text theme={null}
x-ratelimit-limit: 50
x-ratelimit-remaining: 48
x-ratelimit-reset: 3593
```
Fifty an hour anonymously. [Details](/docs/operations/rate-limits).
## Caching
Reports are cached for fifteen minutes and `x-cache` says `hit` or `miss`. Add
`fresh=1` to bypass. Cached responses still count against your limit.
# Changelog
Source: https://metamanager.dev/docs/changelog
What changed, and when.
**Brands can be added and edited under Signed cards**, including a logo. Set
`mark_source` to an image address and we fetch it once and keep it, so drawing
a card never waits on your customer's server.
Also fixed: a customer already on a paid plan saw the pricing page mark their
card as theirs while the button still invited them to buy it. It now opens the
billing portal, on every paid plan rather than only Pro.
**Colours and a picture can travel in the signed payload.** Send `bg`, `fg`,
`ac` and `i` and the card draws in those colours with that image, having
registered nothing.
Registering a brand still earns its place for one thing a URL cannot do: carry
a **logo**. A mark is image data rather than an address, so it is resolved once
when you save it and stored — drawing must not need the network. Brands
are also handy if you would rather not repeat the same fields on every page.
Where both exist, the payload wins: it is the most specific and the most
current.
**A card with a photograph on it now comes back as JPEG rather than PNG.** The
same card went from 896 KB to 64 KB — and 896 KB was past the size our own
checker flags as a preview image too heavy, so we were shipping cards
MetaManager would have complained about.
Cards without a photograph are still PNG, which is smaller for flat colour.
The format follows the content.
**The image limit is now 4 MB, up from 1 MB.** One megabyte was a guess and it
was wrong: an ordinary unoptimised stock photo is two to three, so the first
real image anybody tried was quietly refused. When an image still cannot be
used, the reason is now shown — too large, not an image, unreachable
— instead of the card simply appearing without it.
**A fifth layout, `banner`, draws a picture behind your words.** Point `&i=` at
the image you want:
```html theme={null}
```
Explicit on purpose. We do not scan your page for a likely-looking image,
because the first one on a page is a logo or a tracking pixel about as often as
it is the picture you meant, and guessing wrong puts a broken card on a live
page.
Fetched once and cached with the card, so it is one request per page per day
rather than one per share. If it cannot be fetched, the card draws without it.
**Four settings on every card layout:** title size, alignment, whether the
description draws, and whether your mark appears. Set them once in your
account, or override one on a single page with `&ts=l`, `&align=center`,
`&desc=0` or `&mark=0`.
These deliberately stop short of a template editor. Each one changes *how* a
card is drawn and none can change *what* it says — no setting can add
text, an image or a link. That is what lets them ship without the review a
free-form editor would need before anything drawn on our domain could appear
as a preview for somebody else's page.
**A new Business plan, for platforms that draw cards on behalf of their own
customers.** Instead of verifying every customer domain — which does not scale
and cannot cope with a domain added this afternoon — you sign each card on your
server, and the signature authorises it.
Because the payload carries the title, we never fetch the page: cards draw for
pages behind a login or not yet published, and they cache permanently, since a
changed title is a changed signature is a changed URL.
Each of your customers can have their own colours, mark and layout, registered
once and referenced by id. Otherwise all of them would wear yours.
[Cards for your customers →](/docs/guides/og-cards-for-saas)
**Add `&l=` to a card URL to say which language to read the page in.**
```html theme={null}
```
Without it, a site that varies by locale serves whichever language our edge
happens to prefer — which is how a card for a US pricing page came back in
Dutch. We deliberately send no language by default rather than assuming
English, because a default would quietly change what we report for every site
that honours the header.
This chooses the language we **read** in, not the one we draw in. Which
typeface a card uses is decided from the text we actually get back, so a page
tagged `en` that is full of Japanese still renders correctly.
Related: cards now also read a page's own `lang` attribute to tell Traditional
and Simplified Chinese apart. Most Chinese characters are shared between the
two, so the page is the only thing that can say which forms to draw.
**Cards can now use one of four layouts**, chosen under **Social cards** in
your account:
* **Classic** — title and description, with your mark above. The default, and
unchanged: existing cards draw exactly as they did before.
* **Minimal** — the title alone, set large.
* **Split** — an accent panel beside the text. The panel's text colour is
computed from your accent so it stays readable whatever you pick.
* **Quote** — sets the description as a pull quote. Good for writing, less so
for a pricing page.
Pick a default in the dashboard, or override it per page by adding
`&tpl=minimal` to the card URL — so one template can serve your blog and
another your product pages, from the same key.
An unrecognised name falls back to your saved default rather than failing.
Nothing on this service returns an error where it could return a card.
**Cards now draw in Japanese, Chinese, Korean, Cyrillic and Greek.** Until now
the renderer shipped a Latin-only face, so a Japanese or Russian page got a
card full of empty boxes — and got it with a confident `200`, which is the
worst way to be wrong.
* Faces are chosen from the text itself, not from the page's language tag, so
an English-tagged page full of Japanese still draws correctly.
* Traditional and Simplified Chinese are told apart by the page's own `lang`,
since most Han characters are shared and the text alone cannot decide.
**Some scripts are deliberately declined.** Arabic, Hebrew and the Indic
scripts are drawn as a plain card showing the page's address instead of its
title. That is not an oversight and it is not about fonts: our renderer lays
words out left to right and does not reshape them, so Arabic would come out
with each word correct and *the sentence in reverse reading order* — a card
that looks right and reads backwards. We would rather show less than show
something untrue about your page.
Cards that fall back this way carry an `x-mm-degraded` header saying why.
**One line in your template gives every page a link preview image.** The card
is drawn from that page's own title and description, in colours you choose,
with your site's icon as the mark.
```html theme={null}
```
* No backend code and nothing to sign. The key in the URL is public by design;
a card is only drawn for a **domain you have verified**, so a copied key is
useless to whoever copies it.
* Content comes from the page itself, through the same checker you already
use — so the card cannot drift out of step with what it previews.
* A page with no title still gets a card, drawn from its URL. This runs inside
your page, and an error there is a broken preview across every platform.
Free draws **100 cards a month** and marks them; Pro draws **5,000** and does
not. Cards are counted when drawn, not when a link is shared — a page shared a
thousand times costs one card, redrawn about daily so an edited title appears
on its own.
You can see one drawn before signing up — the [home page](https://metamanager.dev/#cards)
takes any URL and returns a card for it. Demo cards use our colours and carry
our mark; yours use yours, and do not.
**New issue code: `og_image_too_heavy`.** Every service that unfurls your link
refetches your `og:image`, and a 1200 by 630 preview usually compresses to well
under 500 KB. Nothing rejects a larger one — the platform limits are several
megabytes — so this is a tip, raised only when the image was actually fetched
and measured.
It carries **no score impact**, deliberately. The score is earned over
applicable weight, so a weighted rule would change the denominator and move
every historical score at once — which audit comparison would report as a
change on every page, and monitoring would email about. A new observation must
not rewrite old measurements.
**Your agent can now drive MetaManager.** A remote MCP server at
`mcp.metamanager.dev` gives an AI agent tools to check a page, read its issues,
apply a fix, and re-check to confirm the score moved.
* Create an API key at your account and send it as a bearer token; the whole
`/api` surface works without a cookie.
* Keys are **read-only** unless you give them write access, because starting an
audit spends a monthly allowance and an agent in a loop will not notice.
* The key is shown once. Only a hash is stored, so it cannot be recovered —
revoke and create another instead.
* Every key on an account shares one hourly bucket. Ten keys are not ten times
the quota.
* A **Free** key is real: single-page checks, fix prompts and the issue
catalogue, at Free's 100 an hour. The heavier tools say they need Pro.
**AI metadata suggestions have been dropped**, not postponed. The fix prompt
already hands your own assistant everything it needs, and generating copy
ourselves would have added cost and put a prompt-injection surface behind our
own key — the pages we read are written by the people we would be reading them
for. Letting your agent use MetaManager is the better shape.
**Hand an audit to somebody who does not have an account.** Open a finished
audit, choose **Share**, and publish a read-only copy at a public link.
* The link works until you revoke it, and revoking takes effect immediately.
* Sharing the same audit twice returns the same link rather than a second one,
so a link can always be found again — and always withdrawn.
* The panel shows how many times the link has been opened.
* Shared reports are served `noindex`; the link itself is the secret.
Free publishes a **summary** — site, date, score and issue categories. Pro adds
the page-by-page breakdown. The limit follows the account rather than the
link, so upgrading widens a link you have already sent.
**Pro can export to PDF**, from the dashboard or from the shared page, without
publishing a link first. The PDF is the report rather than a second rendering
of it: same numbers, navigation removed, page breaks placed so a table does not
split mid-row, and every affected URL printed in full.
**See what changed between two audits.** Open a finished audit and choose
**What changed** to compare it with the previous run of the same site: new
issues, fixed issues, changed tags with their before and after, pages that
appeared, and pages that are gone.
* Compares against the most recent finished audit of the same site, so a
manual run and a scheduled one can be compared with each other.
* Audits crawled before metadata capture existed cannot be diffed tag by tag.
Rather than reporting every tag as changed, the comparison says so and still
reports issue and page changes, which remain accurate.
**Alerts can go somewhere other than the account holder.** Each monitor takes
its own recipient, confirmed by a link valid for 24 hours. Until it is
confirmed, alerts keep going to the account holder.
**Projects now scope the whole dashboard.** A project holds a site's saved
URLs, its audits and its monitors, and the sidebar switcher applies that scope
across every page. Anything filed nowhere is reachable under **Unfiled**.
**Watch a site and get an email when its metadata breaks.** Pro watches up to
three sites, daily or weekly. Scheduled runs are checked every 15 minutes and
do not spend the monthly audit allowance.
* Seven fields are watched per page: `title`, `description`, `canonical`,
`og:title`, `og:description`, `og:image` and `twitter:card`.
* Whitespace is normalised before comparison, so a deploy that reflows
templates is not a change.
* You are emailed for new issues and for pages that have vanished — not for a
reworded description. A site that stays broken produces one email, not one
per run.
* The first run of a monitor never emails: there is nothing to compare it to.
**Crawl a whole site rather than one page.** Free audits 25 pages once a
month; Pro audits 500 pages, 50 times a month. Audits are metered separately
from the hourly check quota, so one large crawl cannot exhaust it.
* `robots.txt` is fetched first and obeyed, including `Crawl-delay`, with
longest-match precedence and `Allow` winning ties.
* One request at a time per audit, with a delay between them, backing off when
a server struggles.
* Same origin as the root URL only, and every discovered URL passes the same
validation that protects `GET /inspect` — a crawler that trusted `href`
values would be an SSRF engine.
* The site score is the unweighted mean of page scores, reported alongside the
three buckets, because the distribution is what a mean hides.
* The bot identifies itself as `MetaManagerBot/1.0` and
[explains itself](https://metamanager.dev/bot).
**Subscriptions, monthly or yearly, through Polar.** Entitlements are resolved
from the account rather than the session, so a plan change takes effect on the
next request.
Pro raises checks to 1,000 an hour and makes saved URLs and projects
unlimited. Everyone keeps the free checker.
**Sign in with an emailed link — no password to choose, store or leak.**
* Save URLs and re-check them from the dashboard: three on Free, unlimited on
Pro.
* Group them into projects: one on Free, unlimited on Pro.
* Signed-in Free accounts get 100 checks an hour, up from the 50 available
without an account.
**`GET /inspect`** — metadata extraction, a weighted 0–100 score, previews for
eight platforms, and 31 issue codes with paste-ready fixes.
* Character references are decoded, so `Ben & Jerry's` reports as
`Ben & Jerry's` and length rules count what a reader sees.
* Social images are fetched and measured from their own header bytes, which is
what makes `image_dimensions_mismatch` detectable.
* Non-2xx pages return a structured error rather than being scored as empty
documents.
* `prompt=` produces a paste-ready AI fix prompt for HTML, Laravel, Next.js,
Nuxt or a framework-agnostic form. Derived after the cache, so switching
framework costs nothing.
* `GET /entitlements` reports the caller's plan and limits.
# Authentication
Source: https://metamanager.dev/docs/getting-started/authentication
The checker is open. What signing in changes, and what is coming.
## The checker needs no credentials
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com'
```
There is no API key to obtain, no header to set, and no signup. This is
deliberate and not a trial: the single-page checker is free and stays free.
Requests are rate limited by IP. See [rate limits](/docs/operations/rate-limits).
## Signing in
Signing in is passwordless. You give an email address, we send a link, the link
signs you in. There is no password to remember and none to leak.
```bash theme={null}
curl -X POST https://metamanager.dev/auth/login \
-H 'content-type: application/json' \
-d '{"email":"you@example.com"}'
```
```json theme={null}
{ "ok": true, "message": "If that address can sign in, a link is on its way." }
```
That response is identical whether the address has an account or not. A
different status or message would turn the login form into a way to test
whether somebody uses MetaManager.
The link is single-use and expires in fifteen minutes. Redeeming it sets a
session cookie — `HttpOnly`, `Secure`, `SameSite=Lax` — and the browser is
redirected so the token does not linger in history or a `Referer` header.
## What a session gets you
Sessions are for the web application. They raise your rate limit, move the
budget from your network to your account, and unlock saved URLs and projects.
```bash theme={null}
curl https://metamanager.dev/entitlements
```
```json theme={null}
{
"plan": "anonymous",
"label": "Anonymous",
"checks_per_hour": 50,
"saved_urls": 0,
"projects": 0,
"api": false
}
```
## Programmatic API access
Create an API key at [your keys](https://metamanager.dev/keys) and send it
as a bearer token:
```bash theme={null}
curl https://metamanager.dev/api/audits \
-H "Authorization: Bearer mm_live_..."
```
A key authenticates the whole `/api` surface with no cookie, which is what lets
an agent or a script use MetaManager at all.
**The key is shown once, when you create it.** Only a hash is stored, so we
cannot show it to you again and cannot recover it for you. If you lose one,
revoke it and create another.
### Read and write
A key is **read-only** unless you deliberately give it write access.
| | Read-only | Read & write |
| :------------------------------------------------ | :-------- | :----------- |
| Check pages, read audits, monitors and saved URLs | Yes | Yes |
| Start an audit, save a URL | — | Yes |
The distinction exists because starting an audit spends one of your monthly
audits. An agent working in a loop can exhaust a month's allowance in a minute
and, unlike a person, will not notice it happening — so that power is something
you hand over on purpose rather than by default.
### What each plan reaches
| | Free | Pro |
| :--------------------------------------- | :--- | :-- |
| Keys | 1 | 10 |
| `/inspect`, fix prompts, issue catalogue | Yes | Yes |
| Audits, monitors, saved URLs | — | Yes |
A Free key is real and useful: it checks single pages at the Free rate of 100 an
hour. The heavier endpoints answer `402` with a message saying so.
### Rate limits follow the account
Every key on an account shares **one** hourly bucket, the same one your browser
uses. Creating more keys does not raise your limit — see
[Rate limits](/docs/operations/rate-limits).
### Revoking
Revoking takes effect immediately, and anything using that key stops working at
once. The key's record is kept, so you can still see that it existed and when it
was last used.
## Connecting an AI agent
If you want an agent to use MetaManager rather than writing code against it, use
the [MCP server](/docs/guides/mcp) — no code either way.
A client that speaks the MCP authorization spec needs **no key at all**: it sends
you to MetaManager to approve, you tick which permissions it gets, and it stores
and refreshes the credential itself. Clients that do not can use an API key as a
bearer token, exactly as above.
The two are independent. Approved connections are listed under
[connected agents](https://metamanager.dev/agents) and API keys under
[your keys](https://metamanager.dev/keys); revoking one leaves the other
working.
# Quickstart
Source: https://metamanager.dev/docs/getting-started/quickstart
One request to a full metadata report. No key required.
## Check a page
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com'
```
No API key, no account, no signup. The free checker is open.
## Read the score first
```json theme={null}
{
"url": "https://example.com",
"final_url": "https://example.com/",
"status_code": 200,
"score": 17,
"metadata": { "…": "…" },
"previews": { "…": "…" },
"issues": [ "…" ]
}
```
`score` is 0–100, weighted by consequence rather than by counting tags. A missing `og:image` costs far more than a missing `og:locale`, because one of them changes whether anybody clicks the link.
## Then read the issues
Each issue tells you what is wrong and what to paste:
```json theme={null}
{
"code": "missing_description",
"severity": "warning",
"importance": "recommended",
"title": "Missing description",
"description": "This page has no meta description. Search engines and social platforms fall back to guessing at page content without one.",
"fix": {
"html": "",
"laravel": "Meta::description('A short summary of the page.');"
}
}
```
`fix` is `null` for issues where no snippet can be correct without knowing your content — a title that is too short cannot be fixed by a generic string.
## Parameters
| Parameter | Values | What it does |
| :-------- | :----------------------------------------- | :------------------------------------------------------------------------------- |
| `url` | any public http/https URL | **Required.** The page to check. |
| `prompt` | `html`, `laravel`, `next`, `nuxt`, `other` | Adds a ready-to-paste AI fix prompt. See [fix prompts](/docs/guides/fix-prompt). |
| `fresh` | `1` | Bypasses the cache. Reports are cached for 15 minutes. |
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com&prompt=next&fresh=1'
```
## Check the response, not just the status
A page that returns 404 or 503 is reported as an error rather than scored as an empty document:
```json theme={null}
{
"error": "http_error",
"message": "the site returned HTTP 404",
"url": "https://example.com/missing",
"upstream_status": 404
}
```
A checker that scored an error page would report "16 tags missing" for a site that is simply down. Always branch on `error` before reading `score`. See [errors](/docs/operations/errors) for every code.
## Rate limits
Fifty checks an hour without an account, keyed by IP. Every response carries the current state:
```text theme={null}
x-ratelimit-limit: 50
x-ratelimit-remaining: 48
x-ratelimit-reset: 3593
```
Signing in raises it to 100 an hour and moves the budget from your network to your account. [Full table](/docs/operations/rate-limits).
## Next
Every field, and what it is for.
All thirty-three, with fixes.
# AI fix prompts
Source: https://metamanager.dev/docs/guides/fix-prompt
Turn a report into a prompt you can paste into any coding assistant.
Add `prompt=` and the report gains a ready-to-paste block for your framework.
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com&prompt=next'
```
## Frameworks
| Value | Produces |
| :-------- | :-------------------------------- |
| `html` | Plain `` tags (the default) |
| `laravel` | `laravel-meta-manager` calls |
| `next` | Next.js Metadata API |
| `nuxt` | `useSeoMeta` |
| `other` | Framework-agnostic instructions |
An unrecognised value falls back to `html` rather than failing the request.
## Why it is derived after the cache
Reports are cached for fifteen minutes; the prompt is built from the cached
report on the way out. Switching framework therefore costs nothing and does not
consume a fresh check — the same cached report serves all five.
## What is in it
The prompt states the page, its score, and each issue with the specific change
required. It is written to be pasted into a coding assistant without editing.
Everything in a report comes from somebody else's page — titles, descriptions
and URLs are attacker-controlled. Values interpolated into the prompt are
flattened to a single line, stripped of control characters and truncated, so
a page cannot inject instructions into a prompt you are about to hand an
agent. It is still worth reading a prompt before running what it suggests.
# Issue codes
Source: https://metamanager.dev/docs/guides/issues
Every issue the checker can report, what it means, and how to fix it.
The checker reports 31 distinct issues. Each carries a stable `code`, a `severity`, an `importance`, and — where a generic fix is possible — a snippet you can paste.
**Issue codes are a public contract.** New codes get added; existing ones are not renamed or removed without a deprecation. Branch on `code`, never on `title` or `description`, which are prose and may be reworded.
## Severity
| Severity | Meaning |
| :-------- | :------------------------------------------ |
| `error` | Something is broken and is costing you now. |
| `warning` | A tag that matters is missing or wrong. |
| `tip` | Worth improving, but nothing is broken. |
## Importance
`required` tags every page needs, `recommended` tags most pages benefit from, `optional` refinements. Importance is about the tag; severity is about your page.
## Score weight
Only some issues move the score, and they move it by different amounts — a missing `og:image` costs more than a missing `og:locale` because one of them decides whether anybody clicks. Issues listed below as "no score impact" are advice, not deductions.
## Core HTML
### `missing_title`
**Missing title** — warning, required, costs 10 points.
This page has no \
. It is the single most important piece of metadata, used by search engines and as the fallback for most social previews.
```html theme={null}
Your page title
```
Laravel: `Meta::title('Your page title');`
### `empty_title`
**Empty title** — warning, required, no score impact.
A \ tag is present but contains no text.
```html theme={null}
Your page title
```
Laravel: `Meta::title('Your page title');`
### `title_too_long`
**Title may be truncated** — tip, recommended, no score impact.
Your title is N characters. Some platforms may truncate titles longer than about 60.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `title_too_short`
**Title is quite short** — tip, recommended, no score impact.
Your title is N characters. Titles under about 30 often leave useful space unused.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `missing_description`
**Missing description** — warning, recommended, costs 10 points.
This page has no meta description. Search engines and social platforms fall back to guessing at page content without one.
```html theme={null}
```
Laravel: `Meta::description('A short summary of the page.');`
### `description_too_long`
**Description may be truncated** — tip, recommended, no score impact.
Your description is N characters. Some platforms may truncate longer descriptions.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `description_too_short`
**Description is quite short** — tip, recommended, no score impact.
Your description is N characters. Descriptions under about 70 often under-use the available space.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `missing_canonical`
**Missing canonical URL** — warning, recommended, costs 5 points.
No canonical URL is declared. Without one, duplicate or parameterised versions of this page may be treated as separate pages.
```html theme={null}
```
Laravel: `Meta::canonical(url()->current());`
### `malformed_canonical`
**Malformed canonical URL** — error, required, no score impact.
The canonical URL is not an absolute http(s) URL. Canonical URLs must be absolute to be understood correctly.
```html theme={null}
```
Laravel: `Meta::canonical(url()->current());`
### `duplicate_canonical`
**More than one canonical URL** — error, required, no score impact.
This page declares 0 canonical URLs. Search engines may ignore all of them.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `missing_favicon`
**Missing favicon** — warning, recommended, costs 5 points.
No favicon link was found. Browsers will fall back to /favicon.ico, which may not exist.
```html theme={null}
```
Laravel: `Meta::favicon('/favicon.ico');`
### `missing_og_title`
**Missing og:title** — warning, recommended, costs 7 points.
Without og:title, social platforms fall back to the page title, which may not be the wording you want when shared.
```html theme={null}
```
Laravel: `Meta::openGraph()->title('Your page title');`
### `missing_og_description`
**Missing og:description** — warning, recommended, costs 7 points.
Without og:description, shared links fall back to the meta description or to nothing at all.
```html theme={null}
```
Laravel: `Meta::openGraph()->description('A short summary of the page.');`
## Open Graph
### `missing_og_url`
**Missing og:url** — warning, recommended, costs 5 points.
og:url tells platforms the canonical address of this page, so shares of parameterised URLs consolidate correctly.
```html theme={null}
```
Laravel: `Meta::canonical(url()->current());`
### `missing_og_type`
**Missing og:type** — tip, recommended, costs 4 points.
og:type describes what this page is. Most pages are "website"; articles benefit from "article".
```html theme={null}
```
Laravel: `Meta::type('website');`
### `missing_og_image`
**Missing og:image** — warning, recommended, costs 10 points.
Without og:image, shared links appear as plain text with no thumbnail, which markedly reduces engagement.
```html theme={null}
```
Laravel: `Meta::image(asset('og.png'), width: 1200, height: 630, alt: 'Description');`
### `missing_og_image_dimensions`
**Missing image dimensions** — tip, recommended, costs 4 points.
og:image:width and og:image:height let platforms reserve layout space before the image loads, avoiding a reflow in the preview.
```html theme={null}
```
Laravel: `Meta::image(asset('og.png'), width: 1200, height: 630);`
### `missing_og_image_alt`
**Missing og:image:alt** — tip, optional, costs 3 points.
Alt text describes the preview image to people using screen readers. Optional, but a small accessibility win.
```html theme={null}
```
Laravel: `Meta::image(asset('og.png'), alt: 'Description of the image');`
### `missing_og_site_name`
**Missing og:site\_name** — tip, optional, costs 2 points.
og:site\_name lets platforms show your site name alongside the shared link. Optional.
```html theme={null}
```
Laravel: `Meta::siteName('Your Site');`
### `missing_og_locale`
**Missing og:locale** — tip, optional, costs 2 points.
og:locale declares the language and region of this content, such as en\_US. Optional.
```html theme={null}
```
Laravel: `Meta::locale('en_US');`
## Twitter Cards
### `missing_twitter_card`
**Missing twitter:card** — warning, recommended, costs 5 points.
Without twitter:card, X may render a smaller preview or none at all. "summary\_large\_image" gives the full-width image card.
```html theme={null}
```
Laravel: `Meta::twitter()->card('summary_large_image');`
### `missing_twitter_title`
**Missing twitter:title** — tip, optional, costs 4 points.
X falls back to og:title when twitter:title is absent, so this only matters if you want different wording there.
```html theme={null}
```
Laravel: `Meta::twitter()->title('Your title');`
### `missing_twitter_description`
**Missing twitter:description** — tip, optional, costs 4 points.
X falls back to og:description when twitter:description is absent.
```html theme={null}
```
Laravel: `Meta::twitter()->description('A short summary.');`
### `missing_twitter_image`
**Missing twitter:image** — tip, optional, costs 5 points.
X falls back to og:image when twitter:image is absent.
```html theme={null}
```
Laravel: `Meta::twitter()->image(asset('og.png'));`
### `missing_twitter_site`
**Missing twitter:site** — tip, optional, costs 2 points.
twitter:site attributes the content to your account handle. Optional.
```html theme={null}
```
Laravel: `Meta::twitter()->site('@yourhandle');`
## Images
### `image_inaccessible`
**Social image could not be loaded** — error, required, costs 2 points.
The image at null could not be loaded (unreachable). Platforms will show your link without a thumbnail.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `image_too_small`
**Social image is small** — tip, recommended, no score impact.
Your image is N by N. Platforms commonly render large cards at around 1200 by 630, and smaller images may be shown as a thumbnail instead.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `image_dimensions_mismatch`
**Declared image dimensions do not match the file** — warning, recommended, no score impact.
The page declares N by N, but the file is N by N. Platforms that trust the declared size may lay the preview out incorrectly.
```html theme={null}
```
Laravel: `Meta::image(asset('og.png'), width: 600, height: 315);`
### `og_image_too_heavy`
**Social image is heavy** — tip, recommended, no score impact.
Your social image is N KB. Nothing will refuse it — the platform limits are several megabytes — but every service that unfurls your link refetches this file, and a 1200 by 630 image usually compresses to well under 500 KB with no visible loss.
Only raised when the image was actually fetched and measured; a declared size proves nothing about the file.
No generic fix: re-exporting or recompressing is specific to your image, so `fix` is `null`.
## Consistency and URLs
### `metadata_url_not_absolute`
**Metadata URL is not absolute** — error, required, costs 3 points.
One or more metadata URLs are relative. Social platforms do not resolve relative URLs and will treat them as missing.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `insecure_metadata_url`
**Metadata URL uses http on an https page** — tip, recommended, no score impact.
This page is served over https but references metadata URLs over http. Some platforms decline to load mixed content.
No generic fix: this one depends on your own content, so `fix` is `null`.
### `metadata_inconsistent`
**Title and og:title differ** — tip, optional, costs 3 points.
Your \ and og:title differ. That is often deliberate, but worth confirming it is intentional.
No generic fix: this one depends on your own content, so `fix` is `null`.
# MCP server
Source: https://metamanager.dev/docs/guides/mcp
Connect your AI agent to MetaManager so it can check pages, read audits and verify its own fixes.
MetaManager runs a remote [MCP](https://modelcontextprotocol.io) server, so an
agent you already use can check pages, read your audits, and confirm that a fix
actually worked.
That last part is the point. An agent can check a page, read exactly what is
wrong, edit your templates, then **check again and see the score move** — a loop
with a verifiable end, rather than a suggestion you have to evaluate yourself.
```text theme={null}
https://mcp.metamanager.dev/mcp
```
## Connecting
Every client needs the same two facts: the address above, and which door you are
coming through.
**OAuth** — the client opens MetaManager in your browser, you tick the
permissions it may have, and the client stores and refreshes the credential
itself. Nothing is written into a config file.
**An API key** — a bearer token you paste, for clients that do not speak OAuth
yet and for scripts of your own.
Prefer OAuth where the client supports it. The credential never lands in a file,
it carries only the permissions you ticked, and revoking it is one click.
Add the server:
```bash theme={null}
claude mcp add --transport http metamanager https://mcp.metamanager.dev/mcp -s user
```
Then run `/mcp` inside Claude Code, pick MetaManager, and approve in the
browser window that opens. From a shell instead:
```bash theme={null}
claude mcp login metamanager
```
**With an API key**, one command does both steps:
```bash theme={null}
claude mcp add --transport http metamanager https://mcp.metamanager.dev/mcp \
--header "Authorization: Bearer mm_live_..." \
-s user
```
With a key, do not use `-s project`. That scope writes `.mcp.json`, which
is meant to be committed and shared — your key would go into version
control with it. `-s user` keeps the key in your own configuration and
makes MetaManager available in every project.
Add the server to `~/.codex/config.toml`:
```toml theme={null}
[mcp_servers.metamanager]
url = "https://mcp.metamanager.dev/mcp"
```
Then sign in:
```bash theme={null}
codex mcp login metamanager
```
Codex treats `auth = "oauth"` as the default for a server given a `url`, so
there is nothing else to set.
**With an API key**, Codex holds the *name* of an environment variable
rather than the key itself:
```toml theme={null}
[mcp_servers.metamanager]
url = "https://mcp.metamanager.dev/mcp"
bearer_token_env_var = "METAMANAGER_API_KEY"
```
```bash theme={null}
export METAMANAGER_API_KEY=mm_live_...
```
`bearer_token_env_var` takes a variable name, not a token. Pasting the key
there sends the literal string `mm_live_...` as your bearer token and every
call comes back unauthenticated.
Add the server to `~/.cursor/mcp.json` for every project, or
`.cursor/mcp.json` for one:
```json theme={null}
{
"mcpServers": {
"metamanager": {
"url": "https://mcp.metamanager.dev/mcp"
}
}
}
```
With no `headers`, Cursor discovers that the server wants OAuth and prompts
you to sign in.
**With an API key**, reference an environment variable rather than pasting
the key:
```json theme={null}
{
"mcpServers": {
"metamanager": {
"url": "https://mcp.metamanager.dev/mcp",
"headers": {
"Authorization": "Bearer ${env:METAMANAGER_API_KEY}"
}
}
}
}
```
`.cursor/mcp.json` sits in the project and is usually committed. Use the
`${env:VAR}` form there, or put the key in `~/.cursor/mcp.json` instead.
Add the server to `.vscode/mcp.json` for one workspace, or run
**MCP: Open User Configuration** from the command palette for all of them:
```json theme={null}
{
"servers": {
"metamanager": {
"type": "http",
"url": "https://mcp.metamanager.dev/mcp"
}
}
}
```
VS Code prompts you to authenticate the first time a tool is used.
The top-level key here is `servers`, not `mcpServers`, and `type` is
required. A file copied from another client's docs will be ignored
silently.
**Settings → AI → MCP Servers → Add Server → Add Remote Server**, or in
`settings.json`:
```json theme={null}
{
"context_servers": {
"metamanager": {
"url": "https://mcp.metamanager.dev/mcp"
}
}
}
```
When a remote server has no `Authorization` header configured, Zed runs the
OAuth flow and prompts you to sign in — so leaving `headers` out is how you
ask for OAuth.
**With an API key**, add the header:
```json theme={null}
{
"context_servers": {
"metamanager": {
"url": "https://mcp.metamanager.dev/mcp",
"headers": { "Authorization": "Bearer mm_live_..." }
}
}
}
```
Add the server to `~/.gemini/settings.json`:
```json theme={null}
{
"mcpServers": {
"metamanager": {
"httpUrl": "https://mcp.metamanager.dev/mcp"
}
}
}
```
Then authenticate:
```text theme={null}
/mcp auth metamanager
```
**With an API key**, add a header instead:
```json theme={null}
{
"mcpServers": {
"metamanager": {
"httpUrl": "https://mcp.metamanager.dev/mcp",
"headers": { "Authorization": "Bearer mm_live_..." }
}
}
}
```
Gemini CLI uses `httpUrl` for streamable HTTP. Plain `url` selects SSE, a
different transport.
Add the server to `~/.codeium/windsurf/mcp_config.json`:
```json theme={null}
{
"mcpServers": {
"metamanager": {
"serverUrl": "https://mcp.metamanager.dev/mcp"
}
}
}
```
Reload the server list in Windsurf's MCP settings, then complete the OAuth
consent to finish connecting.
**With an API key**, add a header:
```json theme={null}
{
"mcpServers": {
"metamanager": {
"serverUrl": "https://mcp.metamanager.dev/mcp",
"headers": { "Authorization": "Bearer ${env:METAMANAGER_API_KEY}" }
}
}
}
```
Windsurf calls the field `serverUrl`, not `url`.
No file to edit. Open **Settings → Connectors → Add custom connector**,
give it a name, and paste:
```text theme={null}
https://mcp.metamanager.dev/mcp
```
Claude runs the OAuth flow and shows you the approval screen. The same steps
work on claude.ai and in the mobile apps, since a custom connector belongs to
your Claude account rather than to one device.
### A client that is not listed
Every client needs the URL and, for a key, an `Authorization: Bearer` header.
What differs is only where the file lives and what the fields are called — which
is where most failed setups come from, because a config copied from another
client's documentation is usually ignored in silence rather than rejected.
| Client | File | Top-level key | URL field |
| :---------- | :------------------------------------ | :------------------- | :---------------------------- |
| Claude Code | `claude mcp add` | — | `--transport http` |
| Codex | `~/.codex/config.toml` | `[mcp_servers.NAME]` | `url` |
| Cursor | `~/.cursor/mcp.json` | `mcpServers` | `url` |
| VS Code | `.vscode/mcp.json` | `servers` | `url` (with `"type": "http"`) |
| Zed | `settings.json` | `context_servers` | `url` |
| Gemini CLI | `~/.gemini/settings.json` | `mcpServers` | `httpUrl` |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` | `serverUrl` |
The bare host without `/mcp` also works, so an existing configuration keeps
running. Other paths do not: a typo returns a 404 naming the right address
rather than appearing to succeed.
## What you are approving
The approval screen lists one permission per checkbox, and you can hand over
less than the client asked for:
| Permission | Covers |
| ---------- | ------------------------------------------------------------------------------------ |
| `checks` | Score a page, explain what is wrong, write the fix. Reads nothing from your account. |
| `read` | Your saved URLs, site audits and monitors. |
| `write` | Starting audits and saving URLs, which spend your monthly allowance. |
Untick anything you would rather not grant. A connection that gets only `checks`
can still do most of what an agent does — check a page and fix it — without
seeing your account at all. Approved connections are listed at
[connected agents](https://metamanager.dev/agents), where revoking one stops it
immediately.
Nothing is shared with the agent beyond your own account. Connections are
per-account and can be revoked at any time without touching your API keys.
## Fixing a missing image
`missing_og_image` is the most common finding, and the usual fix is an image
somebody has to design and host. MetaManager draws it instead, so an agent can
close that loop by itself:
```text theme={null}
check_url → missing_og_image
get_card_snippet →
paste it; each page's card is drawn from its own title
check_url → the issue is gone
```
The domain has to be verified on your account first. If it is not,
`get_card_snippet` says so and where to do it, rather than handing back a line
that would not draw.
## Teach your agent the workflow
Connecting the server gives your agent the tools. A companion **skill** teaches
it when to reach for them — and, more importantly, that a metadata change is not
finished until it has been re-checked.
```bash theme={null}
npx skills add davmixcool/skills --skill metamanager
```
For Claude Code you can also copy it in directly:
```bash theme={null}
cp -r skills/metamanager ~/.claude/skills/
```
The skill is optional. Without it your agent can still call every tool; it just
has to be told what to do with them.
## The tools
| Tool | What it does | Needs |
| :----------------------------- | :---------------------------------------------------------------------- | :------------------------------ |
| `check_url` | Score one page and list its issues | Any plan |
| `get_fix_prompt` | Framework-specific instructions to fix a page | Any plan |
| `explain_issue` | What an issue code means and how much it weighs | Any plan |
| `list_audits` | Audits on the account, newest first | Pro |
| `get_audit` | One audit in full, filterable by issue or severity | Pro |
| `compare_audits` | What changed since the previous run of a site | Pro |
| `start_audit` | Crawl a whole site | Pro, **write** |
| `list_monitors` | Sites watched on a schedule | Pro |
| `list_saved_urls` / `save_url` | Read and add saved URLs | Pro, `save_url` needs **write** |
| `get_card_snippet` | The `og:image` line to paste, drawing a real card for a verified domain | Any plan |
| `list_card_templates` | The layouts this account can use, and which are published | Any plan |
| `describe_card_schema` | The schema a card layout is written in | Any plan |
| `create_card_template` | Create or replace one of your layouts, as a draft | **write** |
| `publish_card_template` | Publish a layout — **changes what live cards draw** | **write** |
## What it costs
`check_url`, `get_fix_prompt` and `explain_issue` draw only on your hourly rate
limit, so an agent can use them freely — including to re-check a page after every
edit, which is exactly what you want it doing.
`start_audit` spends one of your **monthly audits** and takes minutes rather than
seconds. That is why it needs a key with write access: an agent in a loop can
exhaust a month's allowance without noticing, and giving that power away should
be a decision rather than a default.
Every key on your account shares one hourly bucket. Connecting three agents
with three keys does not give you three times the limit.
## What a Free key reaches
Single-page work: `check_url`, `get_fix_prompt` and `explain_issue`, at Free's
100 checks an hour. The Pro tools answer with a message saying they need Pro,
which your agent will report rather than retry.
## If something goes wrong
Errors come back as readable sentences rather than status codes, and say whether
retrying will help:
* **Not authenticated** — the key is missing, wrong, or revoked. Not retryable.
* **This API key is read-only** — the tool needs a key with write access. Not
retryable with that key.
* **This endpoint needs Pro** — not retryable on the current plan.
* **Limit reached** — the hourly rate limit; retryable after waiting.
# Monitoring
Source: https://metamanager.dev/docs/guides/monitoring
Re-audit a site on a schedule and get an email when its metadata breaks.
An audit tells you how a site looks today. A monitor tells you when that
changes — it re-runs the audit on a schedule, compares it to the last one, and
emails you when something breaks.
Monitors are configured in [your dashboard](https://metamanager.dev/audits) and
are a Pro feature.
| | Free | Pro |
| :------------ | ---: | --------------: |
| Sites watched | — | 3 |
| Frequency | — | Daily or weekly |
**Scheduled runs do not spend your monthly audit allowance.** One daily
monitor would otherwise consume thirty of a Pro account's fifty audits and
crowd out checking pages by hand. The cap on how many sites you watch is what
bounds the crawling instead.
## What counts as a change
Seven fields are compared between runs, page by page:
| Field |
| :--------------- |
| `title` |
| `description` |
| `canonical` |
| `og:title` |
| `og:description` |
| `og:image` |
| `twitter:card` |
These are the ones that decide how a page looks when it is shared, or where
search sends people. A change is reported with both values:
```text theme={null}
og:title changed
before: My Product — AI Analytics
after: My Product
```
**Whitespace is not a change.** Values are compared after collapsing runs of
spaces and newlines, so a deploy that reflows your templates does not report
every page as different.
## What earns an email
Not everything that changes. You are emailed when:
* a page gains an issue it did not have
* a page that was reachable no longer is
You are **not** emailed for a reworded description, a retitled page, or an issue
being fixed. Those still appear in the audit; they do not warrant an
interruption.
This restraint is deliberate. Monitoring that reports every content edit gets
filtered into a folder nobody opens, and the one alert that mattered goes with
it.
The first run of a new monitor never emails — there is nothing to compare it
against, and every existing issue would look new.
A site that stays broken produces **one** email, not one per run. Alerts have a
cooldown of about a day.
## What the email says
```text theme={null}
MetaManager found 2 new metadata issues on example.com
/blog/new-product
- missing og image
- missing description
Full report: https://metamanager.dev/audits#aud_...
```
Pages that changed but did not break are listed after the problems, with their
before and after, so a skim reaches what is wrong first.
## Where the email goes
By default, to the address you sign in with.
Each monitor can send its alerts somewhere else instead — a shared inbox, an
on-call address, or whoever actually looks after that site. Open the monitor,
choose **Settings**, and enter the address.
A confirmation is sent to that address first, and the link in it is valid for
**24 hours**. Until it is confirmed, alerts keep going to the account holder —
so a typo means you keep receiving them, rather than nobody receiving them.
The recipient is per monitor, not per account. Two sites watched by the same
account can alert two different teams.
To go back to the account holder, clear the address on that monitor.
## Scheduling
A new monitor runs on the next tick rather than after a full interval, so you
see the first result within about fifteen minutes.
After that, each run is scheduled from its **slot** rather than from when the
previous crawl finished — a daily monitor stays on its hour instead of drifting
later every day by however long the crawl took.
Pausing a monitor stops it without losing its history. Resuming schedules the
next run from now, so a monitor paused for a month does not immediately audit
your site several times over to catch up.
## What a monitored run does to your site
Exactly what a manual [site audit](/docs/guides/site-audits) does: one page at a
time, obeying `robots.txt`, backing off if your server starts returning errors.
A weekly monitor of a 200-page site is 200 requests a week.
If that is more than you want, `robots.txt` is the control — our crawler
[explains itself](https://metamanager.dev/bot) and obeys it.
# Cards for your customers
Source: https://metamanager.dev/docs/guides/og-cards-for-saas
Draw social cards for pages on your customers' own domains, without verifying any of them.
If you run a platform — a newsletter tool, a site builder, a help-desk product —
your customers' pages live on their own domains. The [publishable key](/docs/guides/social-cards)
binds cards to domains **you** have verified, which does not scale past a
handful and cannot work at all when a customer points a new domain at you on a
Tuesday afternoon.
This is the other way in. You sign each card, and the signature authorises it.
Signed cards are part of the **Business** plan.
## Why signing, and what it buys
You can run code on a server. That is the thing your customers cannot do, and it
turns out to be a better anchor than domain verification:
* **No domain verification.** Your customers' domains never touch our records.
* **No page fetch.** You already hold the title and description, so we never go
and read the page. That removes the slowest part of drawing a card, and it
means a card works for a page behind a login, or one that is not published yet.
* **Permanent caching.** The payload *is* the content, so a changed title is a
changed URL. Cards are cached hard and never go stale.
## Getting a key
Under **Social cards** in your account you get two values:
| | |
| :------ | :-------------------------------------------------- |
| `ogk_…` | The **key id**. Public — it goes in the card URL. |
| `ogs_…` | The **secret**. Shown once. Keep it on your server. |
If you lose the secret, rotate it. The previous secret keeps working, so cards
already in your templates do not break the moment you rotate.
## Signing a card
Build a JSON payload, base64url it, and sign that string with HMAC-SHA256.
```js theme={null}
const payload = {
u: 'https://customer.example/post/hello', // the page (required)
t: 'Hello, world', // title
d: 'The first post on a new blog.', // description
tn: 'customer-42', // a registered brand (optional)
tpl: 'split', // layout (optional)
l: 'de', // language (optional)
i: 'https://cdn.example/hero.jpg', // a picture (optional)
bg: '#101a2b', fg: '#eef2ff', ac: '#ff5c8a', // colours (optional)
};
const b64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const body = b64(new TextEncoder().encode(JSON.stringify(payload)));
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(process.env.MM_SIGNING_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = b64(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)));
const card = `https://og.metamanager.dev/s?k=${keyId}&p=${body}&s=${sig}`;
```
Then put `card` in the page's `og:image`. Signing is a few lines in any language
with an HMAC library — there is nothing to install.
## Everything in the payload
Most of a card can be described by the payload itself, with nothing registered
here first:
| Key | What it is |
| :----------------- | :---------------------------------- |
| `u` | the page (required) |
| `t` / `d` | title and description |
| `tpl` | layout |
| `l` | language to read the page in |
| `i` | a picture for the card |
| `bg` / `fg` / `ac` | background, text and accent colours |
If your own database already holds a customer's colours, send them. There is
nothing to keep in step and nothing to register.
## Giving each customer their own brand
A brand is a convenience, not a requirement — and there is exactly one
thing it can do that a payload cannot: **carry a logo**. A mark is image data,
not an address, so it is resolved once when you save it and stored. Putting it
in a URL is not an option, and fetching it while drawing would put the network
back on the path that must not need it.
So: send colours in the payload if that suits you, and register a brand when a
customer needs their logo on the card, or when you would rather not repeat the
same four fields on every page.
Register one per customer, then name it with `tn` in the payload:
```bash theme={null}
curl -X PUT https://metamanager.dev/api/og/tenants/customer-42 \
-H 'content-type: application/json' \
-d '{
"background": "#0b0c0d",
"text": "#f4f5f6",
"accent": "#ff0055",
"template": "split",
"mark_source": "https://customer.example/logo.png"
}'
```
`mark_source` is fetched once, when you save it, and stored as the mark itself.
Up to 256 KB — far below what a card image may be, because this is drawn
small and lives against every customer you register. A logo that cannot be
fetched fails the save rather than being stored as an address that never becomes
a picture.
You can also add and edit brands under **Signed cards** in your account, which
is the easier path for a handful. The reference cannot be changed once set:
renaming it would make a second brand and orphan every card naming the first.
Colours and the mark are resolved **when you save them**, never when a card is
drawn. That is deliberate: a renderer that needs the network to draw fails in
the least debuggable way possible, and it is sitting inside your customers'
pages.
`GET /api/og/tenants` lists them, `DELETE /api/og/tenants/:ref` removes one. A
reference belonging to another account answers exactly like one that never
existed, so a key cannot be used to discover somebody else's customer list.
## What it refuses
Everything that fails answers `404` — an unknown key id, a bad signature, a
payload edited after signing, an account no longer on the plan. They are
deliberately indistinguishable, so a probe learns only that no card came back.
Cards are counted when drawn, against your allowance rather than your customers'.
# Previews
Source: https://metamanager.dev/docs/guides/previews
How the same page resolves differently on eight platforms.
Every platform builds its preview from a different set of tags, with a different fallback order. A page can look perfect on X and broken on LinkedIn, and the tags themselves will not tell you which.
`previews` resolves all eight for you.
## The eight
| Key | Platform | Reads |
| :--------- | :------------ | :----------------------------------------------------------- |
| `google` | Google search | `` and `meta description` only — **never** Open Graph |
| `x` | X / Twitter | `twitter:*` preferred, `og:*` as fallback |
| `facebook` | Facebook | Open Graph. Does not read `twitter:*` |
| `linkedin` | LinkedIn | Open Graph, falling back to page title and meta description |
| `discord` | Discord | Open Graph only |
| `whatsapp` | WhatsApp | Open Graph only |
| `slack` | Slack | Open Graph, falling back to page title and description |
| `telegram` | Telegram | Open Graph only |
Each preview carries its own `basis` string stating these rules, so the response
explains itself without this table.
Where a platform's behaviour is undocumented, the resolver is deliberately
conservative — it assumes no `twitter:*` fallback rather than guessing one.
Predicting a preview that does not appear is a worse failure than predicting
a plainer one that does.
## What each one returns
```json theme={null}
"google": {
"title": "Stripe | Financial Infrastructure to Grow Your Revenue",
"description": "Stripe is a financial services platform that helps…",
"image": null,
"domain": "stripe.com",
"source": {
"title": "",
"description": "description",
"image": null
},
"approximate": true,
"basis": "Search results are built from the page title and meta description; OG tags are not used.",
"favicon": "https://…/favicon.svg"
}
```
| Field | What it is |
| :------------------------------ | :------------------------------------------------------------------- |
| `title`, `description`, `image` | What this platform would actually show, after its own fallback chain |
| `source` | **Which tag each value came from.** The most useful field here. |
| `domain` | The host as this platform displays it |
| `approximate` | Whether the rendering is an approximation — see below |
| `basis` | Plain-English statement of this platform's rules |
| `favicon` | The icon this platform would use, where it uses one |
## `source` is the field to read
Two pages can produce an identical preview for entirely different reasons. `source` tells you which tag did the work:
```json theme={null}
"source": { "title": "og:title", "description": "og:description", "image": "og:image" }
```
versus
```json theme={null}
"source": { "title": "", "description": "description", "image": null }
```
The second page has no Open Graph tags at all. It still previews — because platforms fall back — but you are getting whatever the fallback produces rather than what you chose. That distinction is invisible in `title` alone.
## `approximate`
**Every preview is `approximate: true`**, and the field exists to say so rather than to distinguish good pages from bad ones.
No platform publishes its exact rendering rules, and real output depends on things invisible from the HTML: A/B tests, personalisation, the platform's own crawl cache, and layout rules that change with image aspect ratio. Google is the extreme case — search results are generated rather than rendered, and it may rewrite your title entirely if something else matches the query better.
`approximate: true` is not a warning about your page. It is us declining to overstate how precisely anyone can predict a third party's renderer.
## Why Google ignores Open Graph
The single most common misunderstanding this endpoint clears up.
Google builds search results from `` and `meta description`. It does not read `og:title` or `og:description`. A page with immaculate Open Graph tags and no `meta description` will preview beautifully on Facebook and show Google's own guess at a snippet.
That is why `missing_description` is a warning even on a page with a full set of `og:` tags.
## Platforms cache aggressively
A preview here reflects your page **now**. What a platform shows reflects whenever it last crawled you, which can be days.
After fixing tags, most platforms need to be told to look again:
* Facebook and Instagram — the [Sharing Debugger](https://developers.facebook.com/tools/debug/), "Scrape Again"
* LinkedIn — the [Post Inspector](https://www.linkedin.com/post-inspector/)
* X — re-share; the card is refetched on a schedule
* Slack, Discord, WhatsApp, Telegram — cache by URL for hours; a `?v=2` query parameter forces a fresh fetch
If a preview here looks right but the platform still shows the old one, the tags are fixed and the platform is stale. Purge its cache rather than editing further.
# Projects
Source: https://metamanager.dev/docs/guides/projects
Group a site's saved URLs, audits and monitors, and view one site at a time.
A project is one site's worth of work. It holds that site's saved URLs, its
audits and its monitors together, so a dashboard covering several sites can be
narrowed to the one you are actually looking at.
Free accounts get one project. Pro is unlimited.
## Scoping the dashboard
The switcher at the top of the sidebar sets the scope, and it applies
everywhere — Overview, Saved URLs, Audits and Monitors all show only what
belongs to the selected project.
The choice follows you between pages and is remembered between visits. It is
stored in your browser, not on your account, so two people sharing an account
can look at different projects at the same time.
| Scope | Shows |
| :--------------- | :--------------------------------- |
| **All projects** | Everything on the account |
| A project | Only what is filed in that project |
| **Unfiled** | Everything filed in no project |
## Unfiled
Nothing is put into a project automatically, and there is no default project.
Anything created before you had projects — or created while the scope was **All
projects** — is unfiled, and reachable under **Unfiled**.
New work inherits the scope you are in. Save a URL while a project is selected
and it is filed there; save one under **All projects** and it stays unfiled.
The count beside each project is everything it holds, not just its saved URLs.
A project with no saved URLs but six audits reads as six.
## Moving things between projects
A saved URL can be moved at any time from the project column on the
[Saved URLs](https://metamanager.dev/saved) page.
Audits and monitors are filed when they are created and stay where they were
put — an audit is a record of a crawl that already happened, and refiling it
would change history rather than correct it.
## Deleting a project
Deleting a project keeps everything in it. Its saved URLs, audits and monitors
are moved to **Unfiled**; only the grouping is removed.
The confirmation tells you exactly what will be moved before you agree to it.
# Sharing a report
Source: https://metamanager.dev/docs/guides/sharing
Publish an audit at a public link, and export it as a PDF for a client handoff.
An audit lives in your dashboard, behind your sign-in. A **share link** publishes
one read-only copy of it at a public URL, so you can hand a client the result
instead of describing it.
```text theme={null}
https://metamanager.dev/reports/Go3JHiSGE4IXCLqt51hSXw
```
Whoever opens it needs no account, and can change nothing.
## Publishing
Open a finished audit, choose **Share**, then **Create a public link**. Copy the
address and send it.
Pressing Share again does not create a second link — you get the same one back,
so a link can always be found again and always revoked.
The panel also shows how many times the link has been opened, which is usually
the fastest way to tell whether the person you sent it to has looked at it.
## What the reader sees
| | Free | Pro |
| :------------------------------- | :--- | :-- |
| Site, date, score | Yes | Yes |
| Issue categories and page counts | Yes | Yes |
| Page-by-page breakdown | — | Yes |
| Export to PDF | — | Yes |
A report shared from a Free account says on the page that it is a summary, so a
client is never left guessing whether a short report means a clean site.
The limit follows the account, not the link. Upgrading to Pro widens a link you
have already sent — you do not need to publish a new one.
Shared reports are served with `noindex`, so they do not appear in search
results. Treat the link itself as the secret: anyone who has it can read the
report.
## Exporting a PDF
Pro accounts get **Save as PDF**, on both the audit in your dashboard and the
shared report page. You do not have to publish a link to export one.
The PDF is the report — same numbers, same issues, same affected URLs — with the
navigation removed and page breaks placed so a table does not split mid-row.
Every affected URL is printed in full, because a paper report you cannot type a
URL out of is not much use.
It contains the site, the audit date, the overall score, the three score bands,
every issue category with how many pages carry it, and the page list with each
page's score and issues.
## Revoking
**Revoke this link** stops it working immediately. Anyone who still has the
address is told the report is no longer available.
You can publish again afterwards, but it will be a different address — the old
one stays dead.
Deleting an audit also deletes any link to it.
# Site audits
Source: https://metamanager.dev/docs/guides/site-audits
Crawl a whole site and see every page's metadata, score and issues in one report.
The checker answers for one page. An audit answers for a site: it crawls from
an address you give it, checks every page it finds, and reports the whole thing
as one score with the issues grouped across pages.
Audits run in [your dashboard](https://metamanager.dev/audits). They are a Pro
feature, with a smaller allowance on Free.
There is no public API for audits yet. `/inspect` is open and documented here;
audits are driven from the browser with a session. Programmatic access arrives
with API keys — see [authentication](/docs/getting-started/authentication).
## What it crawls
Starting from the URL you give it, an audit follows links **on the same origin
only**. `blog.example.com` is a different origin, and auditing it under the
parent's name would report on something you did not ask about.
Discovery is, in order:
1. `robots.txt` — for the rules, and for any `Sitemap:` it declares
2. `sitemap.xml` — the site's own statement of what matters
3. Links found on the pages themselves, breadth-first
Breadth-first matters when a page limit truncates a run: what you get is the
pages nearest the front door, not one deep branch.
Assets are skipped without spending a request — images, stylesheets, PDFs,
fonts and the rest are recognised by extension.
## What it will not do
**It obeys `robots.txt`.** A disallowed path is never fetched. It still appears
in the report, marked as skipped, so you know the crawler declined rather than
missed it.
**It goes one page at a time**, with a pause between requests, and slows down
further if your server starts returning errors. If your `robots.txt` declares a
`Crawl-delay`, that is used instead.
Our crawler identifies itself on every request, and [explains itself](https://metamanager.dev/bot):
```text theme={null}
MetaManagerBot/1.0 (+https://metamanager.dev/bot)
```
## Limits
| | Free | Pro |
| :--------------- | ---: | --: |
| Pages per audit | 25 | 500 |
| Audits per month | 1 | 50 |
The page ceiling is recorded on each run, so a historical audit still explains
why it stopped where it did after your plan has changed.
An audit also has a time budget that scales with its page limit. A run that
reaches it stops and reports what it covered rather than failing — the report
says so.
The monthly allowance is spent when an audit **starts**, not when it finishes,
and deleting an audit does not return it. Usage records what was run.
## Reading the report
**The site score is the unweighted mean of the pages that could be scored.**
Pages that could not be read are counted as crawled but excluded from the mean:
a 404 has no metadata to score, and counting it as zero would report a site as
broken because one link was stale.
It is a mean, so it hides the distribution. That is what the three buckets are
for:
| Bucket | Score |
| :--------- | :----------- |
| Excellent | 80 and above |
| Needs work | 50 to 79 |
| Critical | below 50 |
A hundred perfect pages and twenty broken ones still average well. The counts
are what tell you that.
**Issue categories** group every [issue code](/docs/guides/issues) across the site
with the number of pages carrying it — the fastest way to find the one missing
tag that affects two hundred pages. Selecting one filters the page list.
## When a page shows an error
A page that could not be read shows what happened rather than a score:
| Shown | Means |
| :----------------------- | :------------------------------------------------------------------------ |
| `HTTP 404` | The page is not there. The link pointing at it is stale. |
| `HTTP 502`, `503`, `429` | Your server was busy at that moment. Usually not a problem with the page. |
| `Timed out` | No response in time. |
| `Blocked by robots.txt` | Your own rules told us not to fetch it. |
Transient failures are retried once before being recorded. If a page still
shows a 5xx, opening its report will usually succeed — the site was under load
during the crawl, and that page is probably fine.
## Comparing two audits
Open a finished audit and choose **What changed** to see it against the previous
finished audit of the same site.
| Group | What it lists |
| :--------------- | :---------------------------------------------------- |
| **New issues** | Pages that gained an issue they did not have |
| **Fixed** | Pages that lost one |
| **Changed tags** | The watched fields that differ, with before and after |
| **New pages** | Pages found this time and not last time |
| **Gone** | Pages found last time and not this time |
The baseline is the most recent finished audit of the same site, whether you
started it by hand or a [monitor](/docs/guides/monitoring) did — so a scheduled run
and a manual one can be compared with each other. The first audit of a site has
nothing to compare against, and says so.
The seven watched fields are the same ones monitoring uses: `title`,
`description`, `canonical`, `og:title`, `og:description`, `og:image` and
`twitter:card`. Whitespace is normalised, so reflowed templates are not changes.
Audits run before MetaManager began recording page metadata cannot be compared
tag by tag. The comparison says so rather than reporting every tag as changed.
New and fixed issues, and pages that came or went, are still accurate for
those audits.
# Social cards
Source: https://metamanager.dev/docs/guides/social-cards
Generate a link preview image for every page, drawn from that page's own title, in your colours.
`missing_og_image` is one of the most common things MetaManager reports. Until
now the answer was a `` tag pointing at an image you still had to design
and host. This draws it instead.
Add one line to your template and every page gets a card built from that page's
real title and description:
```html theme={null}
```
Replace `{{ current_url }}` with whatever your framework calls the current page
URL. That is the whole integration — no backend code, no signing, nothing to
generate at build time.
To see one before setting anything up, the [home page](https://metamanager.dev/#cards)
draws a card for any URL you give it. Those are drawn in our colours and carry
our mark — a real card uses yours and carries neither.
## Layouts
Four are available. Choose a default under **Social cards**, and override it for
any individual page with `&tpl=`:
```html theme={null}
```
| Name | What it draws |
| :-------- | :------------------------------------------------------ |
| `classic` | Title and description, your mark above. The default. |
| `minimal` | The title alone, set large. No description. |
| `split` | An accent panel beside the text. |
| `quote` | The description as a pull quote, credited to the title. |
| `banner` | Your page's own image behind the words. |
`split` fills a large panel with your accent colour, so its text colour is
chosen automatically from that colour's brightness — a colour picked for a thin
rule does not always work behind text, and this is the one place that matters.
A name we do not recognise falls back to your saved default, so a typo in one
page's tag costs you that page's layout and nothing else.
## Languages and scripts
Cards draw in the page's own script. Japanese, Chinese (Simplified and
Traditional), Korean, Cyrillic, Greek and the extended Latin alphabets all
render from the page's real title, with no configuration.
### Putting your own image on the card
The `banner` layout draws a picture behind the text. Name it explicitly:
```html theme={null}
```
We fetch it once when the card is drawn and cache it with the card, so it costs
one request per page per day rather than one per share. PNG, JPEG, WebP and GIF,
up to 4 MB — enough for an unoptimised photograph straight from a stock
library.
A card carrying a photograph comes back as **JPEG**; every other card is a PNG.
That is not a preference, it is arithmetic: the same photographic card is 896 KB
as a PNG and 64 KB as a JPEG, and 896 KB is past the point our own checker calls
a preview image too heavy. Flat cards go the other way, so they stay PNG.
**You have to name it.** We will not go looking for a picture in your markup: the
first image on a page is a logo, an avatar or a tracking pixel about as often as
it is the one you meant, and a card is not the place to find that out.
A card whose image cannot be fetched — wrong address, too large, too slow,
not an image — draws without it rather than failing, and says why in an
`x-mm-degraded` header. The dashboard preview and the demo on our home page
both tell you directly, because a browser cannot check an image's size on
another domain for itself. The text is legible
either way: `banner` puts a gradient between the picture and the words, because
an image chosen for how it looks is rarely one chosen as a background.
### Adjusting a layout
Four settings change how any of the four layouts draws, in your account or per
page:
| Parameter | Values | Default |
| :-------- | :-------------------------- | :------ |
| `ts` | `s`, `m`, `l` — title size | `m` |
| `align` | `left`, `center` | `left` |
| `desc` | `0` to hide the description | shown |
| `mark` | `0` to hide your mark | shown |
```html theme={null}
```
Settings you choose in the dashboard apply to every card; a parameter on one
card overrides just that one, leaving your other choices alone. An unrecognised
value falls back to the default rather than failing — a typo costs you a
setting, never the card.
`minimal` never draws a description, so `desc` does nothing there. The
dashboard greys the control out rather than letting you set something that
will not happen.
### Reading a page in a particular language
If your site serves different languages from one URL, add `&l=` so we read the
one you mean:
```html theme={null}
```
We send no language unless you ask, so by default a multilingual site is read in
whichever language its own edge prefers. This controls the language we *read* —
the typeface is always chosen from the text we get back.
Arabic, Hebrew and the Indic scripts are **not** drawn. Those pages get a plain
card showing the site's address instead of the title.
This is a limitation of how we lay out text, not of the fonts. We place words
left to right without reshaping them, which for Arabic produces a card where
every word is correct but the sentence reads backwards — wrong in a way that
looks right. A card that says less is better than one that misrepresents your
page, so we decline instead.
These cards carry an `x-mm-degraded` response header naming the reason.
## Setting it up
In your account, under **Social cards**:
1. **Verify your site.** Add your domain and put the `` tag we show you on
its home page. We check it with the same checker you already use.
2. **Choose your colours.** Background, text and accent. *Use my favicon* pulls
your site's icon in as the card's mark.
3. **Copy the snippet** into your template.
The key in the URL is public — it sits in your page source where anyone can
read it. That is why step 1 exists: a card is only ever drawn for a domain you
have verified, so a copied key is useless to whoever copies it.
## What the card shows
Your site name and icon, the page's title, its description, and an accent rule
in your colour. Everything comes from the page itself, so the card cannot drift
out of step with what it is previewing.
If a page has no title — the case that probably brought you here — the card
falls back to the page's URL rather than drawing nothing.
## What it costs
| | Free | Pro |
| :--------------------------- | ---: | ----: |
| Cards drawn per month | 100 | 5,000 |
| MetaManager mark on the card | Yes | — |
**Cards drawn, not link previews served.** A card is drawn once and then cached,
so a page shared a thousand times costs one card. Redrawing happens about daily,
so an edited title appears within a day without you doing anything.
Going over the allowance does not break your previews. Cards keep being drawn —
they just carry the mark until the next month starts.
## Changing your colours later
Save new colours and the next redraw picks them up, everywhere, without editing
a single page. The same is true of upgrading: moving to Pro removes the mark
from the next redraw of every card you have.
## Rolling the key
**Rolling the key breaks every page still using the old one.** Cards already
drawn keep serving until they expire, but nothing new is drawn — so update your
template in the same sitting.
Roll it if the key ends up somewhere it should not be. Bear in mind it is
designed to be public, and on its own it cannot draw cards for a domain you have
not verified.
# The report
Source: https://metamanager.dev/docs/guides/the-report
Every field in an /inspect response, and what it is for.
One request returns one JSON document. This page walks it top to bottom.
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com'
```
## Top level
```json theme={null}
{
"url": "https://example.com",
"final_url": "https://example.com/",
"status_code": 200,
"response_time": 184,
"document_size": 1256,
"truncated": false,
"score": 17,
"metadata": { },
"image": null,
"previews": { },
"issues": [ ],
"redirects": [ ]
}
```
| Field | Type | Notes |
| :-------------- | :------ | :------------------------------------------------------------------------- |
| `url` | string | What you asked for, normalised. |
| `final_url` | string | Where the fetch ended up. Differs from `url` when redirects were followed. |
| `status_code` | number | The final response's status. |
| `response_time` | number | Milliseconds to fetch. Your latency, not ours. |
| `document_size` | number | Bytes of HTML read. |
| `truncated` | boolean | `true` if the document exceeded 2 MB and reading stopped. |
| `score` | number | 0–100. See below. |
When `truncated` is `true`, metadata later in the document may be missing. In practice everything relevant lives in ``, so this is rare — but it explains a suspiciously empty report on an enormous page.
## score
A number from 0 to 100, weighted by consequence rather than by counting tags.
A missing `og:image` costs ten points; a missing `og:locale` costs two. That is deliberate: one of them decides whether anybody clicks your link, and the other is a nicety. Several issues carry no weight at all and are advice rather than deductions — [the issue reference](/docs/guides/issues) marks which.
The score is not a standard. It is our weighting of what actually costs you traffic.
## metadata
Everything found on the page, unresolved and unmerged — what is literally there.
```json theme={null}
"metadata": {
"title": "Example Domain",
"description": null,
"canonical": null,
"robots": null,
"viewport": "width=device-width, initial-scale=1",
"charset": null,
"lang": "en",
"author": null,
"keywords": null,
"open_graph": { },
"twitter": { },
"links": {
"canonical": [],
"icons": [ { "rel": "icon", "href": "/favicon.ico" } ],
"alternate": [],
"manifest": []
}
}
```
`null` means the tag is absent. An empty string means the tag is present but empty — a real and different problem, which is why `empty_title` is its own issue code.
Character references are decoded. A page whose title is `Ben & Jerry's` reports `Ben & Jerry's`, because that is what a person sees. Length rules count the decoded string.
## image
Present only when the page declares a social image, and only when that image could be fetched:
```json theme={null}
"image": {
"url": "https://example.com/og.png",
"width": 1200,
"height": 630,
"alt": "A screenshot of the dashboard",
"size": 82014,
"mime": "image/png",
"accessible": true,
"inspected": true,
"state": "ok",
"declared": { "width": null, "height": null }
}
```
| Field | Notes |
| :---------------- | :------------------------------------------------------------------------ |
| `width`, `height` | Read from the **file's own header bytes**, not from what the page claims. |
| `declared` | What the page said via `og:image:width` / `og:image:height`, or `null`. |
| `size` | Bytes. |
| `mime` | From the response, e.g. `image/png`. |
| `accessible` | Whether the file could be fetched at all. |
| `inspected` | Whether we got far enough to measure it. |
| `state` | `ok`, or why not. |
Measuring the file rather than trusting the page is what makes `image_dimensions_mismatch` possible: the page declares one size, the file is another, and platforms that trust the declaration lay the card out wrongly.
`accessible: false` means the URL was declared but could not be fetched — a 404, a redirect loop, or a host that refuses us. To a crawler that is the same as having no image at all.
## previews
How the page renders on each of eight platforms:
```json theme={null}
"previews": {
"google": { },
"x": { },
"facebook": { },
"linkedin": { },
"discord": { },
"whatsapp": { },
"slack": { },
"telegram": { }
}
```
Each carries the resolved `title`, `description` and `image` that platform would use, the `source` each value came from, and whether the result is `approximate`. [Full detail](/docs/guides/previews).
## issues
An array, ordered by how much each one matters:
```json theme={null}
{
"code": "missing_description",
"severity": "warning",
"importance": "recommended",
"title": "Missing description",
"description": "This page has no meta description. …",
"fix": {
"html": "",
"laravel": "Meta::description('A short summary of the page.');"
}
}
```
`code` is stable and safe to branch on. `title` and `description` are prose and may be reworded — do not match on them.
`fix` is `null` where no generic snippet could be right: a title that is too short cannot be fixed by a string we invent.
Every code is listed in [the issue reference](/docs/guides/issues).
## redirects
The chain, when there was one:
```json theme={null}
"redirects": [
{ "url": "http://example.com/", "status": 301 },
{ "url": "https://example.com/", "status": 200 }
]
```
Each hop is the URL requested and the status it returned. The last entry is `final_url` and its final status.
Worth reading. Metadata is taken from `final_url`, so a page that redirects somewhere unexpected explains a report that looks like it belongs to a different site.
Up to five redirects are followed before the request is refused.
# MetaManager
Source: https://metamanager.dev/docs/index
Check how any page looks when it is shared, see what is missing, and copy the fix.
MetaManager reads a page's metadata and tells you three things: what tags are there, how the page will look when someone shares it on eight different platforms, and exactly what to change.
One `GET` request returns all of it.
One request, one report. No key, no account, no signup.
Score, metadata, previews, issues — what every field means.
Google, X, Facebook, LinkedIn, Discord, WhatsApp, Slack, Telegram.
The full reference, with what each one means and how to fix it.
## Base URL
```text theme={null}
https://metamanager.dev
```
## The whole API, in one line
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com'
```
That works right now, from any terminal, with no credentials. The checker is free and stays free — see [rate limits](/docs/operations/rate-limits) for the ceilings.
## What comes back
A single JSON document:
| Field | What it holds |
| :---------- | :------------------------------------------------------------------------- |
| `score` | 0–100, weighted by how much each missing tag actually costs you |
| `metadata` | Every tag found: title, description, canonical, Open Graph, Twitter, links |
| `previews` | How the page renders on each of eight platforms |
| `issues` | What is wrong, how much it matters, and the exact fix |
| `redirects` | The chain followed, if any |
[The report guide](/docs/guides/the-report) walks through each one.
## What it is not
MetaManager checks metadata. It does not render pages, run JavaScript, take screenshots, or crawl sites.
That last one matters for accuracy: **metadata injected by client-side JavaScript will not be seen**, because no crawler that reads your tags runs JavaScript either. If a preview looks wrong here, it looks wrong on Facebook too. That is the point.
Metadata is read from the HTML as served. This is the same view Googlebot, Slackbot and Twitterbot get — which is why a page that looks perfect in your browser can still score badly here.
# Errors
Source: https://metamanager.dev/docs/operations/errors
Every error code /inspect can return, and what to do about each.
Errors are JSON with a stable `error` field. Branch on that, never on `message`,
which is prose and may be reworded.
```json theme={null}
{
"error": "http_error",
"message": "the site returned HTTP 404",
"url": "https://example.com/missing",
"upstream_status": 404
}
```
**Check `error` before reading `score`.** A checker that scored an error page
would report "16 tags missing" for a site that is merely down, which is worse
than useless. An error response has no `score`, `metadata` or `previews`.
## The URL was refused
Returned before any request is made.
| `error` | HTTP | Meaning |
| :---------------------- | :--- | :--------------------------------------------------------------------------------------------------------------------------- |
| `missing_parameter` | 400 | No `?url=` was supplied. |
| `malformed_url` | 400 | Not parseable as a URL. |
| `unsupported_scheme` | 400 | Only `http:` and `https:` are fetched. |
| `credentials_in_url` | 400 | `https://user:pass@host/`. Credentials have no legitimate use in a public metadata check, and the real host follows the `@`. |
| `blocked_hostname` | 403 | `localhost`, `.local`, and names that cannot be public. |
| `private_address` | 403 | Resolves to a private or link-local address. |
| `dns_resolution_failed` | 400 | The hostname does not resolve. |
`blocked_hostname` and `private_address` are the SSRF defences. They are
load-bearing rather than decorative: a Worker's `fetch` will happily connect to
`127.0.0.1`, so these checks are the only thing preventing it.
## The fetch failed
| `error` | HTTP | Meaning |
| :------------------- | :--- | :----------------------------------------------------------------------- |
| `http_error` | 502 | The site answered, but with 4xx or 5xx. `upstream_status` carries which. |
| `fetch_timeout` | 504 | No response within the timeout. |
| `network_error` | 502 | Connection failed or TLS could not be negotiated. |
| `too_many_redirects` | 502 | More than five hops. |
| `invalid_redirect` | 502 | A `Location` we could not or would not follow. |
| `document_too_large` | 413 | Over 2 MB. |
`upstream_status` appears only on `http_error`, so a consumer can tell a 404
from a 503 without parsing English.
## Refused for other reasons
| `error` | HTTP | Meaning |
| :------------------- | :--- | :--------------------------------------------------- |
| `rate_limited` | 429 | Over your hourly quota. `retry_after` is in seconds. |
| `self_inspection` | 400 | metamanager.dev cannot check itself — see below. |
| `not_found` | 404 | No such route. |
| `method_not_allowed` | 405 | Wrong verb. |
| `internal_error` | 500 | Ours. Worth reporting. |
## Why the site cannot check itself
```json theme={null}
{
"error": "self_inspection",
"message": "metamanager.dev cannot inspect itself. A Cloudflare Worker cannot fetch its own domain…"
}
```
Cloudflare routes a Worker's request to its own zone past the Worker to an
origin server. This site has no origin, so the request fails rather than
looping. Detecting it and saying so plainly beats a confusing 522.
Any other URL works normally.
# Limits
Source: https://metamanager.dev/docs/operations/limits
Timeouts, sizes, redirects and caching.
| Limit | Value | On exceeding |
| :----------------- | :--------- | :----------------------------- |
| Fetch timeout | 15 seconds | `fetch_timeout`, HTTP 504 |
| Document size | 2 MB | `document_too_large`, HTTP 413 |
| Redirects followed | 5 | `too_many_redirects`, HTTP 502 |
| Report cache | 15 minutes | — |
## Caching
Identical requests inside fifteen minutes return the cached report. The
response says which you got:
```text theme={null}
x-cache: hit
x-cache: miss
```
Bypass it with `fresh=1`:
```bash theme={null}
curl 'https://metamanager.dev/inspect?url=https://example.com&fresh=1'
```
A cached response still counts against your rate limit. The limit is about
fairness of access, not about our compute.
`prompt=` is applied **after** the cache, so switching framework never costs a
fresh check. One cached report serves all five frameworks.
## Document size
Only the HTML is measured, and reading stops at the ceiling rather than failing
outright where possible — `truncated: true` on the report tells you it happened.
Everything that matters is normally in ``, so a truncated document usually
still produces a complete report.
## Crawling
A [site audit](/docs/guides/site-audits) fetches one page at a time with a pause
between requests, and backs off further when a site starts returning errors. A
declared `Crawl-delay` in `robots.txt` is honoured.
| Limit | Value |
| :----------------------------- | :---------------------------- |
| Requests in flight per audit | 1 |
| Minimum gap between requests | 300 ms, or your `Crawl-delay` |
| Redirects followed per page | 3 |
| Retries on a transient failure | 1 |
Each run also has a time budget scaled to its page ceiling. Reaching it ends the
audit with what it covered, reported as such rather than as a failure.
## Images
Social images are fetched to measure them, and are subject to the same SSRF
rules as the page itself — an `og:image` pointing at a private address is not
fetched.
Only the header bytes are needed for dimensions, so the whole file is not
downloaded.
# Rate limits
Source: https://metamanager.dev/docs/operations/rate-limits
How many checks an hour, how they are counted, and what the headers say.
## The limits
| Plan | Checks per hour | Saved URLs | Projects | Audit pages | Audits per month | Monitors | Sharing | PDF | API keys | Cards/month |
| :-------- | --------------: | ---------: | --------: | ----------: | ---------------: | -------: | ------: | --: | -------: | ----------: |
| Anonymous | 50 | — | — | — | — | — | — | — | — | — |
| Free | 100 | 3 | 1 | 25 | 1 | — | summary | — | 1 | 100 |
| Pro | 1,000 | unlimited | unlimited | 500 | 50 | 3 | full | yes | 10 | 5,000 |
[Site audits](/docs/guides/site-audits) are metered separately from the hourly check
quota, and [scheduled runs](/docs/guides/monitoring) are not metered at all — the
monitor cap bounds them instead. One 500-page audit would otherwise consume half a Pro account's hour, and
the two features would starve each other.
Signing in raises your limit rather than lowering it. A free account can always
do more than a stranger — otherwise signing in would be a downgrade.
## A sliding window, not a bucket that empties on the hour
Timestamps are stored, not counters. Your allowance is "requests in the last
sixty minutes", which means it recovers continuously instead of all at once —
so there is no cliff at the top of the hour and no incentive to wait for one.
Requests that are **denied are not recorded**. Hammering a limit you have
already hit does not extend it.
## Headers
Every response carries the current state:
```text theme={null}
x-ratelimit-limit: 50
x-ratelimit-remaining: 48
x-ratelimit-reset: 3593
```
`x-ratelimit-reset` is seconds until the oldest request in your window falls
out — the point at which you regain one unit, not a full reset.
A 429 adds `retry-after`, also in seconds:
```json theme={null}
{
"error": "rate_limited",
"message": "Limit of 50 inspections per hour reached. Try again in 3502 seconds.",
"retry_after": 3502
}
```
## What the budget is keyed to
Anonymous requests are keyed by IP. **Signed-in requests are keyed by account**,
which means the quota follows the person rather than the network — an office
behind one NAT shares a bucket while anonymous and stops sharing the moment
they sign in.
## Sign-in emails have their own, much tighter limit
Five per hour, counted per address **and** per IP.
Limiting only by IP would leave an email-bombing vector: a hundred addresses
from one machine is caught, but one address from a hundred machines is not —
and the victim is whoever owns the inbox.
A rate-limited login still answers `202` with the usual message, because saying
"too many attempts for this address" would confirm the address is worth trying.
## If the limiter is unavailable
It fails **open**. A rate limiter that is down should not take the checker down
with it.
## API keys share the account's bucket
Every API key on an account counts against the **same** hourly limit your
browser does. Creating ten keys does not give you ten times the quota — the
bucket is the account, not the key.
A request with a key that does not resolve is rejected outright rather than
falling back to an anonymous, IP-based limit.
## Social cards are counted when drawn
A card is drawn once and cached, so a page shared a thousand times costs one
card against your allowance. Cards redraw about daily so an edited title appears
without you doing anything.
Going over does not break previews — cards keep being drawn, carrying the
MetaManager mark until the month rolls over.