AnswerLine Sign in Start free

Rank Tracking · Tutorials

Building a rank-tracking pipeline for AI answers with batches

A rank tracker for AI answers asks the same questions on a schedule and records where your domain appears among the cited sources. This post builds one on async batches: a submission that is safe to re-run, collection by webhook, and a reconciliation pass for anything that didn’t arrive.

The code is Python with httpx and reads API_URL (your API base URL) and API_KEY from the environment.

The unit of work

One task is one prompt, in one market, on one day. That triple is also its identity, so it makes the idempotency key:

def task_key(prompt_id: str, country: str, day: str) -> str:
    return f"{prompt_id}-{country}-{day}"

Keys are unique across your account, so re-running a day’s submission creates nothing twice: tasks that exist come back as RESOURCE_ALREADY_EXISTS.

Submitting in batches

POST /v1/async/task/batch takes 1 to 500 tasks. Each is validated and admitted on its own, and results answers each by index, in input order.

import os
import httpx

MAX_BATCH = 500
api = httpx.Client(
    base_url=os.environ["API_URL"],
    headers={"Authorization": f"Bearer {os.environ['API_KEY']}"},
    timeout=30,
)

def build_tasks(prompts: dict, countries: list, day: str, webhook_url: str) -> list:
    return [
        {
            "taskType": "CHATGPT",
            "payload": {"prompt": prompt, "country": country},
            "idempotencyKey": task_key(prompt_id, country, day),
            "webhook": {"url": webhook_url},
        }
        for prompt_id, prompt in prompts.items()
        for country in countries
    ]

def submit(tasks: list) -> dict:
    """Queues every task; returns the ids of tasks created by this call, by idempotency key."""
    created = {}
    for start in range(0, len(tasks), MAX_BATCH):
        chunk = tasks[start:start + MAX_BATCH]
        res = api.post("/v1/async/task/batch", json=chunk)
        res.raise_for_status()
        for item in res.json()["results"]:
            key = chunk[item["index"]]["idempotencyKey"]
            if item["success"]:
                created[key] = item["task"]["id"]
            elif item["error"]["code"] == "INSUFFICIENT_CREDITS":
                raise RuntimeError(f"out of credits at {key}")
            elif item["error"]["code"] != "RESOURCE_ALREADY_EXISTS":
                print("rejected", key, item["error"])
    return created

Persist what submit returns; reconciliation needs the ids. Three failures to plan for:

To let some prompts jump ahead, such as a launch week’s, add "priority": 10 to their tasks. The default is 1, and priorities only order your own queue.

Collecting results

Each finished task is POSTed to the webhook with the body GET /v1/async/task/{id} returns. The idempotency key rides along in task.idempotencyKey, so the receiver can file a result without a lookup:

from typing import Optional
from urllib.parse import urlsplit

def citation_rank(result: dict, domain: str) -> Optional[int]:
    """Position of the first cited source on the domain or a subdomain of it."""
    for source in sorted(result.get("sources", []), key=lambda s: s["position"]):
        host = urlsplit(source["url"]).hostname or ""
        if host == domain or host.endswith("." + domain):
            return int(source["position"])
    return None

def record(delivery: dict, domain: str, brand: str) -> dict:
    task = delivery["task"]
    row = {"key": task["idempotencyKey"], "task_id": task["id"], "status": task["status"]}
    if task["status"] == "COMPLETED":
        result = delivery["response"]["result"]
        row["rank"] = citation_rank(result, domain)
        row["mentioned"] = any(brand.lower() in e["name"].lower() for e in result.get("entities", []))
    return row

Verify the signature before calling record (webhooks guide), upsert rows on task_id because deliveries can repeat, and answer 2xx before slow work.

A FAILED task is not charged and its response holds the error. Keep its row so gaps in coverage stay visible. Its key stays taken, so a second attempt needs a new key, for example with an attempt suffix.

Reconciling

Webhook deliveries are retried with backoff for up to 10 attempts. If your endpoint is down longer, they stop, so after each run poll the tasks you created but never stored:

def reconcile(created: dict, stored_ids: set, domain: str, brand: str) -> list:
    rows = []
    for task_id in set(created.values()) - stored_ids:
        res = api.get(f"/v1/async/task/{task_id}")
        res.raise_for_status()
        delivery = res.json()
        if delivery["task"]["status"] in ("COMPLETED", "FAILED"):
            rows.append(record(delivery, domain, brand))
    return rows

Scheduling and cost

Start the submission from any scheduler once per period. A run costs prompts × markets × the engine’s price per task, failures are free, and the pricing page lists prices and add-ons. Check GET /v1/credits before a large run.

Try it on your own prompts

500 free credits a month, no card. One POST returns the answer, sources and citations as JSON.

Keep reading