Go · Code recipe
Get a page's metadata and Open Graph tags in Go
Read the title, description and share image of any page, in Go 1.21+ with net/http from the standard library. 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 Go, POST its URL to https://urlpipe.dev/meta with sync set to true and parse the JSON with json.Unmarshal into a struct. 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 Go, json.Unmarshal into a struct gives you the fields; the Open Graph guide covers which tags each platform reads.
Setup
Before you start
Nothing to fetch: every import is standard library, so each program runs with go run as a single file. Put your API key in the environment.
go version # go1.21 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. http.Client has no timeout unless you set one, and res.Body is a stream: io.ReadAll turns it into bytes, and defer res.Body.Close() hands the connection back. Pointer fields tell a null apart from an empty string: any field can be null.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
payload, err := json.Marshal(map[string]any{
"url": "https://example.com",
"sync": true,
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, "https://urlpipe.dev/meta", bytes.NewReader(payload))
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("URLPIPE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
// A sync call can take up to 60 s; the zero-value client would wait forever.
client := &http.Client{Timeout: 90 * time.Second}
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
// The body is the result or the error, so read all of it first.
body, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
log.Fatalf("URLpipe answered %d: %s", res.StatusCode, body)
}
var meta struct {
Title *string `json:"title"`
Description *string `json:"description"`
MainImageURL *string `json:"main_image_url"`
}
if err := json.Unmarshal(body, &meta); err != nil {
log.Fatal(err)
}
orNone := func(s *string) string {
if s == nil {
return "none"
}
return *s
}
fmt.Println("Title:", orNone(meta.Title))
fmt.Println("Description:", orNone(meta.Description))
fmt.Println("Image:", orNone(meta.MainImageURL))
}
Run it: go run main.go
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. Decode only the field you need into an anonymous struct; switch on the status covers the four answers GET /result/:token can give.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
const api = "https://urlpipe.dev"
var client = &http.Client{Timeout: 30 * time.Second}
// send makes one request to the API and returns the status and the body.
func send(method, path string, payload any) (int, []byte) {
var reader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
log.Fatal(err)
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, api+path, reader)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("URLPIPE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
return res.StatusCode, body
}
// poll collects a result by token. The result is POSTed to report_to when it is
// ready; polling is the other way to collect it, and a backup for the webhook.
func poll(token string) []byte {
for attempt := 0; attempt < 60; attempt++ {
status, body := send(http.MethodGet, "/result/"+token, nil)
switch status {
case http.StatusOK:
return body
case http.StatusAccepted: // still processing
time.Sleep(2 * time.Second)
case http.StatusUnprocessableEntity:
log.Fatalf("The analysis failed: %s", body)
case http.StatusGone:
log.Fatal("The result is past the 30-day window; send the request again.")
default:
log.Fatalf("URLpipe answered %d: %s", status, body)
}
}
log.Fatal("Still processing after two minutes; try the token again later.")
return nil
}
func main() {
// No "sync": the request is accepted at once and the work carries on without you.
status, body := send(http.MethodPost, "/meta", map[string]any{
"url": "https://example.com",
"report_to": "https://your-app.com/webhooks/urlpipe",
"labels": map[string]any{"customer": "acme"},
})
if status != http.StatusOK {
log.Fatalf("URLpipe answered %d: %s", status, body)
}
var accepted struct {
Token string `json:"token"`
}
if err := json.Unmarshal(body, &accepted); err != nil {
log.Fatal(err)
}
fmt.Println("Accepted", accepted.Token)
body = poll(accepted.Token)
var meta struct {
Title *string `json:"title"`
Description *string `json:"description"`
MainImageURL *string `json:"main_image_url"`
}
if err := json.Unmarshal(body, &meta); err != nil {
log.Fatal(err)
}
orNone := func(s *string) string {
if s == nil {
return "none"
}
return *s
}
fmt.Println("Title:", orNone(meta.Title))
fmt.Println("Description:", orNone(meta.Description))
fmt.Println("Image:", orNone(meta.MainImageURL))
}
Run it: go run main.go
Errors
Handle errors and retries
Errors are values: urlpipe() returns one for every refusal it will not retry, and main decides what to do with it. A 401 answers in plain text, so it is handled before the JSON decode; max is a builtin from Go 1.21.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
var client = &http.Client{Timeout: 90 * time.Second}
// urlpipe POSTs a sync request and returns the result body. It retries the two
// 429s that clear by themselves and returns an error for everything else.
func urlpipe(path string, payload map[string]any) ([]byte, error) {
payload["sync"] = true
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
const attempts = 5
for attempt := 0; attempt < attempts; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://urlpipe.dev"+path, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("URLPIPE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return nil, err
}
switch res.StatusCode {
case http.StatusOK:
return body, nil
case http.StatusUnauthorized:
return nil, errors.New("401: the API key is missing or wrong, check URLPIPE_API_KEY")
}
var e struct {
Code string `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
}
_ = json.Unmarshal(body, &e) // a body that is not JSON leaves e empty
detail := e.Code
if e.Message != "" {
detail = e.Code + ": " + e.Message
}
switch {
case res.StatusCode == http.StatusTooManyRequests && e.Code == "rate_limited":
// Sending too fast: Retry-After says how long the window has left.
seconds, _ := strconv.Atoi(res.Header.Get("Retry-After"))
time.Sleep(time.Duration(max(seconds, 1)) * time.Second)
case res.StatusCode == http.StatusTooManyRequests && e.Code == "concurrency_limit":
// Every parallel slot on your plan is busy with your own requests.
time.Sleep(time.Duration(1<<attempt) * time.Second)
case res.StatusCode == http.StatusGatewayTimeout:
// Still running on our side; the token collects it from GET /result/:token.
return nil, fmt.Errorf("504 processing_timeout: collect it later with token %s", e.Token)
default:
// 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.
return nil, fmt.Errorf("%d: %s", res.StatusCode, detail)
}
}
return nil, fmt.Errorf("429: still refused after %d attempts", attempts)
}
func main() {
body, err := urlpipe("/meta", map[string]any{
"url": "https://example.com",
})
if err != nil {
log.Fatalf("URLpipe: %v", err)
}
var meta struct {
Title *string `json:"title"`
Description *string `json:"description"`
MainImageURL *string `json:"main_image_url"`
}
if err := json.Unmarshal(body, &meta); err != nil {
log.Fatal(err)
}
orNone := func(s *string) string {
if s == nil {
return "none"
}
return *s
}
fmt.Println("Title:", orNone(meta.Title))
fmt.Println("Description:", orNone(meta.Description))
fmt.Println("Image:", orNone(meta.MainImageURL))
}
Run it: go run main.go
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 Go?
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.