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.

Cover image for Why Sync-Only is the Right Default for Data APIs

Key takeaway (TL;DR) — The SandBase data endpoint tested here, douyin/app-v3/user-profile, declares execution_mode: sync in 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’s execution_mode and 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:

Text
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 result

From 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:

Python
# 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.
Python
# 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 status

The 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:

Shell
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):

JSON
{
  "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-profileDouyin Open Platform
Data scopePublic, read-only profile data of public accountsData of accounts that have authorized your app
AuthenticationOne SandBase API key (Bearer)Developer registration and an app, plus user authorization for account data
ActionsNone; read-onlyAccount-linked operations for authorized accounts, per the platform’s docs
RelationshipSandBase is not an official Douyin partnerDouyin’s own developer platform
Use whenReading a public profile, as in the test aboveWorking with accounts you own or that authorize your app

The sync contract

The endpoint tested here follows a simple shape:

Text
Request             → Server fetches data → Response
POST /v1/api/<model>                      → JSON envelope or error

What’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 completed with 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 id is 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.

SandBase model page for douyin/app-v3/user-profile showing Free base price during API Free Week, sync execution, api model type and 1 input field

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.

SandBase endpoint reference for douyin/app-v3/user-profile showing the POST route, required sec_user_id field and status allowed values pending, running, completed, failed, timeout

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:

Python
# 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?
                pass

Sync APIs need none of this:

Python
# 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:

Python
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):

JSON
{"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:

Python
# 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):

Text
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 reasoning

When an async tool fails, the trace fragments (also illustrative):

Text
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):

Python
# 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):

Python
# 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 explodes

The 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:

  1. API design (sync vs async): whether the server returns results immediately or via a task system
  2. Client concurrency: whether the client makes requests in parallel

You can have sync APIs with parallel client calls:

Python
# 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 sum

Each 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-59b0bd42ba2e and 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; concurrent 506796c0-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
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

Text
"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

Text
"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:

  1. Default to sync. Our rule of thumb: if the operation usually completes within a few seconds, make it sync.
  2. Be clear about timing. Tell clients what to expect per operation so they can pick timeouts.
  3. Use HTTP semantics correctly. 200 for success, 4xx for client errors, 5xx for server errors, 429 for rate limits with a Retry-After header.
  4. Return complete results. Don’t return partial data with a “fetch more” pattern for simple queries.
  5. 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:

  1. Prefer sync APIs. They’re simpler to integrate, debug, and maintain.
  2. Use client-side concurrency for parallelism. asyncio.gather() gives you parallel execution with sync APIs.
  3. Set timeouts from measurements. Measure your endpoints, set the timeout above the normal range, and retry on timeout. A stateless call makes retry cheap.
  4. 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.
  5. 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.

SandBase API catalog grid showing Douyin with 262 endpoints, Instagram with 81 and Weibo with 52

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:

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.

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.