Skip to main content

Confirm

Are you sure?

Node.js · Code recipe

Get a page's metadata and Open Graph tags in Node.js

Read the title, description and share image of any page, in Node.js 18+ with the built-in fetch. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To read a page's title, description, Open Graph image and other metadata in Node.js, POST its URL to https://urlpipe.dev/meta with sync set to true and parse the JSON with await res.json(). It answers with nine fields, any of which can be null, for 5 credits: it is one of the three endpoints that call a language model.

Free plan, no credit card. 1,000 credits a month.

One POST to /meta renders the page and returns what it says about itself: title, description, language, main image, favicon, author, feed, first publication date and extra author details. URLs come back absolute, resolved against the page.

A language model reads the page's metadata declarations — Open Graph, Twitter cards, JSON-LD, plain tags — and settles conflicts by a fixed order: og:title, then twitter:title, then <title>, then the <h1>. A field the page never declares is null, never a guess. That is why it costs 5 credits where a page fetch costs 1 credit. In Node.js, await res.json() gives you the fields; the Open Graph guide covers which tags each platform reads.

Setup

Before you start

Nothing to install: fetch is global from Node 18. The files end in .mjs so Node reads them as ES modules and top-level await works without a wrapper function.

Terminal
node --version   # v18 or later
export URLPIPE_API_KEY="your_api_key"

The request

Print the title, description and main image

sync: true keeps the request open until the result is ready. fetch resolves on any status, 4xx and 5xx included, so check res.ok yourself. Any field can be null; ?? gives it a fallback without also swallowing an empty string.

page_metadata.mjs
const res = await fetch("https://urlpipe.dev/meta", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.URLPIPE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://example.com", sync: true }),
  // fetch has no timeout of its own; a sync call can take up to 60 s.
  signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`URLpipe answered ${res.status}: ${await res.text()}`);

const meta = await res.json();
console.log(`Title: ${meta.title ?? "none"}`);
console.log(`Description: ${meta.description ?? "none"}`);
console.log(`Image: ${meta.main_image_url ?? "none"}`);

Run it: node page_metadata.mjs

Given the example response on the docs page, it prints:

Output
Title: Example Domain
Description: Illustrative examples in documents.
Image: https://example.com/cover.jpg

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away. setTimeout from node:timers/promises is the awaitable sleep, so the polling loop reads top to bottom.

page_metadata_async.mjs
import { setTimeout as sleep } from "node:timers/promises";

const API = "https://urlpipe.dev";
const headers = {
  Authorization: `Bearer ${process.env.URLPIPE_API_KEY}`,
  "Content-Type": "application/json",
};

// No sync: the request is accepted at once and the work carries on without you.
const accepted = await fetch(`${API}/meta`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    url: "https://example.com",
    report_to: "https://your-app.com/webhooks/urlpipe",
    labels: { customer: "acme" },
  }),
});
if (!accepted.ok) throw new Error(`URLpipe answered ${accepted.status}: ${await accepted.text()}`);
const { token } = await accepted.json();
console.log(`Accepted ${token}`);

// The result is POSTed to report_to when it is ready. Polling by token is the
// other way to collect it: no endpoint needed, and a backup for the webhook.
let res;
for (let attempt = 0; attempt < 60; attempt++) {
  res = await fetch(`${API}/result/${token}`, { headers });
  if (res.status !== 202) break; // 202 means still processing
  await sleep(2000);
}
if (res.status === 202) throw new Error("Still processing after two minutes; try the token again later.");
if (res.status === 422) throw new Error(`The analysis failed: ${(await res.json()).error}`);
if (res.status === 410) throw new Error("The result is past the 30-day window; send the request again.");
if (!res.ok) throw new Error(`URLpipe answered ${res.status}: ${await res.text()}`);

const meta = await res.json();
console.log(`Title: ${meta.title ?? "none"}`);
console.log(`Description: ${meta.description ?? "none"}`);
console.log(`Image: ${meta.main_image_url ?? "none"}`);

Run it: node page_metadata_async.mjs

Errors

Handle errors and retries

Read the status before the body: a 401 answers in plain text, so res.json() would throw. .catch(() => ({})) turns any body that is not JSON into an empty object, and the status still says what happened.

page_metadata_errors.mjs
import { setTimeout as sleep } from "node:timers/promises";

class URLpipeError extends Error {}

// POST a sync request and return the response, or throw URLpipeError.
async function urlpipe(path, payload, attempts = 5) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    const res = await fetch(`https://urlpipe.dev${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.URLPIPE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ ...payload, sync: true }),
      signal: AbortSignal.timeout(90_000),
    });
    if (res.ok) return res;
    if (res.status === 401) {
      throw new URLpipeError("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");
    }

    const body = await res.json().catch(() => ({}));
    const code = body.error ?? "";
    const detail = body.message ? `${code}: ${body.message}` : code;

    if (res.status === 429 && code === "rate_limited") {
      // Sending too fast: Retry-After says how long the window has left.
      await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
    } else if (res.status === 429 && code === "concurrency_limit") {
      // Every parallel slot on your plan is busy with your own requests.
      await sleep(2 ** attempt * 1000);
    } else if (res.status === 504) {
      // Still running on our side; the token collects it from GET /result/:token.
      throw new URLpipeError(`504 processing_timeout: collect it later with token ${body.token}`);
    } else {
      // 403 email_unverified, 422 (a bad parameter, or a page that would not load),
      // 429 quota_exceeded: sending the same request again gets the same answer.
      throw new URLpipeError(`${res.status}: ${detail}`);
    }
  }
  throw new URLpipeError(`429: still refused after ${attempts} attempts`);
}

let res;
try {
  res = await urlpipe("/meta", { url: "https://example.com" });
} catch (error) {
  console.error(`URLpipe: ${error.message}`);
  process.exit(1);
}

const meta = await res.json();
console.log(`Title: ${meta.title ?? "none"}`);
console.log(`Description: ${meta.description ?? "none"}`);
console.log(`Image: ${meta.main_image_url ?? "none"}`);

Run it: node page_metadata_errors.mjs

Details

What to know about /meta

  • Any field can be null when the page does not have it — code for that, as the program does.
  • There is no canonical field; the nine fields are the whole response.
  • Image and favicon URLs that are data URIs come back as null rather than as a blob.
  • Pages over 10 MB of HTML are refused before the model sees them.

Other languages

Get a page's metadata and Open Graph tags in another language

More Node.js: every Node.js recipe

FAQ

Frequently asked questions

Which fields does /meta return?
title, description, language, main_image_url, favicon_url, author_name, feed_url, publication_date and additional_author_information. Any of them can be null.
Why does metadata cost more than fetching the HTML?
Because a language model reads every metadata declaration on the page — Open Graph, Twitter cards, JSON-LD, plain tags — and picks each field by a fixed order of precedence. That costs 5 credits, against 1 credit for /html.
Is AI processing done in the EU?
It can be. Fetching, rendering and storage are in the EU on every plan, and AI processing can be switched to EU-only per organization, at no charge.
Do I need an SDK to call URLpipe from Node.js?
No package at all: Node 18 and later ship fetch, and the API is one POST per job.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.