API

Run a crawl and read its internal link findings over REST. Create a key in your dashboard, where it is shown once. API access is included with the Agency plan at $29 a month, and it shares the plan's monthly units with checks run in the web app. The trial grants Pro, which does not include the API.

Authentication

Send your key on every request, in either header:

Authorization: Bearer sk_your_key
x-api-key: sk_your_key

A missing, unknown or revoked key is 401. A key belongs to one account, and every request is answered for that account only: a crawl id that belongs to somebody else answers exactly like one that does not exist.

Endpoints

All requests go to https://internallinkingtool.com. Every response body is JSON except the CSV files, which are sent as attachments.

GET /api/v1/sites

The account's sites, by host, with the id you start a crawl on.

curl https://internallinkingtool.com/api/v1/sites \
  -H "Authorization: Bearer sk_your_key"
{
  "sites": [
    {
      "id": "clz7x2m4p0001abcd1234efgh",
      "host": "https://example.com",
      "verified": true,
      "verifiedAt": "2026-10-04T09:12:44.000Z",
      "createdAt": "2026-10-03T16:40:02.000Z"
    }
  ]
}

host is the origin, with no trailing slash. verified is true once control of the host has been proved, which is required before that site can be crawled.

POST /api/v1/sites/{id}/crawls

Start one crawl of one site. The answer is 202 with the crawl id, which is what the other endpoints take.

curl -X POST https://internallinkingtool.com/api/v1/sites/clz7x2m4p0001abcd1234efgh/crawls \
  -H "Authorization: Bearer sk_your_key" \
  -H "x-request-id: 2026-10-11-homepage"
{
  "crawlId": "clz9h8m4c0002ijkl5678mnop",
  "status": "PENDING",
  "maxPages": 20000
}

maxPages is the page cap the crawl was created with. A site that is not verified is 409, a crawl already running on that site is 409, a full plan is 402, a spent monthly crawl allowance is 429, and a queue that refused the job is 503. Nothing is created and nothing is charged before one of those refusals.

GET /api/v1/crawls/{id}

One crawl: its status and progress counters. Poll this after starting a crawl.

curl https://internallinkingtool.com/api/v1/crawls/clz9h8m4c0002ijkl5678mnop \
  -H "Authorization: Bearer sk_your_key"
{
  "id": "clz9h8m4c0002ijkl5678mnop",
  "status": "COMPLETED",
  "maxPages": 20000,
  "pagesCrawled": 128,
  "pagesFailed": 3,
  "pauseCount": 1,
  "resumeAt": null,
  "stopReason": null,
  "startedAt": "2026-10-11T09:30:02.000Z",
  "finishedAt": "2026-10-11T09:36:51.000Z",
  "createdAt": "2026-10-11T09:30:01.000Z",
  "siteId": "clz7x2m4p0001abcd1234efgh",
  "site": { "host": "https://example.com" },
  "sitemapUrlCount": 131
}

status is one of PENDING, RUNNING, PAUSED, ANALYZING, COMPLETED, FAILED or CANCELED. A paused crawl carries the time it resumes in resumeAt. A crawl id that is not yours is 404.

GET /api/v1/crawls/{id}/orphans

The two orphan lists of a crawl, computed by the same rule the results page and the CSV file use. A sitemap page other than the home page with no inlinks at all is in orphans; one with no inlinks from page content is in contextualOrphans. A page with no inlinks is in both lists, because it has no content inlinks either.

curl https://internallinkingtool.com/api/v1/crawls/clz9h8m4c0002ijkl5678mnop/orphans \
  -H "Authorization: Bearer sk_your_key"
{
  "crawlId": "clz9h8m4c0002ijkl5678mnop",
  "counts": { "orphans": 1, "contextualOrphans": 5 },
  "orphans": [
    {
      "url": "https://example.com/guides/water-temperature",
      "inlinks": 0,
      "contextualInlinks": 0,
      "depth": null
    }
  ],
  "contextualOrphans": [
    {
      "url": "https://example.com/guides/",
      "inlinks": 4,
      "contextualInlinks": 0,
      "depth": 1
    }
  ]
}

GET /api/v1/crawls/{id}/suggestions?limit=

The internal links worth adding, highest score first. Each row names the page to link from, the page to link to, the anchor that already exists in the source page, the sentence it sits in, the score (a cosine of the two pages, so 0 to 1) and the terms that matched. limit is optional and capped, so total tells you what you are paging through.

curl "https://internallinkingtool.com/api/v1/crawls/clz9h8m4c0002ijkl5678mnop/suggestions?limit=100" \
  -H "Authorization: Bearer sk_your_key"
{
  "crawlId": "clz9h8m4c0002ijkl5678mnop",
  "total": 214,
  "limit": 100,
  "suggestions": [
    {
      "from": "https://example.com/guides/french-press-guide",
      "to": "https://example.com/guides/grind-size-chart",
      "anchor": "grind size",
      "sentence": "The grind size chart below covers every brewer on this site.",
      "score": 0.62,
      "matchedTerms": ["grind size"]
    }
  ]
}

Without limit you get the first 100 rows. A value that is not a whole number between 1 and 500 is 400.

GET /api/v1/crawls/{id}/export/{orphans|suggestions|links}

The three CSV files of a crawl, built by the same code the dashboard export buttons use, so a file and the JSON answers cannot disagree. The response is an attachment named after the kind.

curl https://internallinkingtool.com/api/v1/crawls/clz9h8m4c0002ijkl5678mnop/export/orphans \
  -H "Authorization: Bearer sk_your_key" \
  -o orphans.csv

The columns of each file:

A kind the product does not export is 404, like a crawl that is not there.

Rate limit and metering

Errors

Errors are JSON with two fields: error, a label a client can branch on, and message, the sentence a person can read.

{
  "error": "Unauthorized.",
  "message": "Send a valid API key as Authorization: Bearer <key> or in the x-api-key header."
}