C# · Code recipe
Get a page's metadata and Open Graph tags in C#
Read the title, description and share image of any page, in .NET 8+ with HttpClient. 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 C#, POST its URL to https://urlpipe.dev/meta with sync set to true and parse the JSON with JsonNode.Parse. 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 C#, JsonNode.Parse gives you the fields; the Open Graph guide covers which tags each platform reads.
Setup
Before you start
Nothing to add from NuGet: HttpClient and System.Text.Json are part of .NET. Each program below is a whole Program.cs for a console project.
dotnet new console -o PageMetadata && cd PageMetadata
export URLPIPE_API_KEY="your_api_key"
# replace Program.cs with a program below, then:
dotnet run
The request
Print the title, description and main image
sync = true keeps the request open until the result is ready. PostAsJsonAsync serializes the anonymous object, and the property names go out as written, underscores included. HttpClient does not throw on a 4xx or 5xx, so check IsSuccessStatusCode. JsonNode indexes the object by name, and a JSON null comes back as C# null, so ?? gives it a fallback.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
// A sync call can take up to 60 s; HttpClient's default gives up at 100.
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLPIPE_API_KEY"));
var response = await client.PostAsJsonAsync("https://urlpipe.dev/meta",
new { url = "https://example.com", sync = true });
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Console.Error.WriteLine($"URLpipe answered {(int)response.StatusCode}: {body}");
return 1;
}
var meta = JsonNode.Parse(body)!;
Console.WriteLine($"Title: {meta["title"]?.GetValue<string>() ?? "none"}");
Console.WriteLine($"Description: {meta["description"]?.GetValue<string>() ?? "none"}");
Console.WriteLine($"Image: {meta["main_image_url"]?.GetValue<string>() ?? "none"}");
return 0;
Run it: dotnet run
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. JsonNode reads one field without declaring a class for the response, and Task.Delay waits between polls without holding a thread.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
const string Api = "https://urlpipe.dev";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLPIPE_API_KEY"));
// No sync: the request is accepted at once and the work carries on without you.
var accepted = await client.PostAsJsonAsync($"{Api}/meta", new
{
url = "https://example.com",
report_to = "https://your-app.com/webhooks/urlpipe",
labels = new { customer = "acme" },
});
if (!accepted.IsSuccessStatusCode)
{
Console.Error.WriteLine($"URLpipe answered {(int)accepted.StatusCode}: {await accepted.Content.ReadAsStringAsync()}");
return 1;
}
var token = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["token"]!.GetValue<string>();
Console.WriteLine($"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.
var response = await client.GetAsync($"{Api}/result/{token}");
for (var attempt = 1; attempt < 60 && (int)response.StatusCode == 202; attempt++) // 202: still processing
{
await Task.Delay(TimeSpan.FromSeconds(2));
response = await client.GetAsync($"{Api}/result/{token}");
}
var body = await response.Content.ReadAsStringAsync();
switch ((int)response.StatusCode)
{
case 200:
break;
case 202:
Console.Error.WriteLine("Still processing after two minutes; try the token again later.");
return 1;
case 422:
Console.Error.WriteLine($"The analysis failed: {JsonNode.Parse(body)?["error"]?.GetValue<string>()}");
return 1;
case 410:
Console.Error.WriteLine("The result is past the 30-day window; send the request again.");
return 1;
default:
Console.Error.WriteLine($"URLpipe answered {(int)response.StatusCode}: {body}");
return 1;
}
var meta = JsonNode.Parse(body)!;
Console.WriteLine($"Title: {meta["title"]?.GetValue<string>() ?? "none"}");
Console.WriteLine($"Description: {meta["description"]?.GetValue<string>() ?? "none"}");
Console.WriteLine($"Image: {meta["main_image_url"]?.GetValue<string>() ?? "none"}");
return 0;
Run it: dotnet run
Errors
Handle errors and retries
A local function keeps the retry rules next to the call, and the exception type is declared after the top-level statements, where C# requires it. RetryAfter.Delta is the parsed Retry-After header, and a body that is not JSON (a 401 answers in plain text) leaves every field null.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("URLPIPE_API_KEY"));
string body;
try
{
body = await Urlpipe("/meta", new { url = "https://example.com", sync = true });
}
catch (URLpipeException e)
{
Console.Error.WriteLine($"URLpipe: {e.Message}");
return 1;
}
var meta = JsonNode.Parse(body)!;
Console.WriteLine($"Title: {meta["title"]?.GetValue<string>() ?? "none"}");
Console.WriteLine($"Description: {meta["description"]?.GetValue<string>() ?? "none"}");
Console.WriteLine($"Image: {meta["main_image_url"]?.GetValue<string>() ?? "none"}");
return 0;
// POST a sync request and return the result body; retry the two 429s that clear by themselves.
async Task<string> Urlpipe(string path, object payload, int attempts = 5)
{
for (var attempt = 0; attempt < attempts; attempt++)
{
using var response = await client.PostAsJsonAsync($"https://urlpipe.dev{path}", payload);
var text = await response.Content.ReadAsStringAsync();
var status = (int)response.StatusCode;
if (status == 200) return text;
if (status == 401) throw new URLpipeException("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");
JsonNode? error = null;
try { error = JsonNode.Parse(text); } catch (JsonException) { } // not JSON: no fields
var code = error?["error"]?.GetValue<string>() ?? "";
var message = error?["message"]?.GetValue<string>();
var detail = message is null ? code : $"{code}: {message}";
if (status == 429 && code == "rate_limited")
// Sending too fast: Retry-After says how long the window has left.
await Task.Delay(response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1));
else if (status == 429 && code == "concurrency_limit")
// Every parallel slot on your plan is busy with your own requests.
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
else if (status == 504)
// Still running on our side; the token collects it from GET /result/:token.
throw new URLpipeException($"504 processing_timeout: collect it later with token {error?["token"]?.GetValue<string>()}");
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 URLpipeException($"{status}: {detail}");
}
throw new URLpipeException($"429: still refused after {attempts} attempts");
}
class URLpipeException(string message) : Exception(message) { }
Run it: dotnet run
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 C#?
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.