<!-- Source: https://issued.live/developers -->
# API documentation

Look up Certificate Transparency records, SSL certificates and DNS for any
 domain. Free, no key required for domain lookups.

- [Quickstart](#quickstart)
- [Domain lookup](#domain)
- [Response fields](#fields)
- [Code examples](#examples)
- [Reverse IP lookup](#reverse-ip)
- [Live feeds](#feeds)
- [AI agents & MCP](#agents)
- [Statistics](#stats)
- [Errors](#errors)
- [Rate limits](#limits)
- [FAQ](#faq)

## Quickstart

One HTTP GET returns everything we know about a domain. No key, no sign-up, no
 `Authorization` header:

```
$ curl https://issued.live/github.com
```

The same URL serves a human-readable page to a browser and JSON to everything else, based
 on the `Accept` header. To force JSON explicitly:

```
$ curl -H "Accept: application/json" https://issued.live/github.com
```

## Domain lookup

GET https://issued.live/{domain}

Returns the current certificate, issuer, expiry, hosting address and registration dates
 for a registrable domain. Subdomains are accepted and resolved to their registrable domain
 (`www.github.com` and `github.com` return the same record).

**Authentication:** none. **Method:** GET.

### Example response

```
{
  "domain":          "github.com",
  "registered":      null,
  "expires":         null,
  "ssl_expires":     "2026-12-26T23:59:59Z",
  "ssl_cert":        "9F53ECDDF913AFBA5678C924A95149D2",
  "ssl_issuer":      "Amazon",
  "ip":              "52.251.114.96",
  "last_update":     "2026-08-29T07:46:54Z",
  "first_cert_seen": "2026-08-06T21:19:30Z",
  "tracked":         false
}
```

## Response fields

| Field | Type | Meaning |
|---|---|---|
| `domain` | string | The registrable domain the record describes. |
| `registered` | string \| null | Registration date, where we hold one. **null when we do not** — never guessed or inferred from certificate dates. |
| `expires` | string \| null | Registration expiry, where we hold one. Same null semantics. |
| `ssl_expires` | string \| null | Not-after date of the most recently observed certificate. |
| `ssl_cert` | string \| null | Certificate identifier, hex. Stable per certificate. |
| `ssl_issuer` | string \| null | Issuing CA organization, as it appears in the certificate. |
| `ip` | string \| null | An address observed for the domain by our own resolvers. |
| `last_update` | string | When we last updated this record. **Use this to judge freshness.** |
| `first_cert_seen` | string | First time any CT log carried a certificate for this name. **Not a registration date** — an old domain that only recently enabled HTTPS shows a recent value here. |
| `tracked` | boolean | Whether the domain is on our active monitoring list. |

All timestamps are RFC 3339 in UTC. Keys are returned in the order shown above.

## Code examples

Every example below fetches the record for `github.com` and prints its
 certificate issuer and expiry.

### curl

```
curl -sS -H "Accept: application/json" https://issued.live/github.com
```

### PHP

```
<?php
$domain = 'github.com';
$ch = curl_init("https://issued.live/{$domain}");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Accept: application/json'],
    CURLOPT_TIMEOUT        => 10,
]);
$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 200) {
    fwrite(STDERR, "lookup failed: HTTP {$status}\n");
    exit(1);
}
$d = json_decode($body, true);
printf("%s: %s, expires %s\n", $d['domain'], $d['ssl_issuer'] ?? 'unknown',
       $d['ssl_expires'] ?? 'unknown');
```

### Node.js

```
// Node 18+ has fetch built in; no dependencies needed.
const domain = 'github.com';

const res = await fetch(`https://issued.live/${domain}`, {
  headers: { Accept: 'application/json' },
  signal: AbortSignal.timeout(10_000),
});

if (!res.ok) throw new Error(`lookup failed: HTTP ${res.status}`);

const d = await res.json();
console.log(`${d.domain}: ${d.ssl_issuer ?? 'unknown'}, expires ${d.ssl_expires ?? 'unknown'}`);
```

### Python

```
import requests

domain = "github.com"
r = requests.get(f"https://issued.live/{domain}",
                 headers={"Accept": "application/json"}, timeout=10)
r.raise_for_status()

d = r.json()
print(f"{d['domain']}: {d.get('ssl_issuer') or 'unknown'}, "
      f"expires {d.get('ssl_expires') or 'unknown'}")
```

### Go

```
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type Record struct {
	Domain     string  `json:"domain"`
	SSLIssuer  *string `json:"ssl_issuer"`
	SSLExpires *string `json:"ssl_expires"`
}

func main() {
	client := &http.Client{Timeout: 10 * time.Second}
	req, _ := http.NewRequest("GET", "https://issued.live/github.com", nil)
	req.Header.Set("Accept", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var r Record
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		panic(err)
	}
	// SSLIssuer is *string: %v on it prints the ADDRESS, not the value.
	issuer := "unknown"
	if r.SSLIssuer != nil {
		issuer = *r.SSLIssuer
	}
	fmt.Printf("%s: %s\n", r.Domain, issuer)
}
```

### Java

```
// Java 11+ — java.net.http, no external dependencies.
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class Lookup {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10)).build();

        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create("https://issued.live/github.com"))
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(10))
            .GET().build();

        HttpResponse<String> res =
            client.send(req, HttpResponse.BodyHandlers.ofString());

        if (res.statusCode() != 200) {
            throw new RuntimeException("lookup failed: HTTP " + res.statusCode());
        }
        System.out.println(res.body());
    }
}
```

### Ruby

```
require 'net/http'
require 'json'

uri = URI('https://issued.live/github.com')
req = Net::HTTP::Get.new(uri, 'Accept' => 'application/json')

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 10) do |http|
  http.request(req)
end

abort "lookup failed: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)

d = JSON.parse(res.body)
puts "#{d['domain']}: #{d['ssl_issuer'] || 'unknown'}, expires #{d['ssl_expires'] || 'unknown'}"
```

### C#

```
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;

using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
client.DefaultRequestHeaders.Accept.Add(
    new MediaTypeWithQualityHeaderValue("application/json"));

var res = await client.GetAsync("https://issued.live/github.com");
res.EnsureSuccessStatusCode();

using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement;
Console.WriteLine($"{root.GetProperty("domain").GetString()}: " +
                  $"{root.GetProperty("ssl_issuer")}");
```

### Rust

```
// Cargo.toml: reqwest = { version = "0.12", features = ["json", "blocking"] }
use std::time::Duration;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(10))
        .build()?;

    let body: serde_json::Value = client
        .get("https://issued.live/github.com")
        .header("Accept", "application/json")
        .send()?
        .error_for_status()?
        .json()?;

    println!("{}: {}", body["domain"], body["ssl_issuer"]);
    Ok(())
}
```

### PowerShell

```
$d = Invoke-RestMethod -Uri 'https://issued.live/github.com' `
                       -Headers @{ Accept = 'application/json' } `
                       -TimeoutSec 10
"{0}: {1}, expires {2}" -f $d.domain, $d.ssl_issuer, $d.ssl_expires
```

### Bash + jq

```
#!/usr/bin/env bash
# Check a list of domains and print any certificate expiring within 30 days.
set -euo pipefail
cutoff=$(date -u -d '+30 days' +%s)

while read -r domain; do
  json=$(curl -sS --max-time 10 -H 'Accept: application/json' "https://issued.live/${domain}") || continue
  exp=$(jq -r '.ssl_expires // empty' <<<"$json")
  [ -z "$exp" ] && continue
  if [ "$(date -u -d "$exp" +%s)" -lt "$cutoff" ]; then
    echo "$domain expires $exp"
  fi
  sleep 0.2   # be polite
done < domains.txt
```

## Reverse IP lookup

**These endpoints require an API key.** They answer the reverse question:
 which domains resolve to a given address.

GET https://issued.live/api/v1/ip/{ip}
GET https://issued.live/api/v1/range/{cidr}

Authenticate with a bearer token. **Never put the key in the query string**
 — query strings land in access logs, proxy logs and `Referer` headers:

```
curl -sS https://issued.live/api/v1/ip/157.240.17.8 \
  -H "Authorization: Bearer YOUR_KEY"
```

For a range, **write the prefix slash as a hyphen** — a literal slash is
 a path separator and will not survive routing:

```
curl -sS https://issued.live/api/v1/range/157.240.17.0-24 \
  -H "Authorization: Bearer YOUR_KEY"
```

| Parameter | Default | Max | Meaning |
|---|---|---|---|
| `limit` | 500 | 5000 | Maximum hosts returned. Out-of-range values are clamped, not rejected. |

**Ranges are capped at 256 addresses (an IPv4 /24).** This is an engineering
 limit, not a licensing one: our records are stored by domain, so address lookups use a
 bloom-filter index that can answer “is this address present” but cannot answer a
 range predicate. A CIDR is therefore expanded into individual addresses. To sweep something
 larger, iterate a /24 at a time and **serialize the requests** — running
 them concurrently makes each slower rather than finishing sooner.

A single-address lookup typically returns in well under a second. A range on data we have
 not read recently can take ten seconds or more; the same range is fast once warm. Cache
 results on your side — this data changes over hours, not seconds.

### Example response

```
{
  "query": "157.240.17.8",
  "addresses": 1,
  "count": 2,
  "truncated": false,
  "limit": 500,
  "hosts": [
    {
      "hostname":   "m.internmc.facebook.com",
      "domain":     "facebook.com",
      "ip":         "157.240.17.8",
      "first_seen": "2026-08-06T21:19:30Z",
      "last_seen":  "2026-08-28T15:44:38Z",
      "observed":   12
    }
  ]
}
```

`truncated: true` means `limit` was reached and more data exists.
 There is no pagination cursor yet; raise `limit` toward 5000, and
 [tell us](/contact) if that is still not enough.

### Python, with a key

```
import os, requests

key = os.environ["ISSUED_LIVE_KEY"]        # never hard-code a key
r = requests.get("https://issued.live/api/v1/ip/157.240.17.8",
                 headers={"Authorization": f"Bearer {key}"},
                 timeout=30)               # ranges can be slow; allow for it
r.raise_for_status()

for h in r.json()["hosts"]:
    print(h["hostname"], h["last_seen"])
```

### Node.js, iterating a larger range

```
const key = process.env.ISSUED_LIVE_KEY;

// Serialized on purpose — concurrency does not help here.
for (let third = 0; third < 4; third++) {
  const cidr = `157.240.${16 + third}.0-24`;
  const res = await fetch(`https://issued.live/api/v1/range/${cidr}`, {
    headers: { Authorization: `Bearer ${key}` },
    signal: AbortSignal.timeout(40_000),
  });
  if (res.status === 504) { console.warn(`${cidr}: timed out, retry later`); continue; }
  if (!res.ok) throw new Error(`${cidr}: HTTP ${res.status}`);
  const { hosts } = await res.json();
  console.log(cidr, hosts.length);
}
```

## Live feeds

### Certificates as they are logged

GET https://issued.live/api/feed

A rolling 30-minute window of certificates, newest first. No key required.

```
curl -sS "https://issued.live/api/feed?limit=20"
```

```
{
  "window": "30m",
  "count": 20,
  "certificates": [
    { "seen_at": "2026-08-29T17:05:34Z", "issuer": "Let's Encrypt",
      "name": "brobola238.ph", "name_count": 1 }
  ]
}
```

**This is a window, not a cursor.** Poll it and you will see what is
 current; you will not reliably see everything that happened between polls. If you need
 gap-free delivery, use the NRD feed below, which is cursor-paged.

### Newly registered domains — cursor feed

GET https://issued.live/api/v1/nrd

**Requires an API key.** Returns newly registered domains in **ascending
 time order from a cursor**, so a consumer resumes exactly where it stopped and can
 never skip an event, however long it was away.

Add `certified=1` to restrict to newly registered domains that have
 already had a certificate issued — a much smaller and considerably more
 interesting set.

```
curl -sS "https://issued.live/api/v1/nrd?certified=1&limit=100" \
  -H "Authorization: Bearer YOUR_KEY"
```

```
{
  "certified": true,
  "count": 100,
  "next_cursor": "1787957711.click.joffulm",
  "caught_up": false,
  "domains": [
    { "seen_at": "2026-08-28T22:55:11Z", "domain": "joffulm.click" }
  ]
}
```

| Field | Meaning |
|---|---|
| `next_cursor` | Pass as `?after=` on the next call. **Always store it**, including when `caught_up` is true. |
| `caught_up` | True when fewer rows came back than `limit` — you have reached the present. Not an end: poll again later with the same cursor. |
| `after` | Request parameter. Omit it on a first call and the feed starts 48 hours back. |

### Draining the feed correctly

```
import os, time, requests

key    = os.environ["ISSUED_LIVE_KEY"]
cursor = load_cursor()          # persist this; it is the whole point

while True:
    r = requests.get("https://issued.live/api/v1/nrd",
                     params={"certified": 1, "limit": 500, "after": cursor},
                     headers={"Authorization": f"Bearer {key}"}, timeout=30)
    r.raise_for_status()
    page = r.json()

    for d in page["domains"]:
        handle(d["domain"], d["seen_at"])

    # Save the cursor even on a short page -- that is how you resume without gaps.
    cursor = page["next_cursor"]
    save_cursor(cursor)

    # Caught up means "nothing more right now", not "finished". Back off and poll.
    time.sleep(60 if page["caught_up"] else 1)
```

## AI agents, MCP and markdown

Every domain record is available as **markdown** by appending
 `.md`, which is easier for a language model to quote accurately than a page
 wrapped in navigation and CSS:

```
curl -sS https://issued.live/github.com.md
```

[`/llms.txt`](/llms.txt) is the machine-readable map of what is
 worth fetching. Markdown URLs are `noindex` and canonicalize to the HTML page,
 so they are a second representation rather than duplicate content.

### Browser MCP

If you drive a browser from an assistant with
 [Browser MCP](https://browsermcp.io) or a similar
 Model Context Protocol tool, issued.live works without any special handling: the
 pages are server-rendered HTML with no client-side rendering step and nothing behind a
 login. Two things make agent use cheaper:

- **Prefer the `.md` or JSON form over screen-scraping the page.**
 Same data, a fraction of the tokens, and no layout to break your parser.

- **The root speaks plain text.** `curl issued.live` returns a
 short usage summary rather than a JSON dump, so an agent that lands on the homepage
 can read what to do next without being handed 140 KB of statistics.

There is no MCP server for issued.live yet. If you build one, [tell
 us](/contact) and we will link it here.

## Statistics

GET https://issued.live/api/stats

Corpus-wide counts: domains known, certificates seen in the last 24 hours, ingest rate,
 per-TLD breakdown and log health. No key required. Served from a snapshot regenerated every
 ten minutes, so it is cheap to poll but never second-fresh.

```
curl -sS https://issued.live/api/stats | jq '.totals'
```

## Errors

Errors are JSON with an `error` code and a human-readable
 `message`, and are always sent `Cache-Control: no-store`.

| Status | Code | What to do |
|---|---|---|
| 400 | `bad_request` | Malformed domain, IP or CIDR, or a range wider than 256 addresses. Fix the input; retrying will not help. |
| 401 | `unauthorized` | Missing or unrecognized key. **Only the keyed endpoints answer this** — `/api/v1/ip/{ip}`, `/api/v1/range/{cidr}` and `/api/v1/nrd`. Domain lookups, `/api/feed` and `/api/stats` take no key and never return 401. |
| 404 | — | We have no record for that domain. This is not an error in your request; it usually means we have never seen a certificate for it. |
| 405 | `method_not_allowed` | Use GET. |
| 429 | `rate_limited` | Daily free-tier cap reached for your client address. `Retry-After` carries the seconds until the 00:00 UTC reset — wait it out rather than retrying on a fixed interval. See [Rate limits](#limits). |
| 504 | `timeout` | The lookup did not finish in time. **Normal on ranges, not an outage.** Retry once, then narrow the range. |
| 500 | `internal_error` | Our side. Retry once, then [report it](/contact). |

## Rate limits

The free, unauthenticated API is capped at **1,000 requests per day per client
 address**. The counter resets at **00:00 UTC**. That is generous
 enough that an interactive visitor will never reach it and a script doing real work
 rarely will; it exists so one scraper cannot monopolize a shared backend.

Every response to a capped endpoint carries the current state:

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | Your daily allowance. |
| `X-RateLimit-Remaining` | Requests left today. |
| `X-RateLimit-Reset` | Unix timestamp of the next reset. |
| `X-RateLimit-Warning` | Sent when fewer than 100 remain, so you find out before you are cut off rather than at the moment you are. |

Over the cap you get `429` with `Retry-After` set to the seconds
 remaining until reset. **Read that header rather than retrying on a fixed
 interval** — retrying sooner just earns another 429.

```
$ curl -sS -D - -o /dev/null https://issued.live/github.com | grep -i ratelimit
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1788048000
```

### What is not capped

- **Keyed endpoints** (`/api/v1/*`) and the partner export.
 They carry a credential and are metered per consumer instead.

- `/health`, `/robots.txt`, `/llms.txt`, the favicon
 and `/static/`. Capping page furniture would spend a visitor's allowance on
 assets their browser fetched for them.

### Etiquette

- **Need more?** [Ask](/contact). A key raises this
 considerably and costs nothing — we would rather know who you are than watch an
 anonymous address hit the ceiling every day.

- **Cache on your side.** Records change over hours; re-fetching the same
 domain in a loop gets you the same bytes.

- Serialize bulk work and add a small delay between requests. A steady trickle is
 always welcome; a burst is what forces limits to exist.

- Send a `User-Agent` that identifies you and gives us a way to make contact.
 If something you are doing causes a problem, that header is how we ask you about it
 rather than simply blocking you.

## Frequently asked questions

### Is the API free?

Domain lookups are free and need no key or sign-up. Reverse-IP lookups need a key;
 [ask](/contact) and say what you are building.

### Why are `registered` and `expires` null?

Because we do not hold registration data for that domain. We never infer these from
 certificate dates. A null means “we do not know”, not “no such
 date”.

### Is `first_cert_seen` the registration date?

No, and conflating the two is the most common mistake made with this data. It is the
 first time any Certificate Transparency log carried a certificate for the name. A
 domain registered in 2009 that enabled HTTPS last month shows a recent
 `first_cert_seen` and an old `registered`.

### How fresh is the data?

Certificate Transparency logs are read continuously, so certificates appear within
 minutes of being logged. DNS and address data is resolved on a sweep and can be hours old.
 Every record carries `last_update` — use it rather than assuming.

### Do you return every certificate for a domain, or the current one?

The lookup returns the most recently observed certificate. Full per-domain certificate
 history is not exposed through the API yet.

### What counts as a domain?

Requests are resolved to the registrable domain using the Public Suffix List, so
 `www.example.co.uk` and `example.co.uk` return the same record.

### Can I use this commercially?

Yes, within the [terms](/terms). The one thing we ask you not to do is present
 the data as coming from a domain's owner, a registry, or a certificate authority — it
 is our observation of public logs, nothing more.

### Is there an SDK?

No, and there does not need to be. It is one GET returning flat JSON; every example above
 uses the language's own standard library or its most common HTTP client.

 Your connection

 worldip.io

 Our sponsors

 Advertise here →

Last updated August 30, 2026. Questions the docs do not answer:
 [get in touch](/contact).

---

issued.live, operated by Tuxxin LLC. Source: https://issued.live/developers
