Getting started
Data from the TikTok Shop product catalog in JSON, charged by credit. This is the reference for version 1.
Base URL
https://developers.sendyx.com/v1
The complete contract in OpenAPI format is at /openapi.en.json.
Authentication
Create a key in the Sendyx console and send it in the header Authorization: Bearer sdx_live_.... Store the key in an environment variable and never put it in code, in a repository, or in a browser. It is shown only once.
Test environment (sandbox)
To build and test your integration without spending credits, create a test key in the console and send Authorization: Bearer sdx_test_.... The same /v1 routes respond in the same format, from a fixed set of fictional data: the ids and numbers are always the same, so you can write repeatable tests.
The key decides the environment. A test key works on https://developers.sendyx.com/v1 and on https://sandbox.sendyx.com/v1; the test address accepts test keys only. A production key on the test address, or a test key outside /v1, gets the same 401 invalid_credentials as any invalid key. Each account can have up to 5 active test keys, in addition to the 5 production keys.
With a test key nothing is charged or required: no balance, no acceptance of the terms of use, no mature account. The API does not query the data source or the production database. Every response carries X-Sendyx-Environment: sandbox, x-credits-cost: 0.00, and x-credits-remaining: 1000.00 (fictional balance). The body has the same envelope as production.
Test data
The catalog has 24 fictional products, with the same filters, sort orders, and pagination as GET /v1/products, and Brecha and categories come from it. Names, shops, videos, lives, and creators are made up; images point to exemplo.invalid. Creator contacts (GET /v1/creators/{id}/contacts) are fictional (@exemplo.invalid) and the route responds in test even when it is unavailable in production. The lists of videos, creators, shops, lives, and categories have few items.
Id rule. Every id a test list returns opens the detail, the analysis, the series, and the nested lists: you can go from the list to the detail with the id you received. In practice, any 19-digit id that starts with 999 (the fictional test ids) is accepted and always returns the same body, with the id you asked for. The ones in the table below are examples. Category also opens the taxonomy id that the category ranking returns. Any other id responds 404 on details and an empty list on per-entity lists, like the real route.
| Route | Test id |
|---|---|
| GET /v1/products/{id}, /analysis, /videos, /creators | 9990969421641471500 |
| GET /v1/products/{id}/trend | 9990866258189766001 |
| GET /v1/videos/{id} | 9990766912945219402 |
| GET /v1/videos/{id}/products | 9990766912945219402 |
| GET /v1/creators/{id} and /contacts | 9993000000000000001 |
| GET /v1/creators/{id}/products | 9993000000000000001 |
| GET /v1/shops/{id} | 9990820431382416103 |
| GET /v1/shops/{id}/products, /videos, /creators | 9990820431382416103 |
| GET /v1/lives/{id} | 9990114260194168264 |
| GET /v1/lives/{id}/products | 9990114260194168264 |
| GET /v1/categories/{id} | 9990206017089333354 |
Example of an item from GET /v1/products in test:
{
"id": "9990969421641471500",
"name": "Sérum Facial de Exemplo 01",
"coverUrl": "https://exemplo.invalid/sandbox/produto-01.jpg",
"productUrl": "https://exemplo.invalid/sandbox/produto/9990969421641471500",
"categoryL1": {
"id": "601450",
"name": "Beleza"
},
"categoryL2": {
"id": "990101",
"name": "Cuidados com a pele (exemplo)"
},
"categoryL3": {
"id": "990111",
"name": "Séruns (exemplo)"
},
"shopName": "Loja Exemplo 01",
"reviewCount": 120,
"minPrice": 3200,
"maxPrice": 4800,
"commission": 0.08,
"revenue": 106375,
"sales": 2659,
"revenue30d": 462500,
"sales30d": 11563,
"sales7d": 2659,
"revenuePerCreator": 13602.94,
"acceleration": 0.89,
"trend": [
308,
578,
463,
347,
617,
501,
385,
270,
540,
424,
308,
578,
463,
347
],
"state": "exploding",
"launchDate": "2026-02-02",
"rank": 1,
"prevRank": 1,
"channel": {
"videoRevenue": 231250,
"liveRevenue": 138750,
"creatorsMeasured": 34
}
}
Test limits
Up to 1000 calls per day per account, across all test keys. The day rolls over at midnight in Brasilia (Sao Paulo time). Above that the response is 429 rate_limited with Retry-After until the rollover. There is also a limit of 60 calls per minute per key. The console shows the day's usage and recent calls.
Simulate errors
Send the x-sandbox-scenario header with one of the values below and the API returns exactly the real error, spending nothing. A production key ignores this header. An unknown value is 400.
| Value | Status | error | When |
|---|---|---|---|
insufficient_credits | 402 | insufficient_credits | Insufficient balance. The body carries required and available. |
rate_limited | 429 | rate_limited | Request limit. Comes with Retry-After. |
not_found | 404 | not_found | Resource not found. |
upstream_unavailable | 503 | upstream_unavailable | Data service unavailable. Comes with Retry-After. |
Body of the 402 forced by insufficient_credits:
{
"error": "insufficient_credits",
"message": "Insufficient credits.",
"required": 0.1,
"available": 0,
"requestId": "3f6c1d2e-8a4b-4c7e-9b1a-5d2e7f9a0c11"
}
Example test call (the test key lives in SENDYX_API_KEY):
curl -G 'https://sandbox.sendyx.com/v1/products' \
-H "Authorization: Bearer $SENDYX_API_KEY" \
--data-urlencode 'sortBy=revenue'
const params = new URLSearchParams({
"sortBy": "revenue",
});
const res = await fetch(`https://sandbox.sendyx.com/v1/products?${params}`, {
method: "GET",
headers: { Authorization: `Bearer ${process.env.SENDYX_API_KEY}` },
});
if (!res.ok) {
const error = await res.json();
throw new Error(`${res.status} ${error.error}: ${error.message}`);
}
console.log(
"cost:", res.headers.get("x-credits-cost"),
"balance:", res.headers.get("x-credits-remaining"),
);
const body = await res.json();
console.log(body);
import os
import requests
response = requests.get(
"https://sandbox.sendyx.com/v1/products",
headers={"Authorization": f"Bearer {os.environ['SENDYX_API_KEY']}"},
params={
"sortBy": "revenue",
},
)
response.raise_for_status()
print(
"cost:", response.headers.get("x-credits-cost"),
"balance:", response.headers.get("x-credits-remaining"),
)
print(response.json())
Credits
Each list or detail request for any entity consumes 0.10 credits. So 1 credit equals 10 requests. The category tree (GET /v1/categories) and the balance (GET /v1/account/usage) are free; the other category routes, such as GET /v1/categories/ranking, consume the same as the others. The cost of each route appears at the top of its page.
A creator's contacts are an extra lookup: 0.02 credits when you already looked up the creator in the last 24 hours, or 0.12 credits (0.10 for the creator plus 0.02 for the contacts) when the creator has not been looked up yet. Requesting the same creator's contacts again costs 0.02 per request. You must have accepted the API terms of use in the console.
If the data service is unavailable, the response is 503 and no credits are charged.
You must buy credits to use the API. A new account starts with a zero balance, with no courtesy credits, and paid routes respond 402 until the first purchase, made in the Sendyx console. Purchased credits do not expire.
The cost is debited before the response is served. A request with an invalid parameter (400) charges nothing, and if it ends in an error after the charge the credit is refunded immediately. Without enough balance the response is 402.
Response headers
| Header | Meaning |
|---|---|
| x-credits-cost | Credits charged for this response, always with two decimal places (for example 0.10, 0.02, or 0.12). It comes on 200 responses, on 402 (with 0.00), and on errors that happen after the charge and were refunded, such as 404 and 503 (with 0.00). It does not come on 401, on 429, or on parameter 400s, which are rejected before the charge. |
| x-credits-remaining | Remaining balance in credits, always with two decimal places (for example 4.90). On the same responses as the header above; on 402 it is the current balance. |
| x-request-id | Request identifier. Include it when contacting support. |
| Retry-After | Present on 429 and 503: seconds until you can try again. |
The /v1 routes accept GET only. A HEAD gets 405 with Allow: GET, with no lookup and no charge.
In headers, credits are text with two decimal places. In the JSON body, numbers are numeric and JSON does not preserve trailing zeros: 0.10 in the header becomes 0.1 in the body.
Markets
The region parameter picks the market and the default is BR. Two routes exist for Brazil only: asking for region=US on them is 400 and nothing is charged. All the others accept BR and US.
| Market | Routes | Currency |
|---|---|---|
| BR | BR only: category tree (GET /v1/categories) and creator contacts (GET /v1/creators/{id}/contacts). | BRL |
| BR and US | Catalog (GET /v1/products), product detail (GET /v1/products/{id}), and Brecha (GET /v1/brecha); overall product ranking (GET /v1/products/ranking) and the other gateway routes: rankings and details of videos, creators, categories, shops, and lives, products of an entity, and videos, creators, analysis, and series of a product. | BRL in BR, USD in the US |
US data comes as the source delivers it: names, titles, and categories in English and values in USD, with no translation or currency conversion. The meta.currency field tells the currency of each response.
On GET /v1/products, GET /v1/products/{id}, and GET /v1/brecha, while the US catalog is not yet visible, the US response comes back empty (404 on detail) and nothing is charged (x-credits-cost: 0.00). In US Brecha, the minRevenue30d floor (default 10000) applies in dollars.
Data age
The video, creator, category (ranking and detail), shop, live, and product analysis routes carry meta.fetchedAt, the moment (ISO 8601) the data was obtained. The product routes and the category tree carry meta.dataUpTo, the most recent day of data, in yyyy-MM-dd format; the source is one day behind. If the data service is unavailable, the response is 503 and nothing is charged.
Error format
Every error has the format { "error", "message", "requestId" }. The error field is stable and can be used in code.
The message field is human-readable text and follows the Accept-Language header: English if the header prefers en, Portuguese otherwise. Integrate by error, never by the sentence.
| Code | error | When |
|---|---|---|
| 401 | missing_credentials | Authorization header missing. |
| 401 | invalid_credentials | Invalid or revoked key. |
| 402 | insufficient_credits | Balance lower than the cost. Carries required and available, in credits. |
| 429 | rate_limited | Request limit exceeded. See the Retry-After header. |
| 400 | validation_error | Invalid, unknown, or out-of-range parameter. |
| 404 | not_found | Resource does not exist. |
| 500 | internal_error | Our failure. The credit is refunded. |
| 503 | upstream_disabled | The data service is turned off. Nothing is charged. |
| 503 | upstream_budget_exceeded | The data service query limit is used up for now. Try later. Nothing is charged. |
| 503 | upstream_unavailable | The data service failed, is busy, or the live lookup queue is full. Try again after the Retry-After time. Nothing is charged. |
| 429 | contacts_daily_limit | Daily limit of contact lookups for the account reached (sum of all keys). See Retry-After. Nothing is charged. |
| 429 | miss_quota_exceeded | Quota of new lookups in the last 60 minutes reached, per key and per account (cached lookups do not count). The window is rolling: slots come back gradually, see Retry-After. Nothing is charged. |
| 429 | daily_spend_limit_exceeded | Daily spending limit of the account reached (sum of all keys; resets at midnight UTC). See Retry-After. Nothing is charged. |
| 403 | terms_not_accepted | The API terms of use have not been accepted by the key owner. Accept them in the console. Nothing is charged. |
Rate limits
Up to 60 requests per minute and 10000 per day, per key. There is also a limit of 300 requests per minute per IP address, which applies even to attempts with a wrong key. When exceeded, the response is 429 with Retry-After.
Pagination and data freshness
Lists have a fixed page size of 10, with page from 1 to 50. The response carries pagination with page, pageSize, total, and totalPages. total is the real result count, and totalPages never exceeds 50, which is the last page the API delivers (asking for page 51 is 400). To reach what lies beyond the first 500, narrow down by filter and sort order.
Every data response carries meta.dataUpTo, the most recent day of data (the source is one day behind), and meta.currency, the currency of the values.
Your first call
Check your balance. It is free:
curl 'https://developers.sendyx.com/v1/account/usage' \
-H "Authorization: Bearer $SENDYX_API_KEY"
const res = await fetch("https://developers.sendyx.com/v1/account/usage", {
method: "GET",
headers: { Authorization: `Bearer ${process.env.SENDYX_API_KEY}` },
});
if (!res.ok) {
const error = await res.json();
throw new Error(`${res.status} ${error.error}: ${error.message}`);
}
console.log(
"cost:", res.headers.get("x-credits-cost"),
"balance:", res.headers.get("x-credits-remaining"),
);
const body = await res.json();
console.log(body);
import os
import requests
response = requests.get(
"https://developers.sendyx.com/v1/account/usage",
headers={"Authorization": f"Bearer {os.environ['SENDYX_API_KEY']}"},
)
response.raise_for_status()
print(
"cost:", response.headers.get("x-credits-cost"),
"balance:", response.headers.get("x-credits-remaining"),
)
print(response.json())