I wanted a simple thing: paste a Twitter/X post link, get a clean PDF of it. Text, author, images, the whole card. No screenshots stitched together.
It sounded like a one-afternoon project. It was not. Here are the four walls I hit, and how I got past each one. If you ever try to read tweet data from the browser, this will save you a day.
Wall 1: you cannot read a tweet from the browser
The obvious first attempt: fetch the tweet from the syndication endpoint that X's own embed widgets use.
const id = "1719049883789729890";
const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${token}`;
const res = await fetch(url); // TypeError: Failed to fetch
Dead on arrival. The response carries this header:
Access-Control-Allow-Origin: https://platform.twitter.com
So the endpoint only allows requests from X's own embed origin. No other website can call it from the browser, CORS blocks it. The old conversation endpoints (/timeline/conversation/<id>.json) now return 200 with an empty body, so those are gone too. And the official API needs a paid key.
There is no pure client-side path. If you want tweet data on your own site, something on your server has to fetch it.
The fix: a tiny server-side proxy
CORS is a browser rule. Server to server, it does not apply. So the browser asks my server, and my server asks X. My stack is WordPress, so this is a small REST endpoint (any backend works the same way).
register_rest_route('myplugin/v1', '/tweet', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function ($req) {
$id = preg_replace('/[^0-9]/', '', $req->get_param('id'));
$token = preg_replace('/[^A-Za-z0-9]/', '', $req->get_param('token'));
$url = "https://cdn.syndication.twimg.com/tweet-result?id=$id&lang=en&token=$token";
$r = wp_remote_get($url, [
'timeout' => 15,
'headers' => ['User-Agent' => 'Mozilla/5.0 ... Chrome/124 Safari/537.36'],
]);
// ...normalize and return JSON
},
]);
One caveat worth knowing: send a real browser User-Agent. Without it the endpoint gets moody.
Wall 2: the mysterious token
That endpoint needs a token query param. It is not an API key. It is a value derived from the tweet ID, and X's own embed code computes it client-side. The formula floating around the web (used by Vercel's react-tweet) is:
function token(id) {
return ((Number(id) / 1e15) * Math.PI)
.toString(36)
.replace(/(0+|\.)/g, "");
}
I compute this in the browser (it is trivial and exact there) and pass it to my endpoint, so the PHP side never has to reimplement JavaScript's float-to-base36. Clean division of labor.
Now the browser flow is:
const id = parseIdFromUrl(url);
const r = await fetch(`/wp-json/myplugin/v1/tweet?id=${id}&token=${token(id)}`);
const post = await r.json(); // { name, handle, avatar, text, images, likes, replies, quoted }
Wall 3: images taint the canvas
To put images into a PDF, most tools rasterize through a <canvas>. But drawing a cross-origin image onto a canvas taints it, and toDataURL() throws a security error. Twitter's pbs.twimg.com sometimes cooperates with crossOrigin, sometimes not.
Simplest reliable fix: let the server fetch the image bytes too and hand them back as base64 data URLs. Data URLs are same-origin by definition, so no taint, ever.
$bin = wp_remote_retrieve_body(wp_remote_get($imgUrl, [...]));
$dataUrl = 'data:' . $mime . ';base64,' . base64_encode($bin);
Yes, base64 is about 33% bigger than the binary. For a handful of tweet images, who cares. It just works, on every browser, every time.
Wall 4: jsPDF breaks on Hindi and emoji
This one bit hardest. I drew the whole tweet card with snapshots the preview element, and because the images are already base64 data URLs, there is no CORS drama.
const canvas = await html2canvas(cardEl, { backgroundColor: "#fff", scale: 2, useCORS: true });
canvas.toBlob(blob => download(blob, "post.png"), "image/png");
Lessons
- If a third party locks CORS to their own origin, no client-side trick beats it. Proxy through your server or stop.
- Compute fiddly values (like that base36 token) on whichever side has the right primitives, then pass them along. Do not port float math across languages for no reason.
- Base64 data URLs are the boring, reliable answer to canvas tainting.
- jsPDF is Latin-only out of the box. For real i18n, render text on a canvas and embed it as an image.
The finished thing does what I wanted: paste a link, get a clean PDF or image, threads and quote tweets included, every language intact, all in the browser with a thin server helper.
If you want to see it in action: Twitter/X to PDF.
Have you fought the syndication endpoint or jsPDF's font limits before? I would love to hear how you handled it.
SOCIAL SHARE CARD GENERATOR