Skip to main content

Confirm

Are you sure?

Java · Code recipe

Get a page's metadata and Open Graph tags in Java

Read the title, description and share image of any page, in Java 17+ with java.net.http.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 · Last updated: September 2026

TL;DR

To read a page's title, description, Open Graph image and other metadata in Java, POST its URL to https://urlpipe.dev/meta with sync set to true and parse the JSON with Gson's JsonParser. 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 Java, Gson's JsonParser gives you the fields; the Open Graph guide covers which tags each platform reads.

Setup

Before you start

Java 17 or later: java.net.http is in the JDK. Every program on this page reads JSON, and the JDK has no parser, so those use Gson — one jar, no dependencies of its own. In a Maven or Gradle build add com.google.code.gson:gson:2.13.1; to try a file on its own, download the jar next to it.

Terminal
java -version   # 17 or later
curl -sSLO https://repo1.maven.org/maven2/com/google/code/gson/gson/2.13.1/gson-2.13.1.jar
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. BodyHandlers.ofString() reads the whole body as text, decoded with the charset the response names. HttpClient does not throw on a 4xx or 5xx, so check statusCode(). Gson's JsonParser reads the object; dig treats a missing field and a JSON null alike, since any field can be null.

PageMetadata.java
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class PageMetadata {
    public static void main(String[] args) throws Exception {
        var request = HttpRequest.newBuilder(URI.create("https://urlpipe.dev/meta"))
            .header("Authorization", "Bearer " + System.getenv("URLPIPE_API_KEY"))
            .header("Content-Type", "application/json")
            // A sync call can take up to 60 s; HttpClient would otherwise wait forever.
            .timeout(Duration.ofSeconds(90))
            .POST(HttpRequest.BodyPublishers.ofString("""
                {"url": "https://example.com", "sync": true}
                """))
            .build();

        var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            System.err.println("URLpipe answered " + response.statusCode() + ": " + response.body());
            System.exit(1);
        }
        String body = response.body();

        JsonElement meta = JsonParser.parseString(body);
        String[][] fields = {{"Title", "title"}, {"Description", "description"}, {"Image", "main_image_url"}};
        for (String[] field : fields) {
            JsonElement value = dig(meta, field[1]);
            System.out.println(field[0] + ": " + (value == null ? "none" : value.getAsString()));
        }
    }

    // The value at a path of keys, or null where any step is missing or JSON null.
    static JsonElement dig(JsonElement element, String... keys) {
        for (String key : keys) {
            if (element == null || !element.isJsonObject()) {
                return null;
            }
            element = element.getAsJsonObject().get(key);
        }
        return element == null || element.isJsonNull() ? null : element;
    }
}

Run it: java -cp gson-2.13.1.jar PageMetadata.java

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. One HttpClient is shared by every call — it holds the connection pool, so build it once — and a switch expression covers the answers GET /result/:token can give.

PageMetadataAsync.java
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class PageMetadataAsync {
    static final String API = "https://urlpipe.dev";
    static final HttpClient CLIENT = HttpClient.newHttpClient();

    static HttpResponse<String> send(HttpRequest.Builder builder) throws Exception {
        var request = builder
            .header("Authorization", "Bearer " + System.getenv("URLPIPE_API_KEY"))
            .timeout(Duration.ofSeconds(30))
            .build();
        return CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
    }

    static void fail(String message) {
        System.err.println(message);
        System.exit(1);
    }

    public static void main(String[] args) throws Exception {
        // No "sync": the request is accepted at once and the work carries on without you.
        var accepted = send(HttpRequest.newBuilder(URI.create(API + "/meta"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString("""
                {
                  "url": "https://example.com",
                  "report_to": "https://your-app.com/webhooks/urlpipe",
                  "labels": {"customer": "acme"}
                }
                """)));
        if (accepted.statusCode() != 200) {
            fail("URLpipe answered " + accepted.statusCode() + ": " + accepted.body());
        }
        String token = JsonParser.parseString(accepted.body()).getAsJsonObject().get("token").getAsString();
        System.out.println("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.
        HttpResponse<String> response = null;
        for (int attempt = 0; attempt < 60; attempt++) {
            response = send(HttpRequest.newBuilder(URI.create(API + "/result/" + token)).GET());
            if (response.statusCode() != 202) { // 202 means still processing
                break;
            }
            Thread.sleep(2000);
        }
        switch (response.statusCode()) {
            case 200 -> { }
            case 202 -> fail("Still processing after two minutes; try the token again later.");
            case 422 -> fail("The analysis failed: "
                + JsonParser.parseString(response.body()).getAsJsonObject().get("error").getAsString());
            case 410 -> fail("The result is past the 30-day window; send the request again.");
            default -> fail("URLpipe answered " + response.statusCode() + ": " + response.body());
        }
        String body = response.body();

        JsonElement meta = JsonParser.parseString(body);
        String[][] fields = {{"Title", "title"}, {"Description", "description"}, {"Image", "main_image_url"}};
        for (String[] field : fields) {
            JsonElement value = dig(meta, field[1]);
            System.out.println(field[0] + ": " + (value == null ? "none" : value.getAsString()));
        }
    }

    // The value at a path of keys, or null where any step is missing or JSON null.
    static JsonElement dig(JsonElement element, String... keys) {
        for (String key : keys) {
            if (element == null || !element.isJsonObject()) {
                return null;
            }
            element = element.getAsJsonObject().get(key);
        }
        return element == null || element.isJsonNull() ? null : element;
    }
}

Run it: java -cp gson-2.13.1.jar PageMetadataAsync.java

Errors

Handle errors and retries

A checked URLpipeException makes the caller decide what a refusal means. firstValueAsLong reads Retry-After without a parse of your own, and a body that is not JSON (a 401 answers in plain text) falls back to an empty object.

PageMetadataErrors.java
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class PageMetadataErrors {
    static class URLpipeException extends Exception {
        URLpipeException(String message) {
            super(message);
        }
    }

    static final HttpClient CLIENT = HttpClient.newHttpClient();

    // POST a sync request and return the result body; retry the two 429s that clear by themselves.
    static String urlpipe(String path, String payload) throws Exception {
        final int attempts = 5;
        for (int attempt = 0; attempt < attempts; attempt++) {
            var request = HttpRequest.newBuilder(URI.create("https://urlpipe.dev" + path))
                .header("Authorization", "Bearer " + System.getenv("URLPIPE_API_KEY"))
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(90))
                .POST(HttpRequest.BodyPublishers.ofString(payload))
                .build();
            var response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            int status = response.statusCode();
            if (status == 200) {
                return response.body();
            }
            if (status == 401) {
                throw new URLpipeException("401: the API key is missing or wrong. Check URLPIPE_API_KEY.");
            }

            JsonObject error;
            try {
                error = JsonParser.parseString(response.body()).getAsJsonObject();
            } catch (RuntimeException notJson) {
                error = new JsonObject();
            }
            String code = error.has("error") ? error.get("error").getAsString() : "";
            String detail = error.has("message") ? code + ": " + error.get("message").getAsString() : code;

            if (status == 429 && code.equals("rate_limited")) {
                // Sending too fast: Retry-After says how long the window has left.
                Thread.sleep(response.headers().firstValueAsLong("Retry-After").orElse(1) * 1000);
            } else if (status == 429 && code.equals("concurrency_limit")) {
                // Every parallel slot on your plan is busy with your own requests.
                Thread.sleep((1L << attempt) * 1000);
            } 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.get("token").getAsString());
            } 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");
    }

    public static void main(String[] args) throws Exception {
        String body;
        try {
            body = urlpipe("/meta", """
                {"url": "https://example.com", "sync": true}
                """);
        } catch (URLpipeException e) {
            System.err.println("URLpipe: " + e.getMessage());
            System.exit(1);
            return;
        }

        JsonElement meta = JsonParser.parseString(body);
        String[][] fields = {{"Title", "title"}, {"Description", "description"}, {"Image", "main_image_url"}};
        for (String[] field : fields) {
            JsonElement value = dig(meta, field[1]);
            System.out.println(field[0] + ": " + (value == null ? "none" : value.getAsString()));
        }
    }

    // The value at a path of keys, or null where any step is missing or JSON null.
    static JsonElement dig(JsonElement element, String... keys) {
        for (String key : keys) {
            if (element == null || !element.isJsonObject()) {
                return null;
            }
            element = element.getAsJsonObject().get(key);
        }
        return element == null || element.isJsonNull() ? null : element;
    }
}

Run it: java -cp gson-2.13.1.jar PageMetadataErrors.java

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 Java: every Java 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 Java?
No SDK. HttpClient is in the JDK; the programs that read JSON add Gson, one jar, because the JDK has no parser.

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.