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
- curl 7.x or later. See the curl manual.
- Node 22 for the resolver and the fixture server. No packages.
- The Open Graph protocol, because the fields a card leaves undeclared have Open Graph values or no values at all.
- A local target carrying the planted contradictions, printed below with the server that serves it. Stop it by process id when you are done, never by image name.
// 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
- 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 onproperty=. Reading them in one pass is the point: three card tags on their own say nothing about what the card will contain. - 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.htmlfield 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 | FixtureThe 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_imagewhile the only image on the page is a relative Open Graph path, which the og image procedure shows failing on its own. - 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/HTTP200 text/html 221672 - Step 4.
Resolve the same three fields on the saved page.
node card-fields.mjs mdn.htmlfield 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 | MDNTwo card tags on the page,
twitter:cardandtwitter: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 onname=rather thanproperty=, as the Open Graph procedure shows on this same page, so a reader that follows the protocol finds neither column filled. - 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/overviewThe 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.
- 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'0Zero 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
What to check next
- How to check open graph tags: the tags every field here falls back to, and the attribute they belong on.
- How to check og image: the file behind whichever image tag wins.
- How to validate structured data: a third description of the same page, with its own failure modes.
- How to check canonical tag: the URL a consumer deduplicates against should match the one you checked.
- How to check json ld: the same document head, checked for silent parse failures.
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.
Related on this site
- Checker: meta-tags title, description, canonical, robots meta, hreflang, Open Graph, Twitter card, viewport
- All crawlability and indexing checks
basic6 minpublished updated Maks Verny