PHP · Code recipe
Get a page's metadata and Open Graph tags in PHP
Read the title, description and share image of any page, in PHP 8.1+ with the curl extension. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To read a page's title, description, Open Graph image and other metadata in PHP, POST its URL to https://urlpipe.dev/meta with sync set to true and parse the JSON with json_decode($body, true). 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 PHP, json_decode($body, true) gives you the fields; the Open Graph guide covers which tags each platform reads.
Setup
Before you start
Nothing to install beyond PHP itself: the curl extension ships with it. Check it is loaded, and put your API key in the environment.
php -m | grep curl # prints "curl"
export URLPIPE_API_KEY="your_api_key"
The request
Print the title, description and main image
Without CURLOPT_RETURNTRANSFER, curl_exec() prints the body and returns true — the most common reason a PHP example "returns nothing". curl_exec() returns false only when there was no HTTP answer at all; a 422 is still a string, so read the status too. json_decode($body, true) gives an associative array; ?? covers a field that came back null.
<?php
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
$ch = curl_init("https://urlpipe.dev/meta");
curl_setopt_array($ch, [
CURLOPT_POST => true,
// Return the body from curl_exec() instead of printing it.
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("URLPIPE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["url" => "https://example.com", "sync" => true]),
// A sync call can take up to 60 s; curl would otherwise wait forever.
CURLOPT_TIMEOUT => 90,
]);
$body = curl_exec($ch);
if ($body === false) {
fail("Request failed: " . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 200) {
fail("URLpipe answered {$status}: {$body}");
}
$meta = json_decode($body, true);
echo "Title: " . ($meta["title"] ?? "none") . "\n";
echo "Description: " . ($meta["description"] ?? "none") . "\n";
echo "Image: " . ($meta["main_image_url"] ?? "none") . "\n";
Run it: php page_metadata.php
Given the example response on the docs page, it prints:
Title: Example Domain
Description: Illustrative examples in documents.
Image: https://example.com/cover.jpgAsync
The async variant: a token, a webhook and a poll
Leave out sync and the answer is a token, straight away. send() uses CURLOPT_CUSTOMREQUEST so one function makes both the POST and the GETs, and array destructuring ([$status, $body] = …) keeps the loop short.
<?php
const API = "https://urlpipe.dev";
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
// One request to the API: returns [status, body].
function send(string $method, string $path, ?array $payload = null): array
{
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("URLPIPE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_TIMEOUT => 30,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = curl_exec($ch);
if ($body === false) {
fail("Request failed: " . curl_error($ch));
}
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body];
}
// No "sync": the request is accepted at once and the work carries on without you.
[$status, $body] = send("POST", "/meta", [
"url" => "https://example.com",
"report_to" => "https://your-app.com/webhooks/urlpipe",
"labels" => ["customer" => "acme"],
]);
if ($status !== 200) {
fail("URLpipe answered {$status}: {$body}");
}
$token = json_decode($body, true)["token"];
echo "Accepted {$token}\n";
// 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.
for ($attempt = 0; $attempt < 60; $attempt++) {
[$status, $body] = send("GET", "/result/{$token}");
if ($status !== 202) { // 202 means still processing
break;
}
sleep(2);
}
if ($status === 202) {
fail("Still processing after two minutes; try the token again later.");
}
if ($status === 422) {
fail("The analysis failed: " . json_decode($body, true)["error"]);
}
if ($status === 410) {
fail("The result is past the 30-day window; send the request again.");
}
if ($status !== 200) {
fail("URLpipe answered {$status}: {$body}");
}
$meta = json_decode($body, true);
echo "Title: " . ($meta["title"] ?? "none") . "\n";
echo "Description: " . ($meta["description"] ?? "none") . "\n";
echo "Image: " . ($meta["main_image_url"] ?? "none") . "\n";
Run it: php page_metadata_async.php
Errors
Handle errors and retries
json_decode() returns null for a body that is not JSON (a 401 answers in plain text), and ?: [] turns that into an empty array. CURLOPT_HEADERFUNCTION is how curl hands you response headers one line at a time.
<?php
final class URLpipeError extends RuntimeException
{
}
// POST a sync request and return the result body, or throw URLpipeError.
function urlpipe(string $path, array $payload, int $attempts = 5): string
{
for ($attempt = 0; $attempt < $attempts; $attempt++) {
$headers = [];
$ch = curl_init("https://urlpipe.dev{$path}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("URLPIPE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload + ["sync" => true]),
CURLOPT_TIMEOUT => 90,
// Collect the response headers: Retry-After is the one needed here.
CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int {
[$name, $value] = array_pad(explode(":", $line, 2), 2, "");
$headers[strtolower(trim($name))] = trim($value);
return strlen($line);
},
]);
$body = curl_exec($ch);
if ($body === false) {
throw new URLpipeError("network error: " . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
return $body;
}
if ($status === 401) {
throw new URLpipeError("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");
}
$error = json_decode($body, true) ?: [];
$code = $error["error"] ?? "";
$detail = isset($error["message"]) ? "{$code}: {$error["message"]}" : $code;
if ($status === 429 && $code === "rate_limited") {
// Sending too fast: Retry-After says how long the window has left.
sleep((int) ($headers["retry-after"] ?? 1));
} elseif ($status === 429 && $code === "concurrency_limit") {
// Every parallel slot on your plan is busy with your own requests.
sleep(2 ** $attempt);
} elseif ($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 {$error["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("{$status}: {$detail}");
}
}
throw new URLpipeError("429: still refused after {$attempts} attempts");
}
try {
$body = urlpipe("/meta", ["url" => "https://example.com"]);
} catch (URLpipeError $e) {
fwrite(STDERR, "URLpipe: {$e->getMessage()}\n");
exit(1);
}
$meta = json_decode($body, true);
echo "Title: " . ($meta["title"] ?? "none") . "\n";
echo "Description: " . ($meta["description"] ?? "none") . "\n";
echo "Image: " . ($meta["main_image_url"] ?? "none") . "\n";
Run it: php page_metadata_errors.php
Details
What to know about /meta
- Any field can be
nullwhen the page does not have it — code for that, as the program does. - There is no
canonicalfield; the nine fields are the whole response. - Image and favicon URLs that are data URIs come back as
nullrather than as a blob. - Pages over 10 MB of HTML are refused before the model sees them.
FAQ
Frequently asked questions
Which fields does /meta return?
Why does metadata cost more than fetching the HTML?
Is AI processing done in the EU?
Do I need an SDK to call URLpipe from PHP?
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.