Sync calls, async tasks or webhooks: which to use
An AI assistant’s answer takes far longer than a typical API call: the engine reads the prompt, often searches the web, and writes. There are three ways to handle that wait, and all three return the same result JSON, so the choice comes down to failure handling and what you have to operate.
A synchronous call is one HTTP request to POST /v1/monitor/{engine} that stays open until the answer is ready. An async task with polling starts with POST /v1/async/task, which returns a task id at once; you read GET /v1/async/task/{taskId} until it is final. An async task with a webhook is the same task with webhook: {"url": ...}, and the finished task is POSTed to you.
In short: synchronous calls for interactive, one-at-a-time work; tasks for anything scheduled, batched or larger than a handful of requests; webhooks once you have many tasks and can run a public HTTPS endpoint.
All three modes return the same result JSON; what differs is who carries the wait and what happens when the connection drops.
The comparison
| Synchronous | Async + polling | Async + webhook | |
|---|---|---|---|
| Endpoint | POST /v1/monitor/{engine} |
POST /v1/async/task or /batch, then GET /v1/async/task/{taskId} |
Same creation, with webhook.url |
| Requests per submission | 1 | 1 to 500 in a batch | 1 to 500 in a batch |
| Safe to retry after a timeout | Only if it never reached the server | Yes, with an idempotencyKey |
Yes, with an idempotencyKey |
| Priority | Not applicable | priority 1 to 10 |
priority 1 to 10 |
| You operate | A long-lived HTTP call | A polling loop | A public HTTPS endpoint, plus a reconciliation poll |
| Suits | Interactive tools, agents, one-off checks | Scripts, notebooks, small jobs | Pipelines, schedules, large prompt sets |
Synchronous calls
curl -X POST "https://api.answerline.dev/v1/monitor/chatgpt" \
-H "Authorization: Bearer $ANSWERLINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "iPhone 17 or Galaxy S26?", "country": "US"}'
The response body is {"success": true, "result": {...}}, with headers that tell you what the call cost and what is left: X-Credits-Charged (the charge in dollars, such as 0.0006), X-Credits-Remaining (your balance in dollars) and X-Latency-Ms. X-RateLimit-Limit and X-RateLimit-Remaining are deprecated and always 100000.
In Python, the one setting people get wrong is the client timeout. A synchronous call may take up to five minutes, so the read timeout must be longer than that:
import os
import requests
API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['ANSWERLINE_API_KEY']}"}
res = requests.post(
f"{API}/v1/monitor/chatgpt",
json={"prompt": "iPhone 17 or Galaxy S26?", "country": "US"},
headers=HEADERS,
timeout=(10, 330), # connect, read: longer than the five-minute server bound
)
res.raise_for_status()
print(res.headers["X-Credits-Charged"], res.json()["result"]["text"][:200])
In TypeScript the SDK sets a long per-attempt timeout for synchronous calls and applies the retry policy below:
import { Client } from "@answerline/sdk";
const client = new Client({ apiKey: process.env.ANSWERLINE_API_KEY! });
const { result } = await client.chatgpt({ prompt: "iPhone 17 or Galaxy S26?", country: "US" });
The trade-offs:
- It holds a connection for the whole run. There is no concurrency cap, but the client has to keep that request open until the answer arrives.
- Retries need care. If the connection drops mid-call, the request may still finish and be charged. Retry only when it never reached the server, such as a refused connection or an unresolved host. A
500or502from a synchronous call is final: the server has already retried, and neither is charged.
Use synchronous calls where a person or an agent is waiting for one answer: a dashboard’s “check now” button, a support tool, a notebook cell, or an agent calling through the MCP server, whose tool calls are synchronous.
When a synchronous call gets 429
There is no per-account request rate limit, so scheduled work cannot use up what your “check now” button needs. A synchronous call can still get 429 CONCURRENT_LIMIT_EXCEEDED when it cannot start, or 429 SERVICE_OVERLOADED when the API sheds load. Two options:
- Retry the synchronous call with jittered backoff, or after
Retry-Afterseconds when it is sent. It is always safe, because nothing was admitted or charged. - Send the interactive request as a task with
"priority": 10and wait for it. It runs ahead of your priority-1 batch and waits in the queue instead of failing.
Limits are on the rate limits page.
Option 2 is usually the best fit, and the SDK makes it one line: await client.asyncTasks.run({ taskType: "CHATGPT", payload, priority: 10 }) creates the task and resolves once it reaches COMPLETED or FAILED.
Async tasks with polling
curl -X POST "https://api.answerline.dev/v1/async/task" \
-H "Authorization: Bearer $ANSWERLINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"taskType": "CHATGPT", "payload": {"prompt": "iPhone 17 or Galaxy S26?", "country": "US"}, "idempotencyKey": "iphone-galaxy:US:2026-09-15"}'
This answers at once with task (id, status QUEUED, priority, createdAt, idempotencyKey) and credits (creditsToCharge, the reserved maximum, and creditsCharged, null until it finishes, both in dollars). The task waits in the queue instead of failing.
A task moves from QUEUED to PROCESSING, then to COMPLETED or FAILED. Treat only the last two as final: a task can return from PROCESSING to QUEUED, so a poller may see both in that order. A polling loop with jittered backoff:
import random
import time
FINAL = {"COMPLETED", "FAILED"}
def wait_for_task(task_id: str, limit_s: float = 660) -> dict:
deadline, cap = time.monotonic() + limit_s, 0.5
while time.monotonic() < deadline:
try:
res = requests.get(f"{API}/v1/async/task/{task_id}", headers=HEADERS, timeout=30)
if res.status_code == 200 and res.json()["task"]["status"] in FINAL:
return res.json()
if 400 <= res.status_code < 500 and res.status_code != 429:
res.raise_for_status() # 401 or 404: polling again will not help
except (requests.ConnectionError, requests.Timeout):
pass # transient; poll again
time.sleep(random.uniform(0, cap))
cap = min(cap * 2, 10)
raise TimeoutError(task_id)
done = wait_for_task("<task id>")
if done["task"]["status"] == "COMPLETED":
print(done["response"]["result"]["text"][:200])
else:
print("failed, not charged:", done["response"])
The TypeScript SDK ships this loop as client.waitForTask(id, { intervalMs, timeoutMs }), backing off with jitter from 500 ms up to 10 seconds between polls.
Polling has a scaling limit. Every poll is a request, and between a task finishing and your next poll the result sits unread. With a few tasks neither matters. With thousands of open tasks, polling turns into a scheduler you have to tune. That is the point to switch to webhooks.
A task that has not started within 4 hours of creation fails uncharged with 502 EXTERNAL_SERVICE_ERROR and details.cause QUEUE_WAIT_EXCEEDED, and a started task has five minutes to finish. Your loop’s own timeout should reflect how many tasks you have pending; GET /v1/async/status reports queuedTasks, and processingTasks.
Async tasks with webhooks
Add a URL and the finished task comes to you:
{
"taskType": "CHATGPT",
"payload": { "prompt": "iPhone 17 or Galaxy S26?", "country": "US" },
"idempotencyKey": "iphone-galaxy:US:2026-09-15",
"webhook": { "url": "https://your-app.example/hooks/answers" }
}
The body is what GET /v1/async/task/{taskId} returns for the finished task. A minimal correct receiver in TypeScript:
import express from "express";
import { verifyWebhook } from "@answerline/sdk";
const app = express();
app.post("/hooks/answers", express.raw({ type: "application/json" }), async (req, res) => {
const ok = await verifyWebhook(req.body, req.get("webhook-signature"), process.env.WEBHOOK_SECRET!);
if (!ok) return res.sendStatus(400);
const delivery = JSON.parse(req.body.toString("utf8"));
if (!delivery.test) await jobs.enqueue(delivery.task.id, delivery); // your job queue, keyed by task id
res.sendStatus(204);
});
Three rules keep a receiver correct:
- Verify the signature on the raw body, before parsing.
Webhook-Signature: t=<unix>,v1=<hex>signs<t>.<raw body>with HMAC-SHA256 and yourwhsec_...secret; JSON that was parsed and re-serialized will not match. The SDK helper rejects timestamps more than 300 seconds from your clock. Verifying webhook signatures has Python and Node versions. - Answer 2xx within 15 seconds, then do the work. Anything else, including redirects and timeouts, is retried: up to 10 attempts, waits starting at 5 seconds and doubling, the last attempt roughly 21 to 43 minutes after the first.
- Deduplicate by
task.id. Deliveries are concurrent and retried independently, so they can repeat and arrive out of order.Webhook-Idis not signed;task.idis inside the signed body.
Webhooks move the waiting off your side, but they add an obligation: a public HTTPS endpoint that resolves to a public address, with uptime you are responsible for. Dashboard test deliveries arrive validly signed with "test": true, which is why the receiver checks it.
Webhooks plus a reconciliation poll
Webhooks alone do not guarantee delivery. If your endpoint is down past the last retry, a delivery is marked dead. The dashboard can redeliver it, but a pipeline should recover without manual steps. Combine both collection methods:
- Create tasks with meaning-derived idempotency keys and a webhook URL; record every returned
task.id. - Store each verified delivery keyed by
task.id. - On a schedule, list the ids you created but never stored, and read each with
GET /v1/async/task/{taskId}, filing the ones that areCOMPLETEDorFAILED.
The reconciliation poll reads only the gaps, so it sends few requests, and it turns endpoint outages into a delay rather than data loss. The rank-tracking pipeline implements this end to end, and webhook reliability design covers the receiver side in depth.
Idempotency in every mode
Whichever mode you pick for collection, give each task an idempotencyKey derived from what it means, such as prompt, market and day, never generated fresh per attempt. Creating a task with a key already in use answers 409 RESOURCE_CONFLICT (RESOURCE_ALREADY_EXISTS inside a batch) and creates nothing, so retrying a creation after a timeout or a 5xx can never run a task twice. Synchronous calls have no key, which is why their retry rules are stricter. Idempotency keys explained and handling API errors cover the full policy.
Deciding for your workload
Work through these in order:
- Is someone waiting on this specific answer right now? If yes and it is one request, call synchronously. If your account also runs batches, prefer a priority-10 task and wait on it.
- For more than a handful of requests, or a schedule, use tasks. You batch up to 500 per call, and queued tasks wait instead of failing.
- Can you run a public HTTPS endpoint? If yes, use webhooks plus a reconciliation poll. If not, as with a laptop script, a CI job without ingress or a notebook, poll with backoff.
- If the job has thousands of open tasks to poll, use webhooks. If you are unsure, start by polling and add the webhook URL later; the result JSON is the same, so nothing downstream changes.
A worked example: 400 ChatGPT prompts in the US is 400 requests, 400 × $0.0006 = $0.24. Synchronously that is 400 open connections. As tasks it is a single batch request, where tasks wait in the queue. Cost planning shows how to size the bill across engines.
Pitfalls
- Client timeouts shorter than five minutes on synchronous calls turn slow answers into charged requests you never read.
- Retrying a synchronous
500or502. It is final: the server already retried, and a502means the engine failed in a way retrying would not fix. - Treating
PROCESSINGas final in a poller. - Parsing the webhook body before verifying it.
- Relying on webhooks without a reconciliation poll.
For a Google-specific build of the webhook pattern, see tracking AI Overviews with batches and webhooks. Reference details are in the async tasks and webhooks docs.
Questions
What is the difference between a synchronous call and an async task?
A synchronous call to /v1/monitor/{engine} holds the HTTP connection open until the answer is ready and returns it in the response. An async task is created with POST /v1/async/task, answers at once with a task id, and delivers the result later by polling or webhook.
Why do I get 429 on a synchronous call?
There is no concurrency cap and no per-account request rate limit, so a synchronous call is not refused because a batch is running. It gets 429 CONCURRENT_LIMIT_EXCEEDED when it cannot start and SERVICE_OVERLOADED when the API sheds load, while a submitted task simply waits in the queue.
Is it safe to retry a synchronous call that timed out?
Not blindly. The request may still finish and be charged after your connection drops, and a 500 or 502 from a synchronous call is final because the server already retried. Retry only when the request never reached the server, or switch to tasks with idempotency keys.
Should I poll or use webhooks for async tasks?
Poll when a script waits for a handful of tasks and has no public endpoint. Use webhooks when you run many tasks or a schedule, because every poll is a request, and add a reconciliation poll for deliveries that never arrive.