Why Sync-Only is the Right Default for Data APIs
Engineering opinion: why synchronous request-response is the right default for data APIs AI agents call, with a tested example, trade-offs, and when async fits.

Key takeaway (TL;DR) — The SandBase data endpoint tested here,
douyin/app-v3/user-profile, declaresexecution_mode: syncin its reference, and our dated sample completed in one request. We think sync is the right default for data retrieval: it simplifies agent integration, removes state management, and matches how a blocking LLM tool loop works. Generation workloads may be asynchronous; check the selected model’sexecution_modeand endpoint reference. This is an opinion piece; the only measurements in it are a small, dated sample described where it appears.
The opinion, stated clearly
For data APIs consumed by AI agents, synchronous request-response is the correct default. Not because async is bad, but because sync removes an entire category of integration complexity that agents don’t need, and that complexity has real costs in reliability, debuggability, and development speed.
This is an engineering opinion, formed while building and operating the social data endpoints on SandBase and watching how agent developers integrate with them. Our impression is that sync endpoints are easier to adopt and to debug. We haven’t run a controlled study on that, so read it as experience, not data.
A note on surfaces, because it matters for what the evidence below means. Every example and measurement in this article uses the data API route, POST https://api.sandbase.ai/v1/api/<vendor>/<path>, specifically douyin/app-v3/user-profile. The generation workloads mentioned later (images, video, audio) use a different Model API surface, /v1/run, which is documented separately. Nothing measured here applies to /v1/run.
How LLM tool-calling actually works
Before discussing API design, understand the execution model of the consumer.
In the common blocking tool loop (function calling as most LLM APIs expose it), the flow is:
1. LLM generates a tool call: {"name": "get_user", "args": {"id": "123"}}
2. Runtime executes the function
3. Runtime returns the result to the LLM
4. LLM continues reasoning with the resultFrom the LLM’s perspective this is synchronous. The model generates a call, pauses, receives a result, continues. The loop itself has no built-in step for:
- “Here’s a task ID, poll later”
- “I’ll call your webhook when ready”
- “Check back in 30 seconds”
You can build those patterns with client-managed background orchestration on top of tool-calling. But every async layer you add becomes complexity the agent developer must manage:
# What sync looks like in an agent tool (SandBase data API route)
import os
import requests
SANDBASE_API_KEY = os.environ["SANDBASE_API_KEY"]
def get_douyin_user(sec_user_id: str) -> dict:
resp = requests.post(
"https://api.sandbase.ai/v1/api/douyin/app-v3/user-profile",
headers={"Authorization": f"Bearer {SANDBASE_API_KEY}"},
json={"sec_user_id": sec_user_id},
timeout=30,
)
body = resp.json()
outputs = body.get("outputs") or []
if body.get("status") != "completed" or not outputs:
raise RuntimeError(f"SandBase call did not complete: {body.get('status')} {body.get('error')}")
return outputs[0].get("data") or {}
# Done. LLM gets the result immediately.# What a hypothetical async design looks like (for contrast; not a SandBase route)
def get_douyin_user(sec_user_id: str) -> dict:
task = api.post("/hypothetical-async/user-profile", json={"sec_user_id": sec_user_id})
task_id = task.json()["task_id"]
# Now what? The LLM is waiting.
# Option A: Poll in a loop (blocking, wasteful)
for _ in range(30):
result = api.get(f"/tasks/{task_id}")
if result.json()["status"] == "complete":
return result.json()["data"]
time.sleep(1)
raise TimeoutError("Task didn't complete in 30s")
# Option B: Return task_id to LLM (confusing)
# LLM: "I got a task_id but I need the actual data..."
# Now you need another tool call just to check statusThe async version is more code, introduces failure modes (timeout, lost tasks, partial results), and doesn’t match the blocking tool loop.
Tested on 2026-10-01 (UTC)
Public resource: the official Douyin account of People’s Daily, sec_user_id MS4wLjABAAAA8U_l6rBzmy7bcy6xOJel4v0RzoR_wfAubGPeJimN__4. Prerequisite: a SandBase API key in SANDBASE_API_KEY. Request:
curl -s -X POST https://api.sandbase.ai/v1/api/douyin/app-v3/user-profile \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sec_user_id": "MS4wLjABAAAA8U_l6rBzmy7bcy6xOJel4v0RzoR_wfAubGPeJimN__4"}'Trimmed real response (run id 1265e649-243c-49be-93d3-9f0c3f486eb2; the envelope’s id field, most of user, and the extra / log_pb objects removed):
{
"status": "completed",
"model": "douyin/app-v3/user-profile",
"outputs": [{
"data": {
"status_code": 0,
"user": {
"nickname": "人民日报",
"unique_id": "rmrbxmt",
"enterprise_verify_reason": "人民日报官方账号",
"follower_count": 203210829,
"aweme_count": 12276
}
}
}]
}The envelope (id, status, model, outputs) is what the endpoint reference documents. The payload fields (user, nickname, follower_count, and so on) are observed-only, not a documented guarantee. This is public, read-only profile data; SandBase is not an official Douyin partner.
For this public-profile use case:
SandBase douyin/app-v3/user-profile | Douyin Open Platform | |
|---|---|---|
| Data scope | Public, read-only profile data of public accounts | Data of accounts that have authorized your app |
| Authentication | One SandBase API key (Bearer) | Developer registration and an app, plus user authorization for account data |
| Actions | None; read-only | Account-linked operations for authorized accounts, per the platform’s docs |
| Relationship | SandBase is not an official Douyin partner | Douyin’s own developer platform |
| Use when | Reading a public profile, as in the test above | Working with accounts you own or that authorize your app |
The sync contract
The endpoint tested here follows a simple shape:
Request → Server fetches data → Response
POST /v1/api/<model> → JSON envelope or errorWhat’s declared and observed for douyin/app-v3/user-profile:
- Declared sync: The endpoint reference lists
execution_mode: sync - One wait in our sample: Every call in the dated sample below came back
completedwith the full result in a single request; the client picked the timeout - No client-side task tracking: There was nothing to poll in that sample; the response’s
idis a run id, not a handle you have to come back to - Retryable: On failure, retry the same request with the same parameters
- Observable: Latency = time between request and response. No hidden queues on the client side.
The public model page for the same endpoint shows the sync declaration at a glance.

Caption: The douyin/app-v3/user-profile model page lists Execution “sync”, Base price “Free” and one input field; captured 2026-10-02. The Free label is from API Free Week, which has since ended; data API calls now include 100 free calls per organization per day on eligible APIs, then are billed per call.
The same reference also documents the general run statuses pending, running, completed, failed and timeout, with pending and running examples. So don’t treat “sync” as a promise that a non-terminal status can’t appear. The code in this article already handles it defensively: it raises on any status other than completed (or on empty outputs), so an unfinished run surfaces as an error, not as data.
That status list is visible in the endpoint reference’s response schema.

Caption: The douyin/app-v3/user-profile reference shows POST /v1/api/douyin/app-v3/user-profile, one required sec_user_id field, and status allowed values pending, running, completed, failed and timeout; captured 2026-10-02.
How long the wait is depends on the endpoint and the upstream. We don’t publish a latency guarantee for the data endpoints, so measure your own mix (the sample below shows why).
Why this simplifies agent architecture
1. No state management
Async APIs require tracking outstanding tasks:
# Async: You need a task manager (hypothetical)
class TaskManager:
def __init__(self):
self.pending = {} # task_id -> metadata
self.results = {} # task_id -> result
async def submit(self, operation, params):
task = await api.post(operation, params)
self.pending[task.id] = {"submitted": time.time(), "params": params}
return task.id
async def check(self, task_id):
if task_id in self.results:
return self.results[task_id]
result = await api.get(f"/tasks/{task_id}")
if result.status == "complete":
del self.pending[task_id]
self.results[task_id] = result.data
return result.data
return None
async def cleanup_stale(self):
# Tasks that never completed...
for tid, meta in self.pending.items():
if time.time() - meta["submitted"] > 300:
# Retry? Abandon? Log? All three?
passSync APIs need none of this:
# Sync: Just call and get
# api = httpx.AsyncClient(base_url="https://api.sandbase.ai",
# headers={"Authorization": f"Bearer {SANDBASE_API_KEY}"},
# timeout=30)
result = await api.post("/v1/api/douyin/app-v3/user-profile", json={"sec_user_id": sec_uid})
# Done. No state. No cleanup. No stale tasks.For a single API call the difference seems trivial. For an agent making dozens of calls per decision cycle across several platforms, async state management becomes a real source of bugs.
2. Error handling is straightforward
Sync errors are immediate and actionable:
try:
result = await api.post("/v1/api/douyin/app-v3/user-profile", json={"sec_user_id": sec_uid})
result.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
await asyncio.sleep(1)
result = await api.post("/v1/api/douyin/app-v3/user-profile", json={"sec_user_id": sec_uid}) # Retry
result.raise_for_status()
else:
raise # Unexpected, bubble up
body = result.json()
outputs = body.get("outputs") or []
if body.get("status") != "completed" or not outputs:
raise RuntimeError(f"SandBase call did not complete: {body.get('status')} {body.get('error')}")
user = (outputs[0].get("data") or {}).get("user")
if user is None:
user = {"error": "user_not_found"} # Agent handles gracefully“Not found” doesn’t arrive as an HTTP 404 here. On 2026-10-01 I called the same endpoint with a made-up sec_user_id (MS4wLjABAAAA_this_id_does_not_exist_000000000000). It returned HTTP 200 with status: completed and this payload in outputs[0].data (run id c5123430-641b-4a5d-834a-3aae0885ab1c, extra / log_pb removed):
{"status_code": 2, "status_msg": null, "user": null}That’s observed-only, so the check sits on the payload. Either way the failure is immediate and tied to the request that caused it.
Async errors are distributed across time and harder to attribute:
# Hypothetical: submit succeeds, but task fails 10 seconds later
task = await api.post("/hypothetical-async/user-profile", json={"sec_user_id": sec_uid})
# No error yet... task is "processing"
# Later, when checking:
result = await api.get(f"/tasks/{task.id}")
# result.status might be: "failed", "timeout", "partial", "expired"
# Which sec_user_id was this for? Need to track that.
# Can I retry? Maybe. But the original context is gone.3. Debugging is linear
When a sync agent tool fails, the trace is simple (illustrative log, not a captured trace):
10:00:01.234 → Request: POST /v1/api/douyin/app-v3/user-profile {"sec_user_id": "MS4w..."}
10:00:03.001 → Response: 200 OK, 1.77s
10:00:03.002 → LLM receives result, continues reasoningWhen an async tool fails, the trace fragments (also illustrative):
10:00:01.234 → Submit: POST /hypothetical-async/user-profile
10:00:01.456 → Got task_id: task_xyz
10:00:05.000 → Poll: GET /tasks/task_xyz → "processing"
10:00:10.000 → Poll: GET /tasks/task_xyz → "processing"
10:00:15.000 → Poll: GET /tasks/task_xyz → "failed"
10:00:15.001 → Why did it fail? Check task details...
10:00:15.200 → GET /tasks/task_xyz/details → "upstream_timeout"
10:00:15.201 → Retry? Submit a new task...More log entries, spread across 15 seconds instead of one round trip. And the agent’s reasoning was blocked the entire time, or worse, continued with stale data.
4. Composability
Agents compose multiple tool calls into decision workflows. Sync makes composition natural (pseudocode):
# Agent's internal reasoning leads to this sequence:
user = await get_user(user_id)
videos = await get_user_videos(user_id, count=10)
engagement = calculate_engagement_rate(videos)
decision = f"Engagement {engagement}% is {'above' if engagement > threshold else 'below'} threshold"Each step completes before the next begins. The agent can reason about intermediate results and decide whether to continue or branch.
With async, composition requires orchestration (hypothetical):
# Submit all tasks
user_task = await submit_task("get_user", user_id)
video_task = await submit_task("get_videos", user_id)
# Wait for both (what if one fails?)
user = await wait_for_task(user_task, timeout=10)
videos = await wait_for_task(video_task, timeout=10)
# If video_task fails, do we still use user data?
# If user_task succeeds but video_task times out, retry?
# The branching logic explodesThe performance question: “Isn’t sync slower?”
Common objection: “With async, I can submit 100 tasks and get results as they arrive. Sync means I wait for each one sequentially.”
This conflates two things:
- API design (sync vs async): whether the server returns results immediately or via a task system
- Client concurrency: whether the client makes requests in parallel
You can have sync APIs with parallel client calls:
# Sync API + concurrent client
async def get_profiles(sec_user_ids: list[str]) -> list[dict]:
tasks = [
api.post("/v1/api/douyin/app-v3/user-profile", json={"sec_user_id": s})
for s in sec_user_ids
]
results = await asyncio.gather(*tasks)
return [r.json() for r in results]
# Wall-clock ≈ the slowest single call, not the sumEach sync request completes on its own; the client fires them concurrently, so total wall-clock time tracks the slowest single call rather than the sum, up to whatever rate limits apply to your key.
What we measured (small sample)
All on 2026-10-01 (UTC), from one client, against POST /v1/api/douyin/app-v3/user-profile:
- 10 sequential calls for the People’s Daily account above: all 10 completed. Nine took between 1.66 s and 2.21 s; one took 7.16 s. Median 1.76 s. (Run ids include
f817b42d-22a3-452e-8d8b-59b0bd42ba2eand the slow one,fe48be04-0b3b-454d-8c93-65513f4abfdf.) - 1 call vs 3 concurrent calls: about 1.7 s for one, about 1.7 s wall-clock for three in parallel (single
72241124-a0f4-4f32-a948-9ec9deaf432d; concurrent506796c0-796a-40f7-a8ec-947d25efefb7,97799423-a91d-41a5-87dc-8c871f4adc96,ad8b973e-48a1-44e8-91c5-c489aec2123b).
That’s 14 calls on one endpoint, not a benchmark. It does show two things. Concurrency on the client gives you parallelism without an async API. And latency has a tail: a 5-second client timeout would have cut off one successful call in ten. Because the calls are stateless, a timeout-and-retry is cheap, but pick the timeout from your own measurements.
For bounded retrieval, we think this beats async because:
- You know immediately which calls succeeded and which failed
- No task management overhead
- No polling cost
- The server doesn’t need to maintain task state
When async fits
Sync is not always appropriate. Here’s where async can be the better choice:
1. Generation tasks (video, images)
Text → video generation and image generation often run for tens of seconds or longer
(rough, model-dependent; not a SandBase measurement).
That doesn't fit comfortably inside one HTTP request.Generation workloads may be asynchronous. On SandBase, image, video, and audio generation use the Model API’s /v1/run surface rather than the /v1/api data route, and the selected model’s execution_mode decides whether a request completes synchronously or returns 202 Accepted for polling. Check that model’s execution_mode and its reference in the Model API docs; this article doesn’t cover that surface.
2. Bulk exports
"Export all 10,000 comments on this video"
This requires many paginated fetches.When the data volume is too large for a single response and the fetching takes longer than reasonable HTTP timeouts, async with a download URL makes sense.
3. Multi-step aggregations
"Calculate 90-day engagement trend for this account"
Requires fetching 90 days of data + computation.When the server needs to perform significant computation across multiple data fetches, async prevents HTTP timeout issues.
The pattern
Notice what these have in common: they involve creation or computation, not retrieval. Data retrieval (“give me this user’s profile,” “give me this video’s stats,” “search for this keyword”) is usually fast enough for one request. The underlying data exists; the API just needs to fetch and return it.
The line we draw:
- Data retrieval → sync (the data exists, just fetch it)
- Data generation/computation → often async (the result doesn’t exist yet, must be created; on SandBase, check the model’s
execution_mode)
Architecture implications
For API providers
If you’re building a data API for agent consumption:
- Default to sync. Our rule of thumb: if the operation usually completes within a few seconds, make it sync.
- Be clear about timing. Tell clients what to expect per operation so they can pick timeouts.
- Use HTTP semantics correctly. 200 for success, 4xx for client errors, 5xx for server errors, 429 for rate limits with a Retry-After header.
- Return complete results. Don’t return partial data with a “fetch more” pattern for simple queries.
- Reserve async for generation. Use task-based patterns when the operation genuinely takes too long for one request.
For agent developers
If you’re building agents that consume data APIs:
- Prefer sync APIs. They’re simpler to integrate, debug, and maintain.
- Use client-side concurrency for parallelism.
asyncio.gather()gives you parallel execution with sync APIs. - Set timeouts from measurements. Measure your endpoints, set the timeout above the normal range, and retry on timeout. A stateless call makes retry cheap.
- Keep tool functions simple. A tool function should be: call API → return result. If it needs state management, the API design is wrong for your use case.
- Separate sync and async workflows. If you need both (data retrieval + video generation), keep them in separate agent tools with different timeout/retry strategies.
How SandBase applies this
The data endpoints described in Normalizing Hundreds of Social APIs into One Agent Contract are called the same way, on POST /v1/api/<model>. As of 2026-10-01 the public catalog shows Douyin 262, TikTok 145, Weibo 52, and Xiaohongshu 34 endpoints (browse the catalog). The catalog cards below show two of those counts.

Caption: The SandBase API catalog grid shows Douyin at 262 endpoints and Weibo at 52, matching the counts above; captured 2026-10-02.
This article only verified execution_mode for the endpoint tested here, so for any other endpoint check its reference. We don’t publish fleet-wide latency or success-rate figures, so the only numbers in this article are the 14-call sample above.
If an endpoint like this were async, every fast call would carry overhead it doesn’t need: task submission, task storage, polling or webhook delivery, task cleanup. That overhead would exist because of the API design, not because the operation requires it.
To try it:
- Get a SandBase API key (authentication docs)
- Open the
douyin/app-v3/user-profileAPI reference - Browse the platform API catalog
Objections addressed
“What about webhooks? They’re more efficient than polling.”
True, but webhooks require:
- Public endpoint on the client side
- Webhook verification and security
- Retry logic for failed deliveries
- State reconciliation when webhooks are lost
For an agent running on a developer’s laptop or in a serverless function, none of this infrastructure exists. Sync removes the need entirely.
“What if the upstream source is slow?”
That’s mostly the API provider’s problem, not the agent developer’s: caching and connection reuse help keep latency steady. If an upstream is consistently too slow for one request, that operation should be async. Most data retrieval operations aren’t.
“Sync limits throughput.”
Only if you make sequential calls. With concurrent requests, throughput is bounded by the rate limit on your key and by upstream latency, not by the request/response protocol.
Related Reading
- Normalizing Hundreds of Social APIs into One Agent Contract
- Social Data API vs Web Scraping for Agents (2026)
- Per-Call vs Token Pricing: Which Works for Agents
- AI Agent Infrastructure Stack 2026
- Best Web Search API for AI Agents, tested monthly
- SandBase Product Update: Sharpening the Agent Infrastructure Layer
Conclusion
Sync-only for data APIs is not a compromise. It’s the architecture that matches how AI agents consume data in a blocking tool loop: request, receive, reason, repeat. Every async pattern you add to a data API is a complexity tax on every agent developer who integrates with it, and that complexity mostly serves the provider’s infrastructure, not the developer.
The principle we use: if the data exists and can usually be fetched within one request’s timeout, return it synchronously. Save async for operations where the result genuinely doesn’t exist yet and must be created.