Skip to content
TurboIndex

TurboIndex API and webhooks

Everything the dashboard does, from code: queue pages for Google and IndexNow, read what Google said about each page, and get a signed call when a page changes state. A deploy script, a CMS plugin or an agency tool can run on it. The WordPress plugin is built on it.

Base URL https://turboindex.io/api/v1. Included in every plan, the free one too.

On this page

Authentication

Keys are made on the API page ("Create key") or by the WordPress plugin's connect flow. A key looks like ti_live_ followed by 40 hex characters. It is shown once; TurboIndex stores only its hash, so a lost key is replaced, never recovered. A key can be scoped to one site (the plugin's keys are) or cover every site of the account.

Send it as a bearer token on every request:

Authorization: Bearer ti_live_3f9a...

X-Api-Key: ti_live_... works too. Revoking a key on the API page stops it at once.

Limits. 600 requests per minute per key (429 rate_limited beyond that). The plan's daily limits apply to what the requests do: a site's table is capped by the plan's URLs per site, Google submissions by the site's daily limit and its keys. Queueing a page through the API does not send it at once: the worker sends queued pages in the next minutes, within today's quota, checked pages first.

Endpoints

JSON in, JSON out. Dates are ISO 8601 in UTC. Site ids are the ones in your dashboard's addresses, or from GET /sites.

Method and pathWhat it does
GET /meThe account and the key: { "user": { "email", "plan" }, "key": { "name", "site_id" } }
GET /sitesEnabled sites with counts and today's usage. A site-scoped key sees its site only
GET /sites/{id}One site, plus last_submission_at, sitemaps and page_checks
POST /sites/{id}/urlsQueue up to 2,000 pages: { "urls": [...], "priority": "first" | "normal" }
DELETE /sites/{id}/urlsPages that are gone: { "urls": [...] }. Marked "Removed by you", sent to IndexNow, URL_DELETED to Google
POST /sites/{id}/urls/statusStatus of up to 500 pages: { "urls": [...] }
GET /sites/{id}/urlsThe URL table, 100 per page: ?status=&issue=&q=&page=
GET /webhooksYour webhooks, the secret hidden
POST /webhooks{ "url", "events": [...], "site_id"? }, answers with the secret once
DELETE /webhooks/{id}Removes a webhook
POST /connect/exchangeWordPress plugin only, no key: { "code", "site" } becomes { "api_key", "site_id", "host" }

Sites

GET /sites answers with an array of enabled sites. Each has id, host, site_url (the Search Console property, https://yoursite.com/ or sc-domain:yoursite.com), status (needs_setup, active, paused), state (the words of the dashboard row, such as "Auto-indexing on" or "Quota used today, resumes after midnight Pacific"), state_key (the same as a word for code: active, quota_used, needs_setup, key_error and so on), counts (total, indexed, submitted, queued, discovered, not_indexed, excluded, error) and today (submissions, inspections, cap: the site's daily Google cap).

GET /sites/{id} adds last_submission_at, sitemaps (count, errors) and page_checks (ok, problems, unchecked: how the pages answered TurboIndexBot's pre-check).

Pages

POST /sites/{id}/urls queues pages with the rules of "Submit URLs" in the dashboard: a page must belong to the property (foreign otherwise), it must be a valid http(s) URL (invalid), the plan's URL limit applies (limit, or 402 plan_limit when nothing fits), and a page submitted in the last 24 hours keeps its state (recent). priority is first for the front of the line (what the dashboard does) or normal. The answer is { "queued": n, "skipped": [{ "url", "reason" }] }. In the dashboard a new page shows where it came from: "Sent by the WordPress plugin" for the plugin's key, "Sent through the API" for the others.

DELETE /sites/{id}/urls is for pages that are gone. Each is marked "Removed by you" and never submitted again, goes out to IndexNow on its next run (IndexNow takes removed pages like any other URL), and Google hears URL_DELETED through the site's first live key, up to 100 pages per request within the key's daily quota. A page the table did not know is added as removed, so it is reported too. Answer: { "removed": n, "skipped": [...] }. Queueing a removed page again (POST) puts it back in line.

POST /sites/{id}/urls/status answers with one entry per page asked, in the same order, with the fields of the example below. A page TurboIndex has never seen comes back with "status": "unknown" and the other fields null. GET /sites/{id}/urls lists the same entries 100 at a time with the dashboard's filters: status (one of the statuses below), issue (any, or one of the pre-check issues: not_found, redirect, noindex, robots_blocked, canonical_elsewhere, soft_404, server_error, access_denied, rate_limited, unreachable, slow), q (part of the URL) and page; the answer is { "urls": [...], "total", "page", "pages" }.

Statuses: discovered (found in a sitemap, not checked yet), queued, submitted, indexed, not_indexed, excluded (blocked by the page, by the pre-check or by you), error (Google rejected the URL). google_says is Google's coverage state in its own words, or the pre-check's reason when TurboIndexBot blocked the page. page_check is null until TurboIndexBot fetched the page; then status is the HTTP status code and issue one of the issues above or null when the page answered cleanly.

Examples

Queue two pages at the front of the line, from a shell:

curl https://turboindex.io/api/v1/sites/SITE_ID/urls \
  -H "Authorization: Bearer ti_live_3f9a..." \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://yoursite.com/new-page", "https://yoursite.com/updated-page"], "priority": "first"}'
{
  "queued": 2,
  "skipped": []
}

Read the status of a page from JavaScript (Node 18 or newer, no library needed):

const res = await fetch("https://turboindex.io/api/v1/sites/SITE_ID/urls/status", {
  method: "POST",
  headers: { authorization: "Bearer " + process.env.TURBOINDEX_KEY, "content-type": "application/json" },
  body: JSON.stringify({ urls: ["https://yoursite.com/new-page"] }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { urls } = await res.json();
console.log(urls[0].status, urls[0].google_says);
{
  "urls": [
    {
      "url": "https://yoursite.com/new-page",
      "status": "indexed",
      "google_says": "Submitted and indexed",
      "last_crawl_at": "2026-10-11T07:12:00.000Z",
      "checked_at": "2026-10-12T09:00:00.000Z",
      "submitted_at": "2026-10-09T10:30:00.000Z",
      "indexed_at": "2026-10-12T09:00:00.000Z",
      "page_check": { "status": 200, "issue": null, "final_url": null, "checked_at": "2026-10-09T10:00:00.000Z" }
    }
  ]
}

Webhooks

A webhook is an https address that receives a POST with a JSON body for the events it subscribed to. Make one on the API page or with POST /webhooks; the answer carries the secret once. A webhook can be limited to one site or hear from every site of the account. Webhooks come with the Pro, Agency and Ultimate plans; API keys and every other endpoint work on all plans, Free included.

EventWhen
url.submitteda page was sent to Google
url.indexedGoogle indexed a page
url.not_indexedGoogle looked at a submitted page and left it out
url.droppedan indexed page came back not indexed
url.blockedthe pre-check kept a page out (404, redirect, noindex, canonical elsewhere, soft 404)
site.sitemap_errora sitemap could not be read

The body of a url.* event is the entry of the status endpoint with event, site_id and at in front; url.blocked adds issue and label. site.sitemap_error carries sitemap and error instead of a page.

{
  "event": "url.indexed",
  "site_id": "4c1d...",
  "at": "2026-10-12T09:00:00.000Z",
  "url": "https://yoursite.com/new-page",
  "status": "indexed",
  "google_says": "Submitted and indexed",
  "last_crawl_at": "2026-10-11T07:12:00.000Z",
  "checked_at": "2026-10-12T09:00:00.000Z",
  "submitted_at": "2026-10-09T10:30:00.000Z",
  "indexed_at": "2026-10-12T09:00:00.000Z",
  "page_check": { "status": 200, "issue": null, "final_url": null, "checked_at": "2026-10-09T10:00:00.000Z" }
}

Two headers come with every delivery: X-TurboIndex-Event with the event name and X-TurboIndex-Signature with sha256= followed by the HMAC-SHA256 of the raw body, keyed with the webhook secret. Answer with any 2xx within 10 seconds. Anything else is retried 5 times over about 15 hours (after 1, 5, 30, 120 and 720 minutes); a webhook that fails 20 times in a row is disabled and listed as such on the API page, where you can switch it on again.

Checking the signature

Compute the HMAC over the raw bytes, before any JSON parsing, and compare it in constant time. In Node:

import { createHmac, timingSafeEqual } from "node:crypto";

// Express: app.post("/turboindex-hook", express.raw({ type: "application/json" }), handler)
export function handler(req, res) {
  const expected = "sha256=" + createHmac("sha256", process.env.TURBOINDEX_WEBHOOK_SECRET).update(req.body).digest("hex");
  const given = req.get("x-turboindex-signature") ?? "";
  if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  console.log(req.get("x-turboindex-event"), event.url, event.status);
  res.status(204).end();
}

Deliveries can arrive twice

A retry after a timeout can reach you after the first attempt did. Treat at plus url plus event as the identity of a delivery and ignore one you have seen.

Errors

Errors are JSON with the HTTP status: { "error": { "code": "not_found", "message": "No such site on this account." } }. The message says what to do next; the code is what to branch on.

CodeStatusMeaning
unauthorized401No key, an unknown key or a revoked one.
forbidden403The key is scoped to another site, or the account is suspended.
not_found404No such site or webhook on this account (sites you removed count as not found).
invalid400The body is not what the endpoint expects; the message names the field.
plan_limit402The site reached the pages its plan watches, so nothing could be queued; or webhooks on a plan without them.
rate_limited429More than 600 requests in a minute with one key.
internal500Something failed on our side. Try again in a minute.

The WordPress plugin connect flow

The plugin never asks you to paste a key. It connects in four steps:

  1. The plugin sends the admin to https://turboindex.io/connect/wordpress?site=<home_url>&return=<admin_url>&state=<nonce>.
  2. The person signs in with Google (an account is created if needed), sees the Search Console property that matches the site's host (or picks one, or adds the site) and presses Connect. TurboIndex creates a key scoped to that site, named "WordPress plugin on <host>", and a one-time code.
  3. The browser is sent to <return>?ti_code=<code>&state=<nonce>. The plugin checks the nonce, then calls POST /connect/exchange from the server with the code and its home URL, and stores the answer's api_key and site_id. The code is single use and expires after 10 minutes; the key is never in a URL.
  4. From then on the plugin uses POST /sites/{id}/urls on publish and update, DELETE /sites/{id}/urls on trash and unpublish, POST /sites/{id}/urls/status for the post list column (cached one hour) and GET /sites/{id} for its dashboard widget.
curl https://turboindex.io/api/v1/connect/exchange \
  -H "Content-Type: application/json" \
  -d '{"code": "9f2c...", "site": "https://yoursite.com/"}'

{ "api_key": "ti_live_3f9a...", "site_id": "4c1d...", "host": "yoursite.com" }

The site in the exchange must be on the host the code was made for (www. does not matter, and a Domain property covers its subdomains). Disconnecting in the plugin deletes its options; revoking the key on the API page stops the plugin at once. Any other CMS can use the same flow: send the person to the connect address, take the code back, exchange it once.

Ready to build on it?

Make a key on the API page of your account. Questions and requests for endpoints you miss: [email protected].

Open the API page