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 path | What it does |
|---|---|
GET /me | The account and the key: { "user": { "email", "plan" }, "key": { "name", "site_id" } } |
GET /sites | Enabled 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}/urls | Queue up to 2,000 pages: { "urls": [...], "priority": "first" | "normal" } |
DELETE /sites/{id}/urls | Pages that are gone: { "urls": [...] }. Marked "Removed by you", sent to IndexNow, URL_DELETED to Google |
POST /sites/{id}/urls/status | Status of up to 500 pages: { "urls": [...] } |
GET /sites/{id}/urls | The URL table, 100 per page: ?status=&issue=&q=&page= |
GET /webhooks | Your webhooks, the secret hidden |
POST /webhooks | { "url", "events": [...], "site_id"? }, answers with the secret once |
DELETE /webhooks/{id} | Removes a webhook |
POST /connect/exchange | WordPress 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.
| Event | When |
|---|---|
url.submitted | a page was sent to Google |
url.indexed | Google indexed a page |
url.not_indexed | Google looked at a submitted page and left it out |
url.dropped | an indexed page came back not indexed |
url.blocked | the pre-check kept a page out (404, redirect, noindex, canonical elsewhere, soft 404) |
site.sitemap_error | a 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
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.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No key, an unknown key or a revoked one. |
forbidden | 403 | The key is scoped to another site, or the account is suspended. |
not_found | 404 | No such site or webhook on this account (sites you removed count as not found). |
invalid | 400 | The body is not what the endpoint expects; the message names the field. |
plan_limit | 402 | The site reached the pages its plan watches, so nothing could be queued; or webhooks on a plan without them. |
rate_limited | 429 | More than 600 requests in a minute with one key. |
internal | 500 | Something 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:
- The plugin sends the admin to
https://turboindex.io/connect/wordpress?site=<home_url>&return=<admin_url>&state=<nonce>. - 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.
- The browser is sent to
<return>?ti_code=<code>&state=<nonce>. The plugin checks the nonce, then callsPOST /connect/exchangefrom the server with the code and its home URL, and stores the answer'sapi_keyandsite_id. The code is single use and expires after 10 minutes; the key is never in a URL. - From then on the plugin uses
POST /sites/{id}/urlson publish and update,DELETE /sites/{id}/urlson trash and unpublish,POST /sites/{id}/urls/statusfor the post list column (cached one hour) andGET /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