GET/api/v2/redirects

Redirect chain API

Follow a URL's redirects hop by hop (HTTP 3xx, meta refresh and Refresh headers) and judge the chain the way Google and browsers do.

GEThttps://dnsrobot.netBase URL/api/v2Version/redirectsEndpoint?url=&ua=Parameters
200 OKRecorded 2026-10-25. Send it to run the request live.
{
  "query": {
    "url": "http://github.com/",
    "ua": "chrome"
  },
  "verdict": "ok",
  "reasons": [
    "ok"
  ],
  "end": "final",
  "finalUrl": "https://github.com/",
  "finalStatus": 200,
  "redirects": 1,
  "httpRedirects": 1,
  "clientRedirects": 0,
  "totalMs": 178,
  "hops": [
    {
      "n": 0,
      "url": "http://github.com/",
      "status": 301,
      "state": "ok",
      "error": null,
      "kind": "http",
      "arrivedBy": "start",
      "redirectType": "permanent",
      "location": "https://github.com/",
      "next": "https://github.com/",
      "nextRefused": null,
      "delay": null,
      "ip": "20.207.73.82",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 38,
      "dnsMs": 1,
      "connectMs": 18,
      "tlsMs": null,
      "ttfbMs": 19,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 0
    },
    {
      "n": 1,
      "url": "https://github.com/",
      "status": 200,
      "state": "ok",
      "error": null,
      "kind": "final",
      "arrivedBy": "http",
      "redirectType": null,
      "location": null,
      "next": null,
      "nextRefused": null,
      "delay": null,
      "ip": "20.207.73.82",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 140,
      "dnsMs": 0,
      "connectMs": 18,
      "tlsMs": 20,
      "ttfbMs": 38,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 3
    }
  ],
  "finalPage": {
    "url": "https://github.com/",
    "title": "GitHub · Change is constant. GitHub keeps you ahead. · GitHub · GitHub",
    "canonical": "https://github.com/",
    "canonicalSelf": true,
    "noindex": false,
    "nofollow": false,
    "jsRedirect": null
  },
  "issues": [],
  "tookMs": 674
}

How it works

Asks the URL from our server and follows every step the way a browser does: HTTP 301/302/303/307/308, <meta http-equiv=refresh> and the Refresh header, carrying cookies, up to 20 hops. Each hop has its status, Location, timing (DNS, connect, TLS, first byte) and what kind of redirect Google reads it as.

The verdict names the worst thing about the chain: a loop, too many hops, an https → http downgrade, a temporary redirect where a permanent one belongs, more than 3 hops (Google's advice), more than 10 (Googlebot stops), an end on a 4xx/5xx, or a client-side step. JavaScript redirects are spotted in inline scripts but never run or followed.

Honest limits: a hop that times out or a name our DNS couldn't resolve is never called broken: our own failures make the answer incomplete. A bot wall (Cloudflare challenge, 403, 429) is blocked, not broken. Private addresses in the chain are not followed (nextRefused: private).

Answers kept: The same run serves both endpoints and the HTTP Header Checker and Redirect Checker pages: a finished check is kept 2 minutes (30 seconds when a part ran out of time) per URL and user agent. A run that ended on a bot wall or whose first URL got no answer isn't kept.

Query parameters

  • urlstringrequired
    A web address: a full URL (https://github.com/about) or a bare name (github.com, checked as https://). Private and reserved addresses are refused.
  • uastringoptionaldefault chrome
    The user agent to send: chrome (Chrome on Windows, the default), iphone (Safari on iOS), googlebot, bingbot or dnsrobot (ours). Sites answer bots and phones differently.

    one of chrome, iphone, googlebot, bingbot, dnsrobot

Response fields

200 with this JSON object, plus tookMs, and incomplete with warnings when a source failed. Fields marked optional appear only when they apply.

  • queryobjectalways
    What was looked up, normalised (lower case, no trailing dot, IDNs as Punycode).
    • urlstringalways
      The URL checked: scheme added when missing (https), host lower case and Punycode, default port and #fragment dropped.
    • uastringalways
      The user agent the check sent.

      one of "chrome", "iphone", "googlebot", "bingbot", "dnsrobot"

  • verdictstringalways
    The chain in one word, most serious first: direct (no redirect), ok (permanent redirects only, ≤ 3, ends 2xx), temporary, long (> 3), googlebotStops (> 10), loop, downgrade (https → http), broken (ends 4xx/5xx or a dead hop), blocked (a bot wall), client (a meta refresh or JavaScript step), unreachable (the first URL got no answer), tooMany (> 20), notFollowed (our limits) or incomplete (our deadline or lookup).

    one of "direct", "ok", "temporary", "long", "googlebotStops", "loop", "downgrade", "broken", "blocked", "client", "unreachable", "tooMany", "notFollowed", "incomplete"

  • reasonsarray of stringsalways
    Every verdict that applies, most serious first (the first is verdict).
  • endstringalways
    Why the chain ended.

    one of "final", "loop", "tooMany", "noLocation", "choice", "refused", "tooLong", "limited", "failed", "skipped"

  • finalUrlstring or nullalways
    The URL the chain ends on with an answer; null when the last hop got none.
  • finalStatusinteger or nullalways
    That answer's status.
  • redirectsintegeralways
    Redirects followed (HTTP, meta refresh and Refresh header).
  • httpRedirectsintegeralways
    Of which HTTP 3xx.
  • clientRedirectsintegeralways
    Of which meta refresh or Refresh header.
  • totalMsintegeralways
    The hops' times added up.
  • hopsarray of objectsalways
    Every request, in order (at most 21).
    Show 25 fields
    • nintegeralways
      Position in the chain, from 0 (issue messages count hops from 1).
    • urlstringalways
      The URL asked.
    • statusinteger or nullalways
      The HTTP status, or null when no answer came (see state).
    • statestringalways
      ok when an HTTP answer came (any status); otherwise why not. dnsfail, headTooLarge and skipped are on our side, never the site's.

      one of "ok", "dns", "dnsfail", "refused", "timeout", "tls", "reset", "private", "headTooLarge", "skipped"

    • errorstring or nullalways
      A short code for a failed hop (ENOTFOUND, ECONNREFUSED, ETIMEDOUT, ETLS…).
    • kindstringalways
      What this hop does next: an http redirect, a meta refresh, a refresh header, or final (the chain ends here).

      one of "http", "meta", "refresh", "final"

    • arrivedBystringalways
      How the chain got here.

      one of "start", "http", "meta", "refresh"

    • redirectTypestring or nullalways
      Google's reading: 301/308 and an instant meta refresh are permanent, 302/303/307 and a delayed refresh temporary.

      one of "permanent", "temporary", null

    • locationstring or nullalways
      The Location header as sent (on a 3xx).
    • nextstring or nullalways
      Where this hop sends the visitor (absolute URL).
    • nextRefusedstring or nullalways
      The next URL exists but wasn't asked: a private address, not http(s), port 25, our own API, over 2,000 characters, or that site's per-minute limit ran out.

      one of "private", "invalid", "port", "own", "tooLong", "limited", null

    • delayinteger or nullalways
      Seconds before a meta refresh or Refresh header fires.
    • ipstring or nullalways
      The address we connected to.
    • httpVersionstring or nullalways
      From the status line (1.1); null when read through Cloudflare's network (via: fetch).
    • viastringalways
      socket: our own connection, headers exactly as sent; fetch: through Cloudflare's network (sites on Cloudflare and IPv6-only names), header names lower-cased.

      one of "socket", "fetch"

    • timeMsintegeralways
      This hop's total time from our server, our DNS lookup included.
    • dnsMsinteger or nullalways
      Our DNS lookup (0 when the chain already knew the name).
    • connectMsinteger or nullalways
      TCP connect (absent through fetch).
    • tlsMsinteger or nullalways
      TLS handshake after the connect.
    • ttfbMsinteger or nullalways
      Request sent to first byte.
    • crossSitebooleanalways
      The next URL is on another registrable site.
    • downgradebooleanalways
      This step goes from https:// to http://.
    • loopbooleanalways
      The next URL was already asked with the same cookies (browsers stop: ERR_TOO_MANY_REDIRECTS).
    • wallstring or nullalways
      A bot wall or refusal of our check (cloudflare, akamai, captcha, forbidden, rate…): the site is up.
    • cookiesintegeralways
      Set-Cookie headers on this answer.
  • finalPageobject or nullalways
    What the final HTML says about itself (null when the last answer isn't HTML or wasn't read).
    Show 7 fields
    • urlstringalways
      The page's URL.
    • titlestring or nullalways
      The <title>.
    • canonicalstring or nullalways
      <link rel=canonical>, resolved.
    • canonicalSelfboolean or nullalways
      The canonical names the URL the chain ended on; null when there is none.
    • noindexbooleanalways
      The page or its X-Robots-Tag says noindex.
    • nofollowbooleanalways
      The page or its X-Robots-Tag says nofollow.
    • jsRedirectobject or nullalways
      A likely JavaScript redirect in an inline script (never run, never followed).
      Show 2 fields
      • urlstringalways
        Its target.
      • viastringalways
        What it assigns to, e.g. location.href.
  • issuesarray of objectsalways
    Problems found in the target's configuration, worst first. Empty when there are none.
    • severitystringalways
      error breaks something, warning risks it, info is worth knowing.

      one of "error", "warning", "info"

    • codestringalways
      Stable machine-readable code.
    • messagestringalways
      The problem and what to do about it, in plain English.

Errors

  • 422 target_not_allowed The URL points at a private or reserved address, port 25, or our own API.
  • 429 rate_limited Over your 20 a minute, or the target site's 40 a minute from everyone (the message says which).
  • 503 unavailable The checker's rate limiter isn't reachable: it fails closed rather than run a check uncounted.

Plus the errors every endpoint can return: 400 for a missing or unreadable parameter, 429 past the limit, 503 when every source failed.

Code samples

curl -G "https://dnsrobot.net/api/v2/redirects" \
  --data-urlencode "url=http://github.com"
const params = new URLSearchParams({ url: "http://github.com" });
const res = await fetch(`https://dnsrobot.net/api/v2/redirects?${params}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
console.log(data.hops);
import requests

r = requests.get(
    "https://dnsrobot.net/api/v2/redirects",
    params={"url": "http://github.com"},
    timeout=30,
)
data = r.json()
if not r.ok:
    raise RuntimeError(data["error"]["message"])
print(data["hops"])
package main

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

func main() {
	q := url.Values{"url": {"http://github.com"}}
	res, err := http.Get("https://dnsrobot.net/api/v2/redirects?" + q.Encode())
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(data["error"])
	}
	fmt.Println(data["hops"])
}
<?php
$query = http_build_query(["url" => "http://github.com"]);
$context = stream_context_create(["http" => ["ignore_errors" => true, "timeout" => 30]]);
$data = json_decode(file_get_contents("https://dnsrobot.net/api/v2/redirects?" . $query, false, $context), true);
if (isset($data["error"])) {
    throw new RuntimeException($data["error"]["message"]);
}
print_r($data["hops"]);
require "net/http"
require "json"

uri = URI("https://dnsrobot.net/api/v2/redirects")
uri.query = URI.encode_www_form(url: "http://github.com")
data = JSON.parse(Net::HTTP.get(uri))
raise data["error"]["message"] if data["error"]
puts data["hops"]
// Cargo.toml: reqwest = { version = "0.13", features = ["blocking", "json"] }, serde_json = "1"
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let res = reqwest::blocking::get("https://dnsrobot.net/api/v2/redirects?url=http%3A%2F%2Fgithub.com")?;
    let ok = res.status().is_success();
    let data: serde_json::Value = res.json()?;
    if !ok {
        return Err(data["error"]["message"].to_string().into());
    }
    println!("{}", data["hops"]);
    Ok(())
}
using System.Text.Json;

using var http = new HttpClient();
var res = await http.GetAsync("https://dnsrobot.net/api/v2/redirects?url=http%3A%2F%2Fgithub.com");
var data = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!res.IsSuccessStatusCode)
    throw new Exception(data.GetProperty("error").GetProperty("message").GetString());
Console.WriteLine(data.GetProperty("hops"));

http://github.com: one permanent redirect to HTTPS

GET /api/v2/redirects?url=http%3A%2F%2Fgithub.com
200 OKRecorded 2026-10-25
{
  "query": {
    "url": "http://github.com/",
    "ua": "chrome"
  },
  "verdict": "ok",
  "reasons": [
    "ok"
  ],
  "end": "final",
  "finalUrl": "https://github.com/",
  "finalStatus": 200,
  "redirects": 1,
  "httpRedirects": 1,
  "clientRedirects": 0,
  "totalMs": 178,
  "hops": [
    {
      "n": 0,
      "url": "http://github.com/",
      "status": 301,
      "state": "ok",
      "error": null,
      "kind": "http",
      "arrivedBy": "start",
      "redirectType": "permanent",
      "location": "https://github.com/",
      "next": "https://github.com/",
      "nextRefused": null,
      "delay": null,
      "ip": "20.207.73.82",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 38,
      "dnsMs": 1,
      "connectMs": 18,
      "tlsMs": null,
      "ttfbMs": 19,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 0
    },
    {
      "n": 1,
      "url": "https://github.com/",
      "status": 200,
      "state": "ok",
      "error": null,
      "kind": "final",
      "arrivedBy": "http",
      "redirectType": null,
      "location": null,
      "next": null,
      "nextRefused": null,
      "delay": null,
      "ip": "20.207.73.82",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 140,
      "dnsMs": 0,
      "connectMs": 18,
      "tlsMs": 20,
      "ttfbMs": 38,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 3
    }
  ],
  "finalPage": {
    "url": "https://github.com/",
    "title": "GitHub · Change is constant. GitHub keeps you ahead. · GitHub · GitHub",
    "canonical": "https://github.com/",
    "canonicalSelf": true,
    "noindex": false,
    "nofollow": false,
    "jsRedirect": null
  },
  "issues": [],
  "tookMs": 674
}

http://google.com: two hops to www

GET /api/v2/redirects?url=http%3A%2F%2Fgoogle.com
200 OKRecorded 2026-10-25
{
  "query": {
    "url": "http://google.com/",
    "ua": "chrome"
  },
  "verdict": "temporary",
  "reasons": [
    "temporary"
  ],
  "end": "final",
  "finalUrl": "https://www.google.com/?gws_rd=ssl",
  "finalStatus": 200,
  "redirects": 2,
  "httpRedirects": 2,
  "clientRedirects": 0,
  "totalMs": 1413,
  "hops": [
    {
      "n": 0,
      "url": "http://google.com/",
      "status": 301,
      "state": "ok",
      "error": null,
      "kind": "http",
      "arrivedBy": "start",
      "redirectType": "permanent",
      "location": "http://www.google.com/",
      "next": "http://www.google.com/",
      "nextRefused": null,
      "delay": null,
      "ip": "142.251.221.174",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 381,
      "dnsMs": 1,
      "connectMs": 38,
      "tlsMs": null,
      "ttfbMs": 342,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 0
    },
    {
      "n": 1,
      "url": "http://www.google.com/",
      "status": 302,
      "state": "ok",
      "error": null,
      "kind": "http",
      "arrivedBy": "http",
      "redirectType": "temporary",
      "location": "https://www.google.com/?gws_rd=ssl",
      "next": "https://www.google.com/?gws_rd=ssl",
      "nextRefused": null,
      "delay": null,
      "ip": "142.251.156.119",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 376,
      "dnsMs": 0,
      "connectMs": 16,
      "tlsMs": null,
      "ttfbMs": 360,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 3
    },
    {
      "n": 2,
      "url": "https://www.google.com/?gws_rd=ssl",
      "status": 200,
      "state": "ok",
      "error": null,
      "kind": "final",
      "arrivedBy": "http",
      "redirectType": null,
      "location": null,
      "next": null,
      "nextRefused": null,
      "delay": null,
      "ip": "142.251.156.119",
      "httpVersion": "1.1",
      "via": "socket",
      "timeMs": 656,
      "dnsMs": 0,
      "connectMs": 21,
      "tlsMs": 108,
      "ttfbMs": 267,
      "crossSite": false,
      "downgrade": false,
      "loop": false,
      "wall": null,
      "cookies": 6
    }
  ],
  "finalPage": {
    "url": "https://www.google.com/?gws_rd=ssl",
    "title": "Google",
    "canonical": null,
    "canonicalSelf": null,
    "noindex": false,
    "nofollow": false,
    "jsRedirect": null
  },
  "issues": [
    {
      "severity": "warning",
      "code": "temporary_redirect",
      "message": "Hop 2 is a temporary redirect (302): Google may keep the old URL. Use 301 or 308 for a permanent…"
    }
  ],
  "tookMs": 5051
}

Shortened for the page: long lists show their first items.