How to check twitter card tags

Read the twitter: and og: tags out of the served HTML together. Where a twitter: tag is absent, the Open Graph tag beside it is the only value on the page, and where both are present they can disagree. One command prints both columns per field and names the pairs that conflict.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this alongside the Open Graph check, before release on any shared template, and after any change that adds card tags to a page that already had Open Graph ones. The failure it prevents is the split preview: one URL, one headline in one client and another elsewhere, because two tags for the same field carry different text.

There is no vendor tool left to appeal to. The card validator was retired, and as steps 5 and 6 show, the card guide URL now redirects to an index with no card page in it. What remains is a source read: list the tags, resolve each field to the value present, and remove the contradictions. Whether a client renders a large image or a small one is not decidable from here.

Prerequisites

// fixtures.js
const http = require('http'), fs = require('fs'), path = require('path');
const TYPES = { '.html': 'text/html; charset=utf-8', '.png': 'image/png' };
http.createServer((req, res) => {
  const url = req.url.split('?')[0];
  if (url === '/img/moved.png') return res.writeHead(302, { location: '/img/card.png' }).end();
  if (url === '/img/missing.png') return res.writeHead(404, { 'content-type': 'text/html' }).end('<h1>404</h1>');
  const file = path.join(__dirname, 'fixtures', path.normalize(url).replace(/^[\\/]+/, ''));
  if (!file.startsWith(path.join(__dirname, 'fixtures')) || !fs.existsSync(file)) {
    return res.writeHead(404).end('not found');
  }
  const body = fs.readFileSync(file);
  res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream',
                       'content-length': body.length });
  res.end(body);
}).listen(8919, '127.0.0.1', () => console.log('fixtures on http://127.0.0.1:8919, pid ' + process.pid));
<!-- fixtures/social.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Release notes 4.2 | Fixture</title>
<meta name="description" content="What changed in Fixture 4.2.">
<meta property="og:title" content="Release notes 4.2">
<meta property="og:description" content="What changed in Fixture 4.2.">
<meta property="og:description" content="Fixture 4.2 changelog.">
<meta property="og:image" content="/img/card.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Fixture 4.2 is out">
<meta name="twitter:site" content="fixture">
</head><body><h1>Release notes 4.2</h1></body></html>
// card-fields.mjs
import { readFile } from 'node:fs/promises';
const src = process.argv[2];
const html = /^https?:/.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
const attr = (t, n) => (new RegExp(`${n}\\s*=\\s*["']([^"']*)["']`, 'i').exec(t) || [])[1];
const meta = new Map();
for (const m of html.matchAll(/<meta[^>]*>/gi)) {
  const k = attr(m[0], 'property') ?? attr(m[0], 'name');
  if (k && !meta.has(k.toLowerCase())) meta.set(k.toLowerCase(), (attr(m[0], 'content') ?? '').replace(/\s+/g, ' ').slice(0, 70));
}
const title = (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [, ''])[1].trim();
const rows = [
  ['title', 'twitter:title', 'og:title', title],
  ['description', 'twitter:description', 'og:description', meta.get('description') ?? ''],
  ['image', 'twitter:image', 'og:image', ''],
];
console.log('field        twitter:*                og:*                     last resort');
for (const [field, tw, og, last] of rows) {
  const a = meta.get(tw), b = meta.get(og);
  let verdict;
  if (a && b) verdict = a === b ? 'both, identical (the twitter tag adds nothing)' : 'both, DIFFERENT (the preview depends on the consumer)';
  else if (!a && b) verdict = 'og only (a consumer that falls back shows it, one that does not shows nothing)';
  else if (a && !b) verdict = 'twitter only (no Open Graph value for any other consumer)';
  else verdict = last ? `neither, only ${field === 'title' ? '<title>' : 'meta description'}` : 'NEITHER, and no last resort';
  console.log(`${field.padEnd(12)} ${(a ?? '-').slice(0, 24).padEnd(24)} ${(b ?? '-').slice(0, 24).padEnd(24)} ${verdict}`);
}
console.log(`\ntwitter:card  ${meta.get('twitter:card') ?? 'MISSING'}`);
console.log(`<title>       ${title}`);

Steps

  1. Step 1.

    Start the fixture server and list every card and Open Graph tag the page serves.

    curl -sS http://127.0.0.1:8919/social.html | grep -oE '<meta (property|name)="(og|twitter):[^>]*>'
    
    <meta property="og:title" content="Release notes 4.2">
    <meta property="og:description" content="What changed in Fixture 4.2.">
    <meta property="og:description" content="Fixture 4.2 changelog.">
    <meta property="og:image" content="/img/card.png">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="Fixture 4.2 is out">
    <meta name="twitter:site" content="fixture">

    Card tags sit on name=, Open Graph tags on property=. Reading them in one pass is the point: three card tags on their own say nothing about what the card will contain.

  2. Step 2.

    Resolve each field to the value that is actually on the page.

    node card-fields.mjs http://127.0.0.1:8919/social.html
    
    field        twitter:*                og:*                     last resort
    title        Fixture 4.2 is out       Release notes 4.2        both, DIFFERENT (the preview depends on the consumer)
    description  -                        What changed in Fixture  og only (a consumer that falls back shows it, one that does not shows nothing)
    image        -                        /img/card.png            og only (a consumer that falls back shows it, one that does not shows nothing)
    
    twitter:card  summary_large_image
    <title>       Release notes 4.2 | Fixture

    The title row is the defect. Two tags, two different strings, and the URL then previews as one headline in one place and another elsewhere. The image row is the other one worth acting on: the card declares summary_large_image while the only image on the page is a relative Open Graph path, which the og image procedure shows failing on its own.

  3. Step 3.

    Save a page from production once, so the rest costs no further requests.

    curl -sS -o mdn.html -w '%{http_code} %{content_type} %{size_download}\n' https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    200 text/html 221672
  4. Step 4.

    Resolve the same three fields on the saved page.

    node card-fields.mjs mdn.html
    
    field        twitter:*                og:*                     last resort
    title        -                        HTTP: Hypertext Transfer og only (a consumer that falls back shows it, one that does not shows nothing)
    description  -                        HTTP is an application-l og only (a consumer that falls back shows it, one that does not shows nothing)
    image        -                        https://developer.mozill og only (a consumer that falls back shows it, one that does not shows nothing)
    
    twitter:card  summary
    <title>       HTTP: Hypertext Transfer Protocol | MDN

    Two card tags on the page, twitter:card and twitter:creator, and no card title, description or image. Every field resolves to the Open Graph value instead, which is the configuration this pattern is meant to produce: declare the card type once and let Open Graph carry the content. One detail makes it fragile here. Those Open Graph tags sit on name= rather than property=, as the Open Graph procedure shows on this same page, so a reader that follows the protocol finds neither column filled.

  5. Step 5.

    Ask where the vendor documentation for these tags went.

    curl -sS -o /dev/null -L -w '%{num_redirects} redirects, final %{http_code} %{url_effective}\n' \
      'https://developer.x.com/en/docs/x-for-websites/cards/guides/getting-started'
    
    2 redirects, final 200 https://docs.x.com/overview

    The card guide does not redirect to a moved card guide. It lands on the documentation home page, which is what a retired section looks like from the outside.

  6. Step 6.

    Confirm it against the publisher's own index rather than by searching the site by hand.

    curl -sS https://docs.x.com/llms.txt | grep -ci 'card'
    
    0

    Zero mentions in the index the publisher offers for machine reading, as fetched on 2026-09-11. Nothing external will confirm your tags, so keep the evidence in your own repository.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | both, DIFFERENT | Two values for one field | Delete the card tag unless it is deliberately different, and say why in the template | | both, identical | The card tag repeats the Open Graph one | Delete the card tag. It is one more place to forget to update | | og only | The Open Graph value is the only one present | Correct for most pages. Confirm the Open Graph tag is on property= | | twitter only | No value for any consumer outside the card set | Add the Open Graph tag as well | | NEITHER, and no last resort | The field is empty everywhere | Add og:title, og:description or og:image | | twitter:card MISSING | No card type declared | Add one. Without it the card tags on the page describe nothing |

Common mistakes

Sign: twitter:title and og:title are both present, and the preview headline differs between two chat clients.Cause: Each consumer picks its own column. The fixture in step 2 carries Release notes 4.2 on og:title and Fixture 4.2 is out on twitter:title, and neither is wrong on its own. Two fields for one string means two places to update, so keep one unless the difference is intentional.
Sign: twitter:card says summary_large_image and the card renders small or blank.Cause: The card type declares an intent, not an image. In step 2 the page declares the large form while the only image on it is a relative og:image that no consumer can resolve. Check the image itself, not the type that asks for it.
Sign: A card checker reports the tags as fine and a strict Open Graph parser reports the page as empty.Cause: Card tags belong on name=, Open Graph tags on property=, and a template that writes name= for both passes the card read while failing the Open Graph one. Step 4 is that case on a live page: no card title, description or image, and the og tags that would fill them are on the wrong attribute.

What to check next

FAQ

Do I need both open graph and twitter card tags?

Declare twitter:card and let the Open Graph tags carry title, description and image. Step 4 shows that shape on a live page. Duplicating every field doubles the number of places a stale string can hide.

Where is the card validator?

Retired. Step 5 shows the card guide URL redirecting twice to a documentation index, and step 6 shows that index mentioning cards zero times. Check the source of your own page and keep the output.

Which twitter tags does a page actually need?

twitter:card at minimum, plus a title, description and image from either set. twitter:site and twitter:creator name accounts and change nothing about the content of the card.

Why does my card look right for me and wrong for someone else?

Different consumers, different columns, and different cache ages. Resolve the fields as in step 2 first. If both columns hold the same strings, the difference is cache, and the lever you control is the URL.

Should twitter:image repeat og:image?

Only when the two images differ on purpose. Otherwise it is a second copy of one URL that has to be updated twice, and step 2 shows how a mismatch reads once it exists.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2grep 3.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic6 minpublished updated Maks Verny