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.

Cover image for Normalizing Hundreds of Social APIs into One Agent Contract

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.

SandBase Douyin API catalog page showing 262 endpoints with the selected endpoint marked Available and Free

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

DimensionVariants an adapter has to handle
HTTP methodsGET and POST
Parameter positionsquery string, path, JSON body, mixed
Body shapesJSON 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 patternsDifferent header conventions per upstream
Response shapesJSON (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_result
  • get_unique_id
  • handler_hot_search_list
  • fetch_multi_video_v2
  • kol_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:

  1. Strip the outer path prefix (e.g. a /api/v1/ prefix → removed)
  2. Normalize channel+version — douyin/app/v3/ → app-v3, weibo/web/v2/ → web-v2
  3. Strip action prefixes — Remove leading fetch_, get_, handler_ from the action segment
  4. Strip redundant platform tokens — If the action repeats the platform name, remove it
  5. 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_v1douyin/search/video-search-v1
/api/v1/tiktok/web/get_unique_idtiktok/web/unique-id
/api/v1/weibo/web/v2/fetch_hot_searchweibo/web-v2/hot-search
/api/v1/douyin/app/v3/fetch_multi_video_v2douyin/app-v3/multi-video-v2
/api/v1/xiaohongshu/web_v3/fetch_note_detailxiaohongshu/web-v3/note-detail

Why not just use the original names?

Three reasons:

  1. Agent usability: douyin/search/video-search-v1 tells you platform/channel/action at a glance. fetch_video_search_v1 doesn’t tell you which platform or channel.
  2. Collision avoidance: Several platforms have a hot_search. The platform prefix keeps names unique.
  3. Stable identity: In a scheme like this, a hypothetical upstream rename from fetch_hot_search to get_hot_search would still normalize to hot-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.

SandBase endpoint reference for douyin/app-v3/multi-video-v2 showing POST route and a required array<string> body field

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:

  • null or absent — standard object body, no wrapping
  • "array" — body field must be an array, unwrapped before sending
  • "string" — body field 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:

LocationAgent sendsAdapter would produce upstream
queryJSON fieldURL query parameter
pathJSON fieldSubstituted into URL path
body (object)JSON fieldPassed in POST body
body (root array/string)body fieldUnwrapped 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.

ConditionBehaviorWhy
Wrong parameter typeReject with errorA string where an integer is expected is a bug, not a hint
Required field missingReject with errorUpstream would fail anyway; fail fast with a clear message
Path parameter still has {placeholder}RejectMeans a required path param was not provided
Agent-supplied headers or cookies as parametersNot mappedAgents can’t inject upstream auth headers
Unsupported body content typeRejectOnly JSON is supported
Field location conflictsRejectSame field in query AND body is ambiguous

What passes through (by design)

ConditionBehaviorWhy
Optional field omittedPass (don’t inject defaults)Agent explicitly chose not to provide it
Boolean false / numeric 0Pass as-isValid 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.

SandBase endpoint reference for douyin/search/challenge-search-v1 showing POST route and optional parameters including integer cursor and string keyword

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.

NeedSandBase platform data APIsOfficial platform APIs
Public-data research (search, public posts and profiles, public counters)Covered: one POST /v1/api/<model> contract across the four platformsNot 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 coveredThe route for this, via the platform’s developer program and user authorization
Auth modelOne SandBase API key as Authorization: BearerPlatform 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

  1. 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.

  2. 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.

  3. No streaming for the endpoints shown here: Each endpoint called in this article declares execution_mode: sync in 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.

  4. 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

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.