Skip to content
tutorial

URL metadata API: build link previews like Slack in one call

| 6 min read
Social media cards displayed on a phone screen
Photo by Rami Al-zayat on Unsplash

A user pastes a URL in your chat app. You want to show a rich preview card with the page title, description, and thumbnail image; the same card Slack, Discord, and iMessage display. You could fetch the page, parse the HTML, and extract the Open Graph tags yourself. Or you could send one POST request.

The botoi /v1/url-metadata endpoint fetches any URL, reads its <meta> tags, and returns structured JSON: OG title, OG description, OG image, Twitter Card data, favicon, canonical URL, language, and more. One call replaces the fetch, the HTML parser, and the fallback logic.

The endpoint

curl -X POST https://api.botoi.com/v1/url-metadata \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://github.com/anthropics/claude-code" }'

Response:

{
  "success": true,
  "data": {
    "url": "https://github.com/anthropics/claude-code",
    "status": 200,
    "content_type": "text/html",
    "title": "anthropics/claude-code: Claude Code is an agentic coding tool",
    "description": "Claude Code is an agentic coding tool that lives in your terminal",
    "og": {
      "title": "anthropics/claude-code",
      "description": "Claude Code is an agentic coding tool that lives in your terminal",
      "image": "https://opengraph.githubassets.com/1/anthropics/claude-code",
      "type": "object",
      "url": "https://github.com/anthropics/claude-code",
      "site_name": "GitHub"
    },
    "twitter": {
      "card": "summary_large_image",
      "title": "anthropics/claude-code",
      "description": "Claude Code is an agentic coding tool that lives in your terminal",
      "image": null
    },
    "favicon": "https://github.com/favicon.ico",
    "canonical": "https://github.com/anthropics/claude-code",
    "language": "en",
    "author": null,
    "keywords": [],
    "theme_color": null
  }
}

The response gives you everything you need to render a link preview card. The og object contains the Open Graph tags that Slack and Discord read. The twitter object contains the Twitter Card tags. When a page sets both, you get both. When a page sets neither, you still get the HTML title and description as fallbacks.

Social media link preview cards on a phone screen
Photo by dole777 on Unsplash

Build a chat app link preview component

This Preact component takes a URL, calls the API, and renders a card with the OG image, title, description, and site name. It falls back to the HTML title when OG tags are missing.

import { useState, useEffect } from "preact/hooks";

interface LinkPreview {
  title: string | null;
  description: string | null;
  image: string | null;
  favicon: string | null;
  url: string;
  siteName: string | null;
}

function useLinkPreview(url: string) {
  const [preview, setPreview] = useState<LinkPreview | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!url) return;
    setLoading(true);

    fetch("https://api.botoi.com/v1/url-metadata", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url }),
    })
      .then((res) => res.json())
      .then(({ data }) => {
        setPreview({
          title: data.og?.title || data.title,
          description: data.og?.description || data.description,
          image: data.og?.image || null,
          favicon: data.favicon,
          url: data.canonical || url,
          siteName: data.og?.site_name || null,
        });
      })
      .catch(() => setPreview(null))
      .finally(() => setLoading(false));
  }, [url]);

  return { preview, loading };
}

function LinkPreviewCard({ url }: { url: string }) {
  const { preview, loading } = useLinkPreview(url);

  if (loading) {
    return (
      <div class="rounded-lg border border-gray-200 p-4 animate-pulse">
        <div class="h-4 bg-gray-100 rounded w-3/4 mb-2"></div>
        <div class="h-3 bg-gray-100 rounded w-full"></div>
      </div>
    );
  }

  if (!preview) return null;

  return (
    <a
      href={preview.url}
      target="_blank"
      rel="noopener noreferrer"
      class="block rounded-lg border border-gray-200 overflow-hidden
             hover:border-gray-400 transition-colors no-underline"
    >
      {preview.image && (
        <img
          src={preview.image}
          alt=""
          class="w-full h-40 object-cover"
        />
      )}
      <div class="p-4">
        <div class="flex items-center gap-2 mb-2">
          {preview.favicon && (
            <img src={preview.favicon} alt="" class="w-4 h-4" />
          )}
          <span class="text-xs text-gray-500">
            {preview.siteName || new URL(preview.url).hostname}
          </span>
        </div>
        <p class="font-semibold text-sm text-gray-900 mb-1">
          {preview.title}
        </p>
        <p class="text-xs text-gray-600 line-clamp-2">
          {preview.description}
        </p>
      </div>
    </a>
  );
}

The useLinkPreview hook handles the fetch and maps the API response to a flat object your UI can consume. The fallback chain (data.og?.title || data.title) means you always have something to display, even when a page has zero OG tags. The component renders a loading skeleton while the API call is in flight, then swaps in the preview card.

Auto-fill SEO metadata in a CMS

Content editors paste reference URLs when writing articles. Instead of making them copy-paste the title and description by hand, your CMS can pull that data from the URL and pre-fill the SEO fields.

async function autoFillSeoFields(referenceUrl: string) {
  const res = await fetch("https://api.botoi.com/v1/url-metadata", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.BOTOI_API_KEY}`,
    },
    body: JSON.stringify({ url: referenceUrl }),
  });

  const { data } = await res.json();

  return {
    seoTitle: data.og?.title || data.title || "",
    seoDescription: data.og?.description || data.description || "",
    ogImage: data.og?.image || "",
    canonical: data.canonical || referenceUrl,
    favicon: data.favicon || "",
  };
}

// Usage in a CMS admin panel
const fields = await autoFillSeoFields("https://stripe.com/docs/payments");
// fields.seoTitle       → "Payments | Stripe Documentation"
// fields.seoDescription → "Accept payments online..."
// fields.ogImage        → "https://images.stripe.com/..."

When an editor pastes a URL in the "reference" field, the CMS calls autoFillSeoFields, populates the SEO title, description, and OG image inputs, and lets the editor tweak from there. The same approach works for bookmark managers, read-later apps, and internal wiki tools that auto-generate cards from pasted links.

Node.js function with timeout and error handling

In production, you want a timeout so a slow target page doesn't block your request indefinitely. This function wraps the API call with a 5-second AbortController timeout and returns null on failure instead of throwing.

async function getLinkPreview(url: string) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);

  try {
    const res = await fetch("https://api.botoi.com/v1/url-metadata", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.BOTOI_API_KEY}`,
      },
      body: JSON.stringify({ url }),
      signal: controller.signal,
    });

    if (!res.ok) {
      throw new Error(`API returned ${res.status}`);
    }

    const { success, data } = await res.json();

    if (!success) {
      return null;
    }

    return {
      title: data.og?.title || data.title,
      description: data.og?.description || data.description,
      image: data.og?.image,
      favicon: data.favicon,
      siteName: data.og?.site_name,
      canonical: data.canonical,
      twitterCard: data.twitter?.card,
    };
  } catch (err) {
    console.error(`Failed to fetch preview for ${url}:`, err);
    return null;
  } finally {
    clearTimeout(timeout);
  }
}

The function returns a clean object with the fields your UI needs. Callers don't touch the raw API response. If the target page is down or the request times out, the function returns null and your app can show a fallback instead of crashing.

Handling edge cases

Not every URL cooperates. Some pages have no OG tags. Some are behind redirect chains. Some take 10 seconds to respond. Here's how to handle each case.

// 1. Pages with no OG tags: fall back to title + description
const preview = await getLinkPreview(url);
const displayTitle = preview?.title || "Untitled page";
const displayDesc = preview?.description || url;
const displayImage = preview?.image || "/fallback-thumbnail.png";

// 2. Detect redirects by comparing input URL to canonical
const inputUrl = "https://bit.ly/3xYzAbc";
const result = await getLinkPreview(inputUrl);
if (result?.canonical !== inputUrl) {
  console.log(`Redirected to: ${result?.canonical}`);
}

// 3. Batch multiple URLs with Promise.allSettled
const urls = [
  "https://github.com",
  "https://stripe.com",
  "https://vercel.com",
];

const previews = await Promise.allSettled(
  urls.map((u) => getLinkPreview(u))
);

const results = previews.map((p, i) => ({
  url: urls[i],
  preview: p.status === "fulfilled" ? p.value : null,
}));

No OG tags: Fall back to title and description. If those are also empty, display the raw URL. Show a placeholder image when og.image is null.

Redirects: The API follows redirects and returns metadata from the final page. Compare the input URL against canonical to detect when a redirect happened.

Slow pages: Set a timeout on your side (5 seconds works for most cases). The API itself has an internal timeout, but you should enforce your own so a slow target doesn't stall your user's experience.

Batch fetching: Use Promise.allSettled to fetch previews for multiple URLs in parallel. Failed requests return null without blocking the rest.

Key points

  • POST /v1/url-metadata returns OG tags, Twitter Card tags, favicon, canonical URL, language, and keywords in one JSON response.
  • The response mirrors the data Slack, Discord, and iMessage use to render link previews. You get the same fields without writing an HTML parser.
  • Anonymous access works at 5 requests per minute with no API key. Enough for development and low-traffic apps.
  • Fall back to title and description when OG tags are missing. The API always returns these from the HTML <head> when they exist.
  • For production use, add a timeout, cache results by URL, and handle null responses gracefully. Check the API docs for the full parameter reference.

Frequently asked questions

What is a URL metadata API?
A URL metadata API fetches a webpage and extracts structured data from its HTML: the page title, meta description, Open Graph tags (og:title, og:image, og:description), Twitter Card tags, favicon URL, canonical URL, and language. You send a URL, and the API returns all of this as JSON. It saves you from fetching the page yourself and parsing raw HTML.
How do Slack, Discord, and iMessage generate link previews?
When a user pastes a URL, these apps fetch the page in the background and read its Open Graph meta tags (og:title, og:description, og:image). They render a preview card from those values. If OG tags are missing, they fall back to the HTML title and meta description. The botoi /v1/url-metadata endpoint returns the same data these apps read, so you can build identical preview cards in your own application.
What happens if a page has no Open Graph tags?
The API still returns the HTML title, meta description, favicon, canonical URL, and language. The og fields in the response will be null. Your frontend should fall back to title and description when og.title and og.image are missing.
Does the API follow redirects?
Yes. The API follows HTTP 301/302/307/308 redirects and returns metadata from the final destination URL. The response includes the resolved URL and its HTTP status code, so you can detect redirect chains.
Is the URL metadata API free?
Anonymous access requires no API key and allows 5 requests per minute plus 100 per day. That covers development and low-traffic use cases. Paid plans start at $9/month for higher rate limits.

Try this API

URL Metadata API — interactive playground and code examples

More tutorial posts

Start building with botoi

150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.