# 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 &amp; Jerry&#39;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} <title>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} <title>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. <Note>No generic fix: this one depends on your own content, so `fix` is `null`.</Note> # 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. <Tabs> <Tab title="Claude Code"> 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 ``` <Warning> 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. </Warning> </Tab> <Tab title="Codex"> 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_... ``` <Note> `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. </Note> </Tab> <Tab title="Cursor"> 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}" } } } } ``` <Warning> `.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. </Warning> </Tab> <Tab title="VS Code"> 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. <Note> 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. </Note> </Tab> <Tab title="Zed"> **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_..." } } } } ``` </Tab> <Tab title="Gemini CLI"> 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_..." } } } } ``` <Note> Gemini CLI uses `httpUrl` for streamable HTTP. Plain `url` selects SSE, a different transport. </Note> </Tab> <Tab title="Windsurf"> 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}" } } } } ``` <Note> Windsurf calls the field `serverUrl`, not `url`. </Note> </Tab> <Tab title="Claude Desktop"> 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. </Tab> </Tabs> ### 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. <Note> 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. </Note> ## 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 → <meta property="og:image" content="https://og.metamanager.dev/card?k=…"> 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. <Note> Every key on your account shares one hourly bucket. Connecting three agents with three keys does not give you three times the limit. </Note> ## 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 | <Note> **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. </Note> ## 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. <Warning> 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. </Warning> 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. <Note> The recipient is per monitor, not per account. Two sites watched by the same account can alert two different teams. </Note> 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. <Note> Signed cards are part of the **Business** plan. </Note> ## 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 | `<title>` 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. <Note> 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. </Note> ## 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": "<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": "<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. <Note> `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. </Note> ## Why Google ignores Open Graph The single most common misunderstanding this endpoint clears up. Google builds search results from `<title>` 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 <Warning> 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. </Warning> # 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. <Note> 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. </Note> ## 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. <Note> 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. </Note> ## 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. <Note> 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). </Note> ## 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. <Warning> 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. </Warning> ## 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. <Note> 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. </Note> # 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 `<meta>` 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} <meta property="og:image" content="https://og.metamanager.dev/card?k=pk_...&u={{ current_url }}"> ``` 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. <Tip> 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. </Tip> ## Layouts Four are available. Choose a default under **Social cards**, and override it for any individual page with `&tpl=`: ```html theme={null} <meta property="og:image" content="https://og.metamanager.dev/card?k=pk_...&u={{ current_url }}&tpl=minimal"> ``` | 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} <meta property="og:image" content="https://og.metamanager.dev/card?k=pk_...&u={{ current_url }}&tpl=banner&i={{ hero_image_url }}"> ``` 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. <Note> 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. </Note> ### 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} <meta property="og:image" content="https://og.metamanager.dev/card?k=pk_...&u={{ current_url }}&ts=l&align=center"> ``` 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. <Note> `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. </Note> ### 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} <meta property="og:image" content="https://og.metamanager.dev/card?k=pk_...&u={{ current_url }}&l=de"> ``` 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. <Warning> 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. </Warning> ## Setting it up In your account, under **Social cards**: 1. **Verify your site.** Add your domain and put the `<meta>` 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. <Note> 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. </Note> ## 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. | <Note> When `truncated` is `true`, metadata later in the document may be missing. In practice everything relevant lives in `<head>`, so this is rare — but it explains a suspiciously empty report on an enormous page. </Note> ## 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. <Warning> 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. </Warning> ## 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": "<meta name=\"description\" content=\"A short summary of the page.\">", "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. <CardGroup> <Card title="Check a URL" icon="bolt" href="/docs/getting-started/quickstart"> One request, one report. No key, no account, no signup. </Card> <Card title="Read the report" icon="file-code" href="/docs/guides/the-report"> Score, metadata, previews, issues — what every field means. </Card> <Card title="Preview on eight platforms" icon="share-nodes" href="/docs/guides/previews"> Google, X, Facebook, LinkedIn, Discord, WhatsApp, Slack, Telegram. </Card> <Card title="Every issue code" icon="list-check" href="/docs/guides/issues"> The full reference, with what each one means and how to fix it. </Card> </CardGroup> ## 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. <Note> 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. </Note> # 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 } ``` <Warning> **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`. </Warning> ## 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. <Note> `prompt=` is applied **after** the cache, so switching framework never costs a fresh check. One cached report serves all five frameworks. </Note> ## 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 `<head>`, 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.