Normalizing Hundreds of Social APIs into One Agent Contract
How SandBase maps heterogeneous Douyin, TikTok, Weibo and Xiaohongshu upstream APIs onto one POST /v1/api contract: naming, body wrapping, fail-closed checks.

Key takeaway (TL;DR) — Hundreds of social media API operations from four platforms, each with different HTTP methods, parameter positions, body shapes, and naming conventions upstream. One adapter layer maps them onto a single public contract:
POST https://api.sandbase.ai/v1/api/<vendor>/<path>with a JSON object body. This article explains the four hardest engineering decisions: naming canonicalization, root body wrapping, parameter position mapping, and fail-closed validation.
As of 2026-10-01 the public SandBase catalog shows 493 endpoints across the four platforms this article covers: Douyin 262, TikTok 145, Weibo 52, and Xiaohongshu 34 (counts are what each vendor catalog page shows on that date; other documentation surfaces may show different aggregates). Upstream, some of these are GET with query parameters, some are POST with JSON objects, and a few are POST with a root-level array or a root-level string. Four platforms, four naming conventions.
The Douyin catalog page is where the per-vendor count comes from; the capture below, taken a day later, shows the same number.

Caption: The Douyin vendor catalog shows “262 SHOWN”, matching the count cited above; captured 2026-10-02.
Your agent should not care about any of this.
The agent calls POST /v1/api/douyin/search/challenge-search-v1 with {"keyword": "AI创作", "cursor": 0} and gets back JSON. Whether that becomes a GET request with ?keyword=AI创作 or a POST with {"keyword": "AI创作"} upstream is the adapter’s problem.
A note on scope: what you can verify from outside is the request/response behavior, and every public call in this article was run on 2026-10-01 (UTC) against POST /v1/api/<model>. Anything upstream of that contract (raw paths, metadata fields, importer rules, query/body/path translation) is shown as a simplified, hypothetical illustration of the problem an adapter like this has to solve. Those examples are labeled “illustrative”; they are not SandBase’s actual upstream routes or a published description of its internals, and they are never the way you call SandBase.
The problem space
The kinds of heterogeneity an adapter like this has to absorb (the naming examples are illustrative):
| Dimension | Variants an adapter has to handle |
|---|---|
| HTTP methods | GET and POST |
| Parameter positions | query string, path, JSON body, mixed |
| Body shapes | JSON object, root array, root optional string, none (GET) |
| Naming patterns (illustrative) | fetch_video_search_result, get_unique_id, handler_hot_search, multi_video_v2 |
| Auth patterns | Different header conventions per upstream |
| Response shapes | JSON (object, array, or bare string) |
Most operations are plain object bodies; root arrays and root strings are a small minority. We don’t publish an estate-wide method or body-shape breakdown, so this article sticks to the categories and to individually tested operations.
The goal: an agent sees one flat namespace of operation names, sends one JSON object body, and gets one JSON envelope back. Everything else is hidden.
Decision 1: Naming canonicalization
The problem
Upstream operation names can look like this (illustrative examples):
fetch_video_search_resultget_unique_idhandler_hot_search_listfetch_multi_video_v2kol_xingtu_index_v1
They have inconsistent prefixes (fetch_, get_, handler_), redundant platform tokens (douyin_ inside a path that already says douyin/), and mixed version indicators.
The rule
The public model names follow this shape:
canonical_name = {platform}/{normalized-channel-version}/{semantic-action}
An illustrative set of normalization steps that produces names of this shape from a hypothetical upstream path:
- Strip the outer path prefix (e.g. a
/api/v1/prefix → removed) - Normalize channel+version —
douyin/app/v3/→app-v3,weibo/web/v2/→web-v2 - Strip action prefixes — Remove leading
fetch_,get_,handler_from the action segment - Strip redundant platform tokens — If the action repeats the platform name, remove it
- Convert to kebab-case —
multi_video_v2→multi-video-v2
Before → After
The left column is a hypothetical upstream path shape, made up to show the transformation; it is not SandBase’s actual upstream route. The right column is the real public model name you put in /v1/api/<model>.
| Hypothetical upstream path shape (illustrative) | Public model name |
|---|---|
/api/v1/douyin/search/fetch_video_search_v1 | douyin/search/video-search-v1 |
/api/v1/tiktok/web/get_unique_id | tiktok/web/unique-id |
/api/v1/weibo/web/v2/fetch_hot_search | weibo/web-v2/hot-search |
/api/v1/douyin/app/v3/fetch_multi_video_v2 | douyin/app-v3/multi-video-v2 |
/api/v1/xiaohongshu/web_v3/fetch_note_detail | xiaohongshu/web-v3/note-detail |
Why not just use the original names?
Three reasons:
- Agent usability:
douyin/search/video-search-v1tells you platform/channel/action at a glance.fetch_video_search_v1doesn’t tell you which platform or channel. - Collision avoidance: Several platforms have a
hot_search. The platform prefix keeps names unique. - Stable identity: In a scheme like this, a hypothetical upstream rename from
fetch_hot_searchtoget_hot_searchwould still normalize tohot-search, because the mapping follows path structure, not operation IDs.
A general design principle for this kind of importer: fail closed on names. If two operations would canonicalize to the same name, treat it as an error that needs a human decision instead of silently deduplicating.
Decision 2: Root body wrapping
The problem
The public contract expects a JSON object body. But some upstream operations expect a bare root array, for example ["7687107502068026650", "7682040509572983400"]. You can’t send a bare array when the contract requires an object.
The solution: body field wrapping
The operation’s public schema declares a single body field (see the endpoint references below). Two examples, both tested on 2026-10-01 (UTC):
# Root array of Douyin video IDs
# Reference: https://www.sandbase.ai/docs/model-api-reference/platform-apis/douyin/app-v3/multi-video-v2
curl -s -X POST https://api.sandbase.ai/v1/api/douyin/app-v3/multi-video-v2 \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body": ["7687107502068026650", "7682040509572983400"]}'
# Root array of profile URLs (TikTok's official account)
# Reference: https://www.sandbase.ai/docs/model-api-reference/platform-apis/tiktok/web/all-sec-user-id
curl -s -X POST https://api.sandbase.ai/v1/api/tiktok/web/all-sec-user-id \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body": ["https://www.tiktok.com/@tiktok"]}'
The multi-video-v2 reference page is where the single body field is declared, which is the wrapper the first call uses.

Caption: The douyin/app-v3/multi-video-v2 reference lists POST /v1/api/douyin/app-v3/multi-video-v2 with one required array<string> body field; captured 2026-10-02.
Conceptually, an adapter with a root-array upstream would unwrap body and forward the bare array (["7687107502068026650", "7682040509572983400"]). That forwarding step is illustrative; from outside you only see the body wrapper and the result.
The first call completed with an aweme_details list holding both videos (run id adaa01f7-cbd6-43c5-a4c4-6602fd10ad5e). The second returned a bare array, which is a good reminder that outputs[0].data is not always an object. Full response, unedited:
{
"id": "6d90c30a-360d-48b8-b55d-fce62ee63c64",
"status": "completed",
"model": "tiktok/web/all-sec-user-id",
"outputs": [{"data": ["MS4wLjABAAAAv7iSuuXDJGDvJkmH_vz1qkDZYo1apxgzaxdBSeIuPiM"]}]
}
The body type is enforced. Sending a string to multi-video-v2 came back as HTTP 400 parameter validation failed: /body: expected array, but got string.
Marking which operations need wrapping (illustrative)
An adapter needs some per-operation flag that says whether body is wrapped. A hypothetical metadata shape (not SandBase’s actual registry format):
{
"name": "douyin/app-v3/multi-video-v2",
"root_body_type": "array",
"body_required": true
}
Possible values in this sketch:
nullor absent — standard object body, no wrapping"array"—bodyfield must be an array, unwrapped before sending"string"—bodyfield must be a string (or absent if optional)
Authentication-dependent capabilities
The root-string case exists, but the only Douyin example we’d use to show it, douyin/web/query-user, takes a user ttwid cookie value as its body. That makes it session-dependent, not a public-data call, so it’s outside the scope of this article and of the public read-only promise below. If you need it, read its endpoint reference and treat the cookie like any other credential.
Decision 3: Parameter position mapping
The problem
An upstream GET operation would take parameters in a query string. But the agent always sends a JSON object body. How do you map JSON fields to query parameters?
# Agent sends (public contract)
# Reference: https://www.sandbase.ai/docs/model-api-reference/platform-apis/tiktok/web/unique-id
curl -s -X POST https://api.sandbase.ai/v1/api/tiktok/web/unique-id \
-H "Authorization: Bearer $SANDBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.tiktok.com/@tiktok"}'
# Illustrative only, not a SandBase route or its actual upstream:
# an upstream path shaped like
# GET https://upstream.example/tiktok/unique_id?url=https%3A%2F%2Fwww.tiktok.com%2F%40tiktok
The mapping rules (illustrative)
An adapter has to know each parameter’s location (for example from an upstream OpenAPI spec). One way to map them:
| Location | Agent sends | Adapter would produce upstream |
|---|---|---|
query | JSON field | URL query parameter |
path | JSON field | Substituted into URL path |
body (object) | JSON field | Passed in POST body |
body (root array/string) | body field | Unwrapped as POST body |
The unique-id call above completed on 2026-10-01 with outputs[0].data equal to the bare string "tiktok" (run id d35785a0-76fe-4d8e-a66b-89475f4da0b2). Note that the endpoint reference currently types outputs[0].data as object | array; the string result is what we observed in our 2026-10-01 call, not a documented guarantee, so a client should accept either shape.
For path parameters, an adapter needs safe substitution. The path below is a hypothetical upstream shape, not a specific SandBase operation:
// Agent sends to POST /v1/api/<platform>/<channel>/user-posts:
{"unique_id": "example_user", "count": 20}
// Illustrative upstream shape:
// GET https://upstream.example/user/example_user/posts?count=20
// ^^^^^^^^^^^^^^^^^^^^^^^ path substitution
// ^^^^^^^^ remaining params as query
Zero-value handling
A critical subtlety: false, 0, and empty arrays are valid parameter values. The adapter must not confuse “field is absent” with “field is present but falsy.”
// Agent sends to POST /v1/api/douyin/web/one-video:
{"aweme_id": "7687107502068026650", "need_anchor_info": false}
// Illustrative: if the upstream were a GET route, the adapter should produce
// GET ...?aweme_id=7687107502068026650&need_anchor_info=false
// NOT: GET ...?aweme_id=7687107502068026650 (dropping false would change semantics)
That call completed with an aweme_detail payload on 2026-10-01 (run id 043512bf-a0e9-4da0-a88a-25fbc3550cef). The design principle: only truly absent fields are omitted; null, false, 0, "", and [] are values and should be transmitted. The upstream translation itself is not observable from outside.
Decision 4: Fail-closed validation
The philosophy
When in doubt, reject. Don’t guess at missing required parameters, don’t coerce wrong types, don’t try to be clever.
What gets rejected (before hitting upstream)
The first two rows are publicly observable (see the examples below). The rest are general design principles for an adapter of this kind, not behavior you can trigger or verify through the public schema.
| Condition | Behavior | Why |
|---|---|---|
| Wrong parameter type | Reject with error | A string where an integer is expected is a bug, not a hint |
| Required field missing | Reject with error | Upstream would fail anyway; fail fast with a clear message |
Path parameter still has {placeholder} | Reject | Means a required path param was not provided |
| Agent-supplied headers or cookies as parameters | Not mapped | Agents can’t inject upstream auth headers |
| Unsupported body content type | Reject | Only JSON is supported |
| Field location conflicts | Reject | Same field in query AND body is ambiguous |
What passes through (by design)
| Condition | Behavior | Why |
|---|---|---|
| Optional field omitted | Pass (don’t inject defaults) | Agent explicitly chose not to provide it |
| Boolean false / numeric 0 | Pass as-is | Valid values, not absence |
Example: rejected calls
// POST /v1/api/tiktok/web/unique-id with body {}
// Response (HTTP 400, no run id):
{"error": "parameter validation failed: (root): missing properties: 'url'"}
// POST /v1/api/douyin/search/challenge-search-v1 with body {"keyword": "AI", "cursor": "abc"}
// Response (HTTP 400, no run id):
{"error": "parameter validation failed: /cursor: expected integer, but got string"}
Both responses are from calls I made on 2026-10-01. The error names the field and the expected shape, so the agent can self-correct. One caveat from the same session: an extra field the schema doesn’t define ("unknown_field": "test" on challenge-search-v1) was not rejected; the call completed (run id a7c6bf3c-4771-4a1e-a886-a8164f328ac5). Don’t rely on unknown-field rejection to catch typos in optional parameter names.
The result
From the agent’s perspective, every operation is called the same way: the model name goes in the URL, only that endpoint’s parameters go in the body, and the result is read from outputs[0].data. Reference for the endpoint used here: douyin/search/challenge-search-v1.
import os
import requests
def call_sandbase(model: str, params: dict):
resp = requests.post(
f"https://api.sandbase.ai/v1/api/{model}",
headers={"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}"},
json=params,
timeout=60,
)
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") # object, array, or string depending on the endpoint
data = call_sandbase("douyin/search/challenge-search-v1", {"keyword": "AI创作", "cursor": 0})
for item in data.get("challenge_list", [])[:3]:
info = item.get("challenge_info", {})
print(info.get("cha_name"), info.get("user_count"))
The reference page for this endpoint types cursor as an integer, which is why the "cursor": "abc" call in Decision 4 was rejected.

Caption: The douyin/search/challenge-search-v1 reference shows the POST route and its optional parameters, with cursor typed Optional<integer>; captured 2026-10-02.
Tested on 2026-10-01 (UTC). Public resource: the generic hashtag keyword AI创作. Request: POST https://api.sandbase.ai/v1/api/douyin/search/challenge-search-v1 with body {"keyword": "AI创作", "cursor": 0}. Trimmed real response (run id 38446d74-a295-4ff6-9df3-c0e6fb122511; most fields and 17 of 20 list items removed):
{
"status": "completed",
"model": "douyin/search/challenge-search-v1",
"outputs": [{
"data": {
"cursor": 20,
"has_more": 1,
"challenge_list": [
{"challenge_info": {"cha_name": "ai创作", "cid": "1641397594586119", "user_count": 968792, "view_count": 10623376934}},
{"challenge_info": {"cha_name": "抖音ai创作", "cid": "1776715988162624", "user_count": 438375154, "view_count": 272831217402}},
{"challenge_info": {"cha_name": "ai创作浪潮", "cid": "7552088959354931251", "user_count": 58672, "view_count": 3915552067}}
]
}
}]
}
The envelope (id, status, model, outputs) is documented in the endpoint reference. The payload field names (challenge_list, challenge_info, cha_name, user_count, cursor, has_more) are observed-only, not a documented guarantee, so read them defensively with .get().
No HTTP method thinking. No parameter position thinking. No body shape thinking. One SandBase API key instead of per-upstream auth.
Scope: what this contract covers
These are public, read-only data calls. Each one needs a SandBase API key. SandBase is not an official partner of Douyin, TikTok, Weibo, or Xiaohongshu, and the contract does not cover private-account data, DMs, owner-only analytics, publishing, or account actions.
| Need | SandBase platform data APIs | Official platform APIs |
|---|---|---|
| Public-data research (search, public posts and profiles, public counters) | Covered: one POST /v1/api/<model> contract across the four platforms | Not a general public-data feed; scope depends on the program and app you’re approved for |
| Private or authenticated data, or account actions (DMs, owner analytics, publishing) | Not covered | The route for this, via the platform’s developer program and user authorization |
| Auth model | One SandBase API key as Authorization: Bearer | Platform developer registration and app credentials; account data needs user authorization (for example Weibo’s OAuth2.0 docs) |
Official developer sites (all loaded on 2026-10-01, UTC): Douyin Open Platform, TikTok for Developers, Weibo Open Platform, Xiaohongshu Open Platform.
Trade-offs we accepted
-
Strict validation means more initial errors: Agents hit missing-field and type errors during development. But each error is self-correcting (the error message names the field and the expected shape). We chose debuggability over permissiveness.
-
Naming canonicalization loses original identifiers: If you know the upstream
operationId, you can’t use it directly. But canonical names are more readable and stable across upstream renames. -
No streaming for the endpoints shown here: Each endpoint called in this article declares
execution_mode: syncin its reference and returned the complete result in one response in our tests. That keeps the contract simple but means you can’t get “first 10 results fast, rest later.” For any other endpoint, check its reference. -
No agent-controlled headers or cookies: The public contract is a JSON body of the endpoint’s declared parameters; there is no field for passing arbitrary upstream headers or cookies. Security wins over rare-case convenience. (The one cookie-valued body above is a declared parameter, which is exactly why it’s labeled authentication-dependent.)
FAQ
Why not just proxy the original API as-is?
Because an agent calling 10 different operations would need to understand 10 different conventions: which are GET, which are POST, where parameters go, how errors look. That cognitive load defeats the purpose of an agent platform. One contract = one code path = one error handler.
What happens when upstream adds new operations?
New operations show up as new model names in the catalog, following the same {platform}/{channel-version}/{action} shape. How they get imported is internal; the design principle from Decision 1 applies: a name collision should be treated as an error and resolved by a person, not silently deduplicated.
Can I call the original API directly if I want?
Not through SandBase. Data APIs are called through POST /v1/api/<model>. This is intentional: in our tests every endpoint shown here took the same Bearer-key auth and ran the same request validation against its published schema, which is what makes the operations composable inside agent workflows.
How do you handle upstream breaking changes?
The contract you can rely on is the endpoint’s published schema in its reference page. Validation runs against that schema, so a parameter shape that no longer matches it is rejected with a clear error (like the HTTP 400 examples above) instead of degrading silently.
Next steps
- Get a SandBase API key (authentication docs)
- Browse the platform API catalog
- Open the
douyin/search/challenge-search-v1API reference
For a platform-level overview, see Douyin Public Data API and Weibo & Xiaohongshu Data APIs Now on SandBase. For context on why agents need this kind of normalization, see our social media data API guide.