# sangtao.ai API Reference (LLM-friendly) # Auto-generated from API docs components # Docs: https://docs.sangtao.ai/vi — keep in sync when docs change # URL: https://docs.sangtao.ai/llms.txt (also https://sangtao.ai/llms.txt) --- ## Table of Contents 1. Introduction 2. Authentication 3. API Conventions (errors, request id, rate limits, pagination, idempotency, ids) 4. Error Codes 5. Account (account, balance, credit transactions) 6. Models 7. GPT Image 8. Image Upload 9. Job Polling 10. List Jobs (and Delete a Job) 11. Publishing 12. User Assets 13. Characters 14. Assets & References (Voices, Background Tracks) 15. Webhooks (events, signature, retries, managing endpoints) --- # 1. Introduction ## Overview The sangtao.ai API lets you create AI images (GPT Image), upload reference images, follow and manage jobs, publish to social networks through connected accounts, manage your media assets and characters, and list voices and background tracks. All endpoints follow REST conventions and return JSON responses. **Base URL**: `https://api.sangtao.ai/v2` **/v2 is stable.** Published field names and response shapes stay as they are; new fields may be added. ## Quick Start — Hello World Try your first API call — check your credit balance (zero risk, read-only): ```bash curl https://api.sangtao.ai/v2/account/balance \ -H "X-Api-Key: YOUR_API_KEY" ``` Response: ```json { "success": true, "data": { "freeCredit": 50, "purchasedCredit": 100, "lockedCredit": 0, "availableCredit": 150 } } ``` > **Tip**: Get your API key from Settings -> API Key in your sangtao.ai dashboard. ## What You Can Build - **GPT Image** (POST /images/generations): Describe an image, optionally with reference images — get back a jobId and poll GET /images/generations/{jobId} for the result. - **Image Upload** (POST /images/presign): Get a temporary URL for a reference image to pass as referenceImages. - **Jobs** (GET /jobs, GET /jobs/{jobId}, DELETE /jobs/{jobId}): List your jobs, follow one until it finishes, remove one. - **Publishing** (/publishing/...): Post to social networks through your connected accounts. - **User Assets** (/user-assets/...): Upload and manage your own media files. - **Characters** (/characters/...): Reusable character profiles for videos. - **Assets & References** (GET /assets/voices, GET /assets/background-tracks): The voices and background tracks you can pick from. ## Response Format All endpoints return a consistent JSON envelope: ```json // Success { "success": true, "data": { ... }, "error": null } // Error { "success": false, "data": null, "error": "Error message" } ``` ## Errors Every error answers in one JSON shape with an errorCode to branch on — the statuses and codes are in section 4 (Error Codes), the error shape in section 3 (API Conventions). ## Rate Limits | Limit | Value | |--------------------------------------|-----------------------------------------| | Requests per minute, per API key | 6,000 | | Reference image uploads per minute | 30 | | Jobs running at once, per account | 20 by default — yours is in GET /account (limits) | | Polling interval (recommended) | 3-5 seconds | The rate-limit headers and what a 429 looks like: section 3 (API Conventions). --- # 2. Authentication ## API Key Authentication The recommended way to authenticate. Go to Settings -> API Key to generate your key. Include it in every request via the X-Api-Key header: ```bash curl -X POST https://api.sangtao.ai/v2/images/generations \ -H "X-Api-Key: your-api-key-here" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-image-latest", "prompt": "A vintage travel poster of Ha Long Bay at sunset", "aspectRatio": "9:16", "resolution": "2k" }' ``` > **Warning**: Your API key is a secret. Do not expose it in client-side code (browsers, mobile apps). Only use it in server-side applications. ## Credits System Every job consumes credits. Check your balance before submitting: ``` GET /api/v2/account/balance // Response { "freeCredit": 100, // Monthly plan credits "purchasedCredit": 50, // Purchased credits "lockedCredit": 10, // Held for running jobs "availableCredit": 140 // free + purchased - locked } ``` | Phase | Description | |---------|-----------------------------------------------------------------| | Lock | Credits are estimated and locked when you submit a job | | Process | Job runs. Actual cost may be lower than estimate | | Settle | When complete, excess credits are automatically refunded | > **Note**: Deduction order: free credits are used first, then purchased credits. ## Job Polling Overview All creation endpoints return a jobId. Poll the job status endpoint to track progress: **Flow**: pending -> processing -> complete | error ```javascript // 1. Submit job const res = await fetch('https://api.sangtao.ai/v2/images/generations', { method: 'POST', headers: { 'X-Api-Key': 'your-api-key', 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'gpt-image-latest', prompt: 'A vintage travel poster of Ha Long Bay at sunset', aspectRatio: '9:16', resolution: '2K', }), }) const { data } = await res.json() // 2. Poll until complete const poll = setInterval(async () => { const job = await fetch(`https://api.sangtao.ai/v2/images/generations/${data.jobId}`, { headers: { 'X-Api-Key': 'your-api-key' }, }).then(r => r.json()) const status = job.data.status.toLowerCase() if (status === 'complete') { clearInterval(poll) console.log('Image URL:', job.data.resultUrl) } else if (status === 'error') { clearInterval(poll) console.error('Job failed:', job.data.error) } }, 5000) // Poll every 5 seconds ``` --- # 3. API Conventions The rules every /v2 endpoint follows. ## Errors Every error answers in one shape: ```json { "success": false, "errorCode": "INVALID_DPI", "error": "dpi must be between 1 and 1200.", "details": [ { "field": "dpi", "code": "INVALID_DPI", "message": "dpi must be between 1 and 1200.", "params": { "min": 1, "max": 1200 } } ], "requestId": "4bf92f3577b34da6a3ce929d0e0e4736" } ``` | Field | Type | Description | |-----------|----------|-------------| | success | boolean | false on every error | | errorCode | string | The code to branch on. Every error has one | | error | string | A sentence for people — in English when the request carries an X-Api-Key header. It may be reworded — do not match on it | | details | object[] | Errors only: what went wrong, field by field — { field, code, message, params }. params holds the numbers in the message (min, max, retryAfter…) | | requestId | string | Errors only: the same value as the X-Request-Id header | Every code — its HTTP status, its English message (with {params}) and whether a retry can help — is listed by `GET /v2/errors` (no key needed): each item `{ code, http, message, params[], retryable }`. The same list is in section 4 (Error Codes). ## Request ID Every response carries an `X-Request-Id` header; an error repeats it as `requestId`. Quote it when you report a problem. ## Rate Limits 6,000 requests per minute per API key; uploading reference images (`POST /images/presign`) at most 30 a minute. | Header | Meaning | |-----------------------|---------| | X-RateLimit-Limit | Requests allowed per minute | | X-RateLimit-Remaining | Requests left in this minute | | X-RateLimit-Reset | When the count starts again, in Unix seconds | | Retry-After | On a 429 only: seconds to wait | Over the limit, the call answers 429 with `errorCode: "RATE_LIMITED"`. Wait `Retry-After` seconds, then send it again. ## Pagination Lists take `page` (from 1) and `pageSize` — `limit` is accepted as the same thing. The total is in the `X-Total-Count` header. | List | Default pageSize | Max | No page or pageSize sent | |---------------------------|------------------|-----|--------------------------| | GET /characters | 50 | 200 | Everything | | GET /user-assets | 50 | 200 | Everything | | GET /jobs | 10 | 100 | The first 10 | | GET /account/transactions | 20 | 100 | The first 20 | GET /jobs gives its total in the body (`total`) instead of the header. ## Idempotency `POST /images/generations` and `POST /publishing/posts` take a key, in the `Idempotency-Key` header or the `idempotencyKey` body field, kept for 24 hours: | You send | You get | |-------------------------------------------|---------| | The same key, the same body | The earlier job or post, with `idempotentReplay: true` — nothing made or charged again | | The same key, another body | 422 `IDEMPOTENCY_KEY_REUSED` | | Header and body field with different keys | 422 `VALIDATION_ERROR` | | No key | A new job or post, every time | One new key per operation (a UUID, say). Send an old key again only when retrying that exact request. ## IDs Every id is an opaque string with a prefix — `sa_…` (a social account), `pp_…` (a post's network), `tx_…` (a transaction), `acct_…` (an account). Store it and send it back as it is; do not compare, sort or compute on it. --- # 4. Error Codes ## Error Format Every failed call — a wrong key, a missing field, a route that does not exist — answers in JSON, in this shape (fields explained in section 3, API Conventions): ```json { "success": false, "errorCode": "INVALID_DPI", "error": "dpi must be between 1 and 1200.", "details": [ { "field": "dpi", "code": "INVALID_DPI", "message": "dpi must be between 1 and 1200.", "params": { "min": 1, "max": 1200 } } ], "requestId": "4bf92f3577b34da6a3ce929d0e0e4736" } ``` > **Tip**: Branch on errorCode — not on the text of error, nor on the HTTP status (a few codes come with another status on some endpoints): the message is written for people and may change; the code does not. ## HTTP Status Codes | Status | Meaning | What to do | |-----------|------------------------|-------------------------------------------------------------------| | 200 / 201 | Success | Read the data field | | 400 | Invalid request | Fix the field the error names, then send again | | 401 | No key, or a wrong one | Send a valid key in the X-Api-Key header | | 402 | Not enough credits | Top up, then retry — retrying before that fails the same way | | 403 | Not allowed | The key cannot use this endpoint | | 404 | Not found | Check the path and the id | | 409 | Already done | The thing was already done (an upload confirmed twice) — do not send it again | | 422 | A field has a wrong value (creating an image) | See errorCode, fix the value, then send again | | 429 | Too many requests | Wait, then retry with backoff | | 500 / 502 / 503 | Server error | Retry after a few seconds; contact support if it keeps happening | Most endpoints answer a bad request with 400. Creating an image (POST /images/generations) answers 422 instead — a missing model or prompt, an aspectRatio, format, resolution or dpi it does not take, or a reference image link that does not exist. ## Error Codes The full list is `GET /v2/errors` (no key needed) — read it there to stay current. Messages are in English, as calls with an API key get them; `{name}` is filled in from `details[].params`. HTTP is the usual status — an endpoint that answers another one says so in its own section. Codes any endpoint can answer: | errorCode | HTTP | Message | Retry? | |---|---|---|---| | BAD_REQUEST | 400 | The request is invalid. | No | | VALIDATION_ERROR | 400 (422) | The request contains invalid fields. See details. | No | | BODY_REQUIRED | 400 | Request body is required. | No | | UNAUTHORIZED | 401 | Missing or invalid API key. | No | | FORBIDDEN | 403 | You do not have permission to do this. | No | | NOT_FOUND | 404 | The requested resource was not found. | No | | CONFLICT | 409 | The request conflicts with the current state of the resource. | No | | PAYLOAD_TOO_LARGE | 413 | The request body is too large. | No | | INSUFFICIENT_CREDITS | 402 | Not enough credits. Required: {required}, available: {available}. | No | | RATE_LIMITED | 429 | Too many requests. Retry after {retryAfter} seconds. | Yes | | TOO_MANY_ACTIVE_JOBS | 429 | Too many active jobs ({active}/{limit}). Wait for current jobs to finish. | Yes | | INTERNAL_ERROR | 500 | Something went wrong on our side. Please try again. | Yes | | UPSTREAM_ERROR | 502 | A downstream service failed. Please try again. | Yes | | SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable. Please try again later. | Yes | Codes of particular endpoints: | errorCode | HTTP | Message | Retry? | |---|---|---|---| | ENDPOINT_NOT_FOUND | 404 | Endpoint not found. Check the path and HTTP method. | No | | MODEL_REQUIRED | 422 | model is required. | No | | PROMPT_REQUIRED | 422 | prompt is required. | No | | MODEL_NOT_FOUND | 404 | Model not found. See GET /v2/models for available models. | No | | MODEL_WRONG_CATEGORY | 422 | This model belongs to another category. Use the endpoint for its category. | No | | SERVER_NOT_FOUND | 422 | This server is not available for the model. See details for available servers. | No | | IDEMPOTENCY_KEY_REUSED | 422 | This idempotency key was already used for a different request. Use a new key. | No | | INVALID_DPI | 422 | dpi must be between {min} and {max}. | No | | INVALID_FORMAT | 422 | format must be one of: {allowed}. | No | | INVALID_ASPECT_RATIO | 422 | aspectRatio must be W:H (positive integers, e.g. 16:9) or "auto", at most {max}:1. Received "{value}". | No | | INVALID_RESOLUTION | 422 | resolution '{value}' is not valid. Use one of: {supported}. | No | | RESOLUTION_NOT_SUPPORTED | 422 | Resolution '{value}' is not supported yet. Available: {supported}. | No | | REFERENCE_IMAGE_UNREACHABLE | 422 (400) | Could not download reference image: {url}. Check that the link is alive and allows server downloads. | No | | AGENT_QUEUE_FULL | 429 | The queue is full ({queued} waiting, {running} running). Try again shortly. | Yes | | NO_AGENT_AVAILABLE | 503 | No worker is available right now. Please try again shortly. | Yes | | INVALID_COUNT | 422 | count must be between {min} and {max}. | No | | COUNT_NOT_SUPPORTED_FOR_API_KEY | 400 | count is web-only. With an API key, send one request per image. | No | | ROUND_NOT_AFFORDABLE | 402 | Not enough credits for this many images. | No | | INVALID_VISIBILITY | 422 | visibility must be 'public' or 'private'. | No | | RESOLUTION_REQUIRES_PLAN | 403 | This resolution requires an active plan. | No | | JOB_NOT_FOUND | 404 | Job not found. | No | | JOB_NOT_READY | 400 | The job has not finished yet. | No | | JOB_PUBLISHED | 409 | The job is published. Unpublish it first. | No | | CHARACTER_NOT_FOUND | 404 | Character not found. | No | | REF_IMAGE_NOT_FOUND | 404 | Reference image not found. | No | | ASSET_NOT_FOUND | 404 | Asset not found. | No | | ASSET_ALREADY_CONFIRMED | 409 | This upload was already confirmed. | No | | UNSUPPORTED_FILE_TYPE | 400 | Unsupported file type. | No | | UPLOAD_FAILED | 400 (500) | Upload failed. Please try again. | Yes | | QUOTA_EXCEEDED | 429 (400) | Quota exceeded. Delete some items or upgrade your plan. | No | | UPLOAD_QUOTA_EXCEEDED | 429 | Daily upload limit reached. Try again after 00:00 UTC, or use an image URL you already host. | Yes | | TEAM_NOT_FOUND | 404 | Publishing team not found. | No | | POST_NOT_FOUND | 404 | Post not found. | No | | PUBLISH_FAILED | 502 | Could not publish. Please try again. | Yes | | NO_API_ACCESS | 403 | Your plan does not include API access. | No | | NO_PUBLISHING_TEAM | 404 | No publishing team found. Connect a social account first. | No | | INVALID_GENRE | 422 | Unknown music genre. | No | | NO_CONNECTED_ACCOUNT | 400 | This platform is not connected. Connect it first. | No | | ACCOUNT_NEEDS_ATTENTION | 409 | The page for {platform} changed or lost access. Select the page again before posting. | No | | CHANNEL_LIMIT | 403 | Connected channel limit reached. | No | | NO_SUBSCRIPTION | 500 | Publishing is not set up for this account. Contact support. | No | | INVALID_DATE_RANGE | 400 | Invalid date range. Use 1DAY, 7DAY or 30DAY. | No | | INVALID_WEBHOOK_URL | 422 | url must be a public https URL on port 443 without credentials. | No | | INVALID_WEBHOOK_EVENT | 422 | Unknown event type. See details for allowed values. | No | | WEBHOOK_LIMIT_REACHED | 422 | You can have at most {limit} webhook endpoints. | No | | WEBHOOK_NOT_FOUND | 404 | Webhook endpoint not found. | No | | DELIVERY_NOT_FOUND | 404 | Delivery not found. | No | | WEBHOOK_DISABLED | 409 | This webhook endpoint is disabled. Enable it first. | No | | TEMPLATE_NOT_FOUND | 404 | Template not found, or your account has no access to it. | No | | SOURCE_NOT_SUPPORTED | 400 | This template does not accept a source video. | No | | URL_NOT_ALLOWED | 400 | sourceUrl must be a public http(s) URL on port 80 or 443. | No | | ANALYSIS_NOT_FOUND | 404 | Analysis not found. | No | | URL_FETCH_FAILED | 502 | Could not download the video from this URL. | Yes | | ASSET_TOO_LARGE | 413 | The video is too large (max 300 MB). | No | | SOURCE_INVALID | 422 | The source video could not be read. Use an mp4 / mov / webm file. | No | | ANALYSIS_FAILED | 502 | Video analysis failed. Please try again later. | Yes | | ROTATE_REQUIRES_LOGIN | 403 | An API key cannot rotate itself. Sign in to rotate it. | No | --- # 5. Account ## GET /api/v2/account The account the API key belongs to: its credits, monthly plan, the key itself and its limits. Any key can read it — no scope needed. For the balance alone, GET /api/v2/account/balance (below) is lighter. ## cURL ```bash curl https://api.sangtao.ai/v2/account -H "X-Api-Key: YOUR_API_KEY" ``` ## Response ```json { "success": true, "data": { "id": "acct_3kT9qPz0B", "name": "An Nguyễn", "email": "an@example.com", "emailVerified": true, "createdAt": "2026-08-01T03:12:00Z", "credits": { "freeCredit": 50, "purchasedCredit": 100, "lockedCredit": 8, "availableCredit": 142, "hasPurchased": true }, "subscription": { "tier": "Pro", "periodStart": "2026-11-01T00:00:00Z", "periodEnd": "2026-12-01T00:00:00Z", "daysLeft": 22, "quotas": [ { "model": "gpt-image-2.6", "maxPerMonth": 3000, "maxPerDay": 200, "usedThisPeriod": 412, "remaining": 2588 } ] }, "apiKey": { "name": "Worker VPS-03", "prefix": "sk_live_ab12", "legacy": false, "scopes": ["jobs:create", "jobs:read"], "createdAt": "2026-10-02T08:00:00Z", "expiresAt": null, "lastUsedAt": "2026-11-09T10:01:00Z" }, "limits": { "requestsPerMinute": 6000, "maxConcurrentJobs": 20 } } } ``` ### Response Fields | Field | Type | Description | |---------------------------|-----------------|-------------| | id | string | The account's public id, stable. Quote it when you contact support | | name / email | string | The account's name and email | | emailVerified | boolean | Whether the email is verified | | createdAt | string | When the account was made, ISO 8601 UTC | | credits | object | The balance, as GET /account/balance gives it (without the website-only fields) | | subscription | object / null | The monthly plan in use, null when none: tier, periodStart, periodEnd, daysLeft, quotas[] (model — the public model name the quota is for, e.g. "gpt-image-2.6"; maxPerMonth, maxPerDay, usedThisPeriod, remaining) | | apiKey | object | The key this call was made with: name, prefix, legacy, scopes, createdAt, expiresAt, lastUsedAt. scopes null = full access; legacy true = an old-style key (one per account, full access, no name) | | limits.requestsPerMinute | number | Most requests per minute | | limits.maxConcurrentJobs | number | Most jobs running at once | ## GET /api/v2/account/balance Get current credit balance. Check this before submitting generation jobs to ensure sufficient credits. ## cURL ```bash curl https://api.sangtao.ai/v2/account/balance \ -H "X-Api-Key: YOUR_API_KEY" ``` ## Response ```json { "success": true, "data": { "freeCredit": 50, "purchasedCredit": 100, "lockedCredit": 8, "availableCredit": 142, "hasPurchased": true, "topupMemo": "891188", "isTelegramLinked": false, "trialOffer": null } } ``` ### Response Fields | Field | Type | Description | |-----------------|---------|--------------------------------------------------------------| | freeCredit | number | Free credits from subscription plan / trial bonus. Resets monthly | | purchasedCredit | number | Purchased credits. Never expire | | lockedCredit | number | Credits held for currently running jobs | | availableCredit | number | freeCredit + purchasedCredit - lockedCredit | | hasPurchased | boolean | Whether user has ever purchased credits | | topupMemo | string | Memo/reference code for bank transfer top-up | | isTelegramLinked | boolean | true when the account has a notification channel switched on (Telegram, for example) | | trialOffer | object / null | The trial pack a new account can still buy. null when there is none to offer | ### trialOffer Fields | Field | Type | Description | |----------------|----------------|--------------------------------------------------------------------| | eligible | boolean | Whether it can still be bought | | expiresAt | string / null | Last moment to buy it, ISO 8601 in UTC (ends in Z). The offer's deadline, not the credits' | | remainingSlots | number / null | Places left — only when few remain, otherwise null | | priceVnd | number | Price to pay, in VND | | credits | number | Total credits received, bonus included | | bonusCredits | number | The bonus part of credits | ## Deduction Order Credits are deducted from freeCredit first, then purchasedCredit. Check availableCredit >= estimated cost before submitting a job. ## GET /api/v2/account/transactions Every credit movement on the account, newest first: top-ups, charges, refunds. ### Query Parameters | Parameter | Type | Required | Description | |-----------|--------|----------|-------------| | type | string | No | Only this type, case-insensitive: Topup, Consume, Bonus, Refund, Adjustment, Exchange | | page | int | No | Page number, from 1. Default 1 | | pageSize | int | No | Transactions per page. Default 20, at most 100. limit is accepted as the same thing | ```bash curl "https://api.sangtao.ai/v2/account/transactions?page=1&pageSize=20" \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "success": true, "data": [ { "id": "tx_8hQ2mZr4K", "type": "Consume", "description": "Motion render", "descriptionKey": "motion_render", "descriptionParams": {}, "amount": -12, "balanceAfter": 142, "jobId": "acff47cbfacf4e27bbc08770c9ea246d", "createdAt": "2026-11-09T10:01:00Z" }, { "id": "tx_3kT9qPz0B", "type": "Topup", "description": "Nạp 200.000đ — Gói Starter", "descriptionKey": "bank_topup", "descriptionParams": { "vnd": "200000", "package": "Gói Starter" }, "amount": 150, "balanceAfter": 154, "jobId": null, "createdAt": "2026-11-08T03:20:00Z" } ] } ``` An array of transactions; the total for the filter is in the `X-Total-Count` header. | Field | Type | Description | |-------------------|---------|-------------| | id | string | The transaction's id, tx_… | | type | string | Topup, Consume, Bonus, Refund, Adjustment or Exchange | | description | string | What it was for, as a sentence (in Vietnamese) | | descriptionKey | string? | The kind of sentence, to write description in your own language, e.g. "bank_topup". null when there is none | | descriptionParams | object? | The values in that sentence (amount, package…), as strings | | amount | number | Credits added (positive) or taken (negative) | | balanceAfter | number | The balance right after it | | jobId | string? | The job it was for. null when it is not about a job (a top-up, say) | | createdAt | string | When it happened, ISO 8601 UTC | --- # 6. Models ## GET /api/v2/models List available AI models. Each model includes a keyed config object that defines form fields — render these dynamically instead of hardcoding. GET /api/v2/models/{slug} returns one model, in the same shape. > **Tip**: This endpoint does not require authentication. You can call it without an API key. ## Query Parameters | Parameter | Type | Required | Description | |-----------|--------|----------|--------------------------------------------| | category | string | No | Filter by category: "image", "video", etc. | ## cURL ```bash curl "https://api.sangtao.ai/v2/models?category=image" \ -H "X-Api-Key: YOUR_API_KEY" ``` ## Response Fields | Field | Type | Required | Description | |-------------|---------------|----------|----------------------------------------------------------------------------| | id | number | No | Legacy — do not use. Kept for existing code; removed in a later version. Use slug | | slug | string | Yes | Unique model identifier — use as the model param when creating, e.g. "gpt-image-2.6" | | displayName | string | Yes | Display name | | category | string | Yes | Lowercase category (see table below) | | creditCost | number | Yes | Base credit cost per generation | | description | string | No | Model description (nullable) | | imageUrl | string | No | Model preview image (nullable). Links here and in config (sampleOutputUrl…) are on cdn.sangtao.ai | | aliases | string[] | No | Other names the model answers to, also accepted as model — e.g. ["gpt-image-latest"] for GPT Image. It may also hold the model's former names; every name in the list can be sent. Empty for most models | | servers | object[] | No | Where the model can run, each { id, name, description, default, creditCost, available, capabilities }. Every model has one for now: "standard", the default. Pass id as server when creating, or leave server out | `servers[].capabilities` — what that server can do, a key only where it applies: `resolutions` (string[], values resolution takes; `original` keeps the image as the model made it, no upscaling), `defaultResolution` (string, used when resolution is left out: `original`), `aspectRatios` (string[], suggested aspectRatio values), `maxRefImages` (number), `formats` (string[], for format), `transparent` (boolean, whether requireTransparent can be asked). | config | object / null | No | Keyed config object — each key is a param name, value is field definition | ## Config Field Schema Config is a keyed object where each key is the parameter name (e.g. "aspectRatio", "imageResolution"). Special keys, not fields: _badges (string[], display badges), maxRefImages (number, max reference images). | Field | Type | Required | Description | |---------------------|----------|----------|--------------------------------------------------------------------| | type | string | Yes | Field type: "select", "card-select", "number", "text", "toggle", "upload" | | label | string | Yes | Display label (English) | | labelVi | string | No | Vietnamese label | | options | object[] | No | Options for select/card-select [{value, label, imageUrl?}] | | default | any | No | Default value | | min | number | No | Min value (number fields) | | max | number | No | Max value (number fields) | | advanced | boolean | No | If true, show in advanced/collapsed section | | optional | boolean | No | If true, field is optional | The GPT Image model (gpt-image-2.6, alias gpt-image-latest) has these keys: | Key | Type | Description | |-----------------|--------|--------------------------------------------------------------------------| | aspectRatio | object | A select field: "auto", "1:1", "16:9", "9:16"… Default "auto" | | imageResolution | object | A select field with options "1k" (1K) and "2k" (2K). Default "1k" — the web picker's. Over the API the field is resolution, and leaving it out means "original" (see GPT Image) | | maxRefImages | number | A plain number, not a field: how many reference images the model takes. 20 for GPT Image | > **Tip**: When submitting a generation job, include config values as top-level params alongside prompt and model. ## Example Response The real GPT Image entry an API key gets, aspectRatio's 16 options cut to four: ```json { "success": true, "data": [ { "id": 48, "slug": "gpt-image-2.6", "displayName": "GPT Image 2.6", "category": "image", "description": null, "imageUrl": null, "creditCost": 3, "config": { "maxRefImages": 20, "aspectRatio": { "type": "select", "label": "Aspect Ratio", "labelVi": "Tỉ lệ khung hình", "options": [ { "value": "auto", "label": "Auto" }, { "value": "1:1", "label": "1:1" }, { "value": "9:16", "label": "9:16" }, { "value": "16:9", "label": "16:9" } ], "default": "auto" }, "imageResolution": { "type": "select", "label": "Resolution", "labelVi": "Độ phân giải", "options": [ { "value": "1k", "label": "1K" }, { "value": "2k", "label": "2K" } ], "default": "1k" } }, "aliases": ["gpt-image-latest"], "servers": [ { "id": "standard", "name": "Standard", "description": null, "default": true, "creditCost": 3, "available": true, "capabilities": { "resolutions": ["original", "1k", "2k"], "defaultResolution": "original", "aspectRatios": ["auto", "1:1", "9:16", "16:9"], "maxRefImages": 20, "formats": ["png", "jpeg"], "transparent": true } } ] } ] } ``` ## Model Categories | Category | Description | |----------------|----------------------------------------------------------| | image | AI image generation (GPT Image) | The list may also hold the older video tools (video, voiceovervideo, stockvideo, slideshowvideo, articlevideo, render). They are moving to Motion and are not documented. ## Errors | Status | errorCode | When | |--------|-----------------|------------------------------------------------------------------------------| | 404 | MODEL_NOT_FOUND | GET /models/{slug}: no model with this slug, or one in a category the API does not offer | --- # 7. GPT Image Human-readable version: https://docs.sangtao.ai/vi/create/gpt-image Base URL: `https://api.sangtao.ai/v2` Auth: header `X-Api-Key: ` > Use the `X-Api-Key` header. `Authorization: Bearer ` returns 401 — > this is the default in the OpenAI SDK and many HTTP clients, so set the header > explicitly. Generation is asynchronous. There is no endpoint that returns an image in one call. ``` 1. POST /images/generations -> { jobId, status: "WaitingForAgent" } 2. GET /images/generations/{jobId} -> poll until status = complete | error 3. read resultUrl / resultImages ``` A job usually takes 30-90 seconds. Two model names: `gpt-image-latest` (recommended) always points to the current GPT Image model; `gpt-image-2.6` stays on this version, for when an update must not change your output. --- ## 1. Create a job ```http POST /api/v2/images/generations X-Api-Key: Content-Type: application/json ``` ```json { "model": "gpt-image-latest", "prompt": "A vintage travel poster of Ha Long Bay at sunset, bold typography, retro color palette", "aspectRatio": "9:16", "format": "png", "idempotencyKey": "poster-halong-001" } ``` ### Parameters | Field | Type | Required | Notes | |---|---|---|---| | `model` | string | yes | `gpt-image-latest` (recommended) or `gpt-image-2.6` (pinned version). Missing: 422 `MODEL_REQUIRED`; an unknown name: 404 `MODEL_NOT_FOUND`; a model that does not make images: 422 `MODEL_WRONG_CATEGORY` | | `server` | string | no | Where the job runs. Only `standard` for now, the default — leave it out. Anything else: 422 `SERVER_NOT_FOUND`, the model's servers in `data.servers` | | `prompt` | string | yes | Describe the scene, style and composition. Very short prompts are rejected. Missing: 422 `PROMPT_REQUIRED` | | `aspectRatio` | string | no | `auto` (the model chooses) or `W:H` — whole numbers 1–100, no flatter than 3:1 (e.g. `1:1`, `16:9`, `9:16`, `4:5`, `21:9`, `3:1`, `1:3`). Spaces are ignored. See below | | `format` | string | no | `png` (default) or `jpeg` | | `referenceImages` | string[] | no | Up to 20 full URLs starting with http:// or https://, checked when the job is created (see below). A longer list is cut to its first 20 | | `resolution` | string | no | `original` (default), `1k` or `2k`, case-insensitive. `original` keeps the model's image as is, no upscaling (16:9 → 1672×941); `1k` gives a long edge of about 1024px, `2k` about 2048px | | `requireTransparent` | bool | no | Ask for a transparent background | | `dpi` | int | no | 1-1200. Print metadata only — does not change pixel dimensions | | `idempotencyKey` | string | no | Recommended. The same key with the same body within 24 hours returns the same job instead of charging twice; another body: 422 `IDEMPOTENCY_KEY_REUSED` (see below). The `Idempotency-Key` header works too | | `webhookUrl` | string | no | A public https URL (port 443) to POST this job's result to when it is done — see 15. Webhooks. A bad URL: 422 `INVALID_WEBHOOK_URL`, before any job or charge | | `webhookSecret` | string | no | Signs those deliveries (`X-Sangtao-Signature`). Up to 200 characters. Without it they come unsigned | `aspectRatio`: the suggested list — the same as the picker on the web — is `auto, 1:1, 1:3, 3:1, 1:2, 2:1, 9:16, 16:9, 10:16, 16:10, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4`. Read it per model from `GET /models/{slug}`, `config.aspectRatio.options`. A valid ratio outside the list is still accepted. Anything else is refused with 422 `INVALID_ASPECT_RATIO`, the suggested list in `data.supportedAspectRatios`. The output keeps the ratio: 16:9 comes back 1672×941. Unknown fields are ignored silently: a misspelled field name still returns 200 and behaves as though you had not sent it. Check names against this table. Each job returns 1 image, sometimes 2 for A/B testing. Read the whole `resultImages` array rather than taking the first entry. ```json { "success": true, "data": { "jobId": "068ee50981eb43c2a3cbc4edf2ead771", "status": "WaitingForAgent", "creditCost": 3.00, "queuePosition": 0, "queueDepth": 50, "runningJobs": 0, "concurrency": 10, "chargeSource": "Credit", "quotaRemaining": null, "model": "gpt-image-latest", "resolvedModel": "gpt-image-2.6", "server": "standard" }, "error": null, "errorCode": null } ``` `queuePosition` is how many jobs not yet picked up by a machine are ahead of this one — including jobs about to start within seconds; the same meaning as in `GET /jobs/{id}`. To know whether the job runs right away, check `runningJobs < concurrency`. `model` is the name exactly as you sent it; `resolvedModel` is the model that runs the job, fixed when it is created (if `gpt-image-latest` later moves to a newer version, this job still says the one it ran on); `server` is where it runs. ### Reference images Every link is checked when the job is created, before any credits are locked (a HEAD request, or a GET of the first 1 KB, waiting up to 6 seconds). A link that definitely does not exist — 404 or 410, or for a link on our own storage or CDN any 4xx except 429 — is refused with 422, naming the broken link; no job is made and nothing is locked: ```json { "success": false, "error": "Không tải được ảnh tham chiếu: https://.../a.jpg. Kiểm tra link còn sống và không bị chặn tải từ máy chủ khác.", "errorCode": "REFERENCE_IMAGE_UNREACHABLE" } ``` A 403, a 5xx or a timeout is let through, since many sites block requests from servers. If the image then cannot be fetched, the job ends in `error` with `failureKind: "REFERENCE_DOWNLOAD_FAILED"`. ### Idempotency Calling again with the same `idempotencyKey` (or `Idempotency-Key` header) and the same body within 24 hours creates no second job and charges nothing further. The answer has the shape of the first one plus `"idempotentReplay": true`: the same `jobId`, the original job's `creditCost` and `chargeSource`, and `status` as the job stands now (it may already be `Processing` or `complete`). `queuePosition`, `queueDepth`, `runningJobs`, `concurrency`, `capReason`, `quotaRemaining` and `runningOnFallback` are null — poll `GET /images/generations/{id}` for the current state. Use it when retrying: a network timeout does not mean the job was not created, and without a key a retry creates a second job you pay for. **One new key per image you actually want** (a UUID, or your own order id). Send an old key again only to retry that exact request. The same key with a different body (another prompt, say) is refused with 422 `IDEMPOTENCY_KEY_REUSED`. A header and a body field with different keys: 422 `VALIDATION_ERROR`. No key: always a new job. --- ## 2. Poll ```http GET /api/v2/images/generations/{jobId} X-Api-Key: ``` The same answer as `GET /api/v2/jobs/{jobId}`, plus `model`, `resolvedModel` and `server`. This route only finds image jobs: any other job is 404 `JOB_NOT_FOUND`. ```json { "success": true, "data": { "jobId": "068ee509...", "status": "complete", "progress": 100.0, "resultUrl": "https://.../3956922d.png", "resultImages": ["https://.../3956922d.png"], "resultData": { "resultUrl": "https://.../3956922d.png", "imageUrls": ["https://.../3956922d.png"], "width": 941, "height": 1672, "durationMs": 20000 }, "error": null, "failureKind": null, "creditCost": 3.0, "createdAt": "2026-09-13T08:38:49Z", "completedAt": "2026-09-13T08:39:09Z", "model": "gpt-image-latest", "resolvedModel": "gpt-image-2.6", "server": "standard" } } ``` | status | Meaning | What to do | |---|---|---| | `WaitingForAgent` | Queued | Keep polling | | `Processing` | Running | Keep polling | | `complete` | Done | Read `resultUrl` / `resultImages` | | `error` | Failed | Read `failureKind` | Compare `status` case-insensitively — do not write `status === "Complete"`. Suggested rhythm: wait 10 seconds before the first poll, then poll every 5-10 seconds, and give up after 10 minutes. --- ## 3. Download `resultUrl` is a public link — a plain GET, no headers. **Images are deleted after 7 days.** Download and store them on your side as soon as a job finishes. Do not use `resultUrl` as long-term storage or embed it directly in a website. `resultImages` is the array and `resultUrl` is its first element. Read `resultImages` so your code needs no change if that ever grows. --- ## Errors when creating | HTTP | Cause | |---|---| | 401 | Missing or wrong `X-Api-Key` (`UNAUTHORIZED`) | | 404 | Unknown model name (`MODEL_NOT_FOUND`), or wrong path (`ENDPOINT_NOT_FOUND`) | | 422 | `MODEL_REQUIRED` / `PROMPT_REQUIRED` — model or prompt missing | | 422 | `MODEL_WRONG_CATEGORY` — the model does not make images; its category in `data.category` | | 422 | `SERVER_NOT_FOUND` — the model has no such server; the ones it has in `data.servers` | | 400 | `VALIDATION_ERROR` — a value of the wrong type (`dpi: "abc"`): `details[0]` is `{ "field": "dpi", "code": "INVALID_TYPE", "message": "dpi must be an integer." }` | | 422 | `VALIDATION_ERROR` — `n` above 1 | | 422 | `INVALID_ASPECT_RATIO` — not `auto` or a W:H up to 3:1; suggested ratios in `data.supportedAspectRatios` | | 422 | `INVALID_FORMAT` — format is not `png` / `jpeg` | | 422 | `INVALID_RESOLUTION` — not `original`, `1k` or `2k` (`RESOLUTION_NOT_SUPPORTED` for 4k) | | 422 | `INVALID_DPI` — dpi outside 1–1200 | | 422 | `REFERENCE_IMAGE_UNREACHABLE` — a reference link does not exist; no job, no credits locked | | 422 | `IDEMPOTENCY_KEY_REUSED` — the idempotencyKey was already used for another body | | 422 | `INVALID_WEBHOOK_URL` — webhookUrl is not a public https URL on port 443 | | 402 | Out of credits or plan quota | | 429 | Queue full (`AGENT_QUEUE_FULL`) — wait and retry | | 503 | Temporarily unable to run the job | Validation errors on this endpoint are 422, not the 400 other endpoints use. A wrong path returns 404 as well, with `errorCode: "ENDPOINT_NOT_FOUND"`. Distinguish by `errorCode`, not by the HTTP status alone. The correct route is `/images/generations`. ## Errors while running A failed job returns `status: "error"` with a `failureKind`. This decides whether retrying is worth anything. | failureKind | Meaning | Retry? | |---|---|---| | `PROMPT_UNCLEAR` | Description too vague | No — change the prompt | | `CONTENT_REJECTED` | Against the content policy | No — change the prompt | | `TRANSPARENT_CREATION_FAILED` | Transparency requested, result had none | No — be explicit in the prompt | | `REFERENCE_DOWNLOAD_FAILED` | A reference image could not be fetched | No — fix the link | | `GENERATION_TIMEOUT` | Ran out of time | Yes | | `QUOTA_EXHAUSTED` | Temporarily at capacity | Yes, after a few hours | | `SESSION_EXPIRED` | Not caused by your request | Contact support | | `INVALID_PAYLOAD` | Internal error | Contact support | ```json { "status": "error", "failureKind": "PROMPT_UNCLEAR", "error": "The prompt is not clear enough to create an image. Describe the image you want more specifically.", "creditCost": 0 } ``` A prompt of one or two words is rejected: `"blue square"` returns `PROMPT_UNCLEAR`. Describe the scene, the style and the composition. --- ## Limits and cost | Limit | Value | |---|---| | Reference images | 20 per job | | Images generated | Usually 1, sometimes 2 — read resultImages | | Jobs in parallel | Your plan's thread count (`concurrency` in the response); extra jobs queue | | Result lifetime | 7 days | Every response — on create and on poll — carries `creditCost` and `chargeSource`. Read those rather than computing a price yourself. `chargeSource` is `"Credit"` (prepaid, 3 credits an image) or `"Subscription"` (a monthly plan, where `quotaRemaining` is what is left). A failed job is not charged; credits are refunded automatically, and its `creditCost` then reads 0. Jobs run in parallel up to your plan's thread count. The create response reports it as `concurrency`, alongside `runningJobs` and `queueDepth`. Submit more than that and the extra ones queue rather than fail — `queuePosition` counts the jobs not yet picked up ahead of them. --- ## Complete example ```bash KEY="your-api-key" BASE="https://api.sangtao.ai/v2" JOB=$(curl -sS -X POST "$BASE/images/generations" \\ -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\ -d '{ "model": "gpt-image-latest", "prompt": "A vintage travel poster of Ha Long Bay at sunset, bold typography, retro color palette", "aspectRatio": "9:16", "format": "png", "idempotencyKey": "poster-halong-001" }' | jq -r '.data.jobId') sleep 10 for i in $(seq 60); do R=$(curl -sS "$BASE/images/generations/$JOB" -H "X-Api-Key: $KEY") S=$(echo "$R" | jq -r '.data.status' | tr 'A-Z' 'a-z') [ "$S" = "complete" ] && { echo "$R" | jq -r '.data.resultUrl'; break; } [ "$S" = "error" ] && { echo "$R" | jq -r '.data.failureKind + ": " + .data.error'; break; } sleep 8 done ``` --- Human-readable version: https://docs.sangtao.ai/vi/create/gpt-image --- # 8. Image Upload ## Uploading a reference image ### POST /api/v2/images/presign Images uploaded here are kept only temporarily, as reference images (referenceImages) for the image you create right after, and are deleted automatically after 1 day. To keep a file for good and reuse it, use the asset library (section 12, User Assets: presign, PUT, confirm). Files up to 25 MB, in two calls: get a link, then PUT the file to it. ```bash # 1. Ask for a link curl -X POST https://api.sangtao.ai/v2/images/presign -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" -d '{ "fileName": "design.png", "contentType": "image/png" }' ``` ```json { "success": true, "data": { "uploadUrl": "https://…/ref-uploads/…/745c53c6-….png?sv=…&sig=…", "method": "PUT", "headers": { "Content-Type": "image/png", "x-ms-blob-type": "BlockBlob" }, "url": "https://cdn.sangtao.ai/ref-uploads/…/745c53c6-….png", "expiresIn": 600, "fileKey": "284c42a4-…/745c53c6-….png" } } ``` | Field | Type | Description | |-----------|--------|-------------| | uploadUrl | string | Where to send the file. The signature is in the URL, so that request needs no X-Api-Key | | method | string | The method for that request: "PUT" | | headers | object | Send exactly these headers with the file — Content-Type, and x-ms-blob-type when it is there. Read them from the response rather than writing them in your code | | url | string | The image's address once uploaded: pass it as referenceImages | | expiresIn | number | Seconds until uploadUrl stops working (600). Upload as soon as you have it | | fileKey | string | The file's key on our side. Not needed to create an image | `contentType` must be image/png, image/jpeg or image/webp. Any other type is refused with 400 `UNSUPPORTED_FILE_TYPE`. ```javascript // 2. Send the file exactly as the response says await fetch(data.uploadUrl, { method: data.method, headers: data.headers, body: file }) ``` ## Usage with GPT Image ```javascript const jobRes = await fetch('https://api.sangtao.ai/v2/images/generations', { method: 'POST', headers: { 'X-Api-Key': 'YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'gpt-image-latest', prompt: 'A vintage travel poster of Ha Long Bay at sunset', referenceImages: [data.url], }), }) ``` ## Error Codes | Status | errorCode | When | |--------|-----------------------|-----------------------------------------------------------------------| | 400 | UNSUPPORTED_FILE_TYPE | contentType is not image/png, image/jpeg or image/webp | --- # 9. Job Polling ## GET /api/v2/jobs/{jobId} Every endpoint that creates a job — GPT Image (POST /images/generations) — returns a jobId. Poll this endpoint to track progress and get results. For an image job, GET /images/generations/{jobId} gives the same answer — use whichever you like. That route only finds image jobs: any other job is 404 JOB_NOT_FOUND there. ## Job Flow | Status | Description | Action | |------------|----------------------------------------------------|-----------------------| | pending | Job queued, waiting to start | Continue polling | | processing | Job running, progress updates available | Show progress bar | | complete | Done! Results available in resultUrl / resultImages | Display results | | error | Failed. Error message in error field | Show error + retry option | A GPT Image job starts at "WaitingForAgent" (waiting for a machine), not "pending" — treat it like pending and keep polling. ## Response Fields | Field | Type | Description | |--------------|----------|-----------------------------------------------------------------| | jobId | string | Unique job identifier | | type | string | The kind of job: "AgentJob" (GPT Image), "RenderVideo" (custom render) — the values the type filter of GET /jobs takes | | category | string | The group the kind belongs to, e.g. "agent" | | typeDisplayName | string | The kind as a name to show, e.g. "Agent Job" | | title | string | The job's title; null for jobs that have none (image jobs) | | prompt | string | The prompt you sent — filled in the list (GET /jobs); null here, read params.prompt | | status | string | "pending" | "processing" | "complete" | "error" | | progress | int | Progress percentage (0-100). Only during processing | | step | string | Current step of a multi-step job. Always null for image jobs | | resultUrl | string | URL of the result (when complete). Holds for seven days | | resultImages | string[] | Array of generated image URLs (when complete) | | resultData | object | resultUrl, imageUrls (an array), width, height and durationMs (numbers), and thumbnailUrl when the job has one | | thumbnailUrl | string | Thumbnail URL (for video jobs) | | hasDownloadableAssets | boolean | true when the job has files to download | | queuePosition | number | While waiting for a machine: how many jobs not yet picked up are ahead of it (0 = next); null once running or done | | etaSeconds | number | While waiting: about how many seconds until a machine starts it; null without enough data, or once running | | etaCompleteSeconds | number | While waiting or running: about how many seconds until the result is ready; null without enough data, or once done | | error | string | Error message (when status = "error") | | creditCost | number | Once settled, what was actually charged; a failed job is refunded, so 0; while running, the amount locked | | params | object | What you sent. For an image job only the fields you sent, with proper types; referenceImages are your original links | | model | string | The model name as you sent it, e.g. "gpt-image-latest" | | resolvedModel | string | The model that ran the job, e.g. "gpt-image-2.6" — fixed when the job was created | | server | string | The server it ran on: "standard" | | webhook | object | Only when the request had webhookUrl: how its latest delivery went — see below | `webhook` (only when the request had `webhookUrl`): `{ url, status, attemptCount, lastHttpStatus, lastError, deliveryId }` — `status` is `waiting` (not finished, nothing sent), `pending` (failed, will retry), `succeeded` or `failed`; open `deliveryId` with `GET /webhooks/deliveries/{deliveryId}`. It is never inside a webhook's own `data`. ## Response Examples ### Processing ```json { "success": true, "data": { "jobId": "acff47cbfacf4e27bbc08770c9ea246d", "type": "AgentJob", "status": "processing", "progress": 0, "step": null, "queuePosition": null, "etaSeconds": null, "etaCompleteSeconds": 25 } } ``` ### Complete ```json { "success": true, "data": { "jobId": "acff47cbfacf4e27bbc08770c9ea246d", "type": "AgentJob", "category": "agent", "typeDisplayName": "Agent Job", "title": null, "prompt": null, "status": "complete", "progress": 100.0, "resultUrl": "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png", "resultImages": [ "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png" ], "resultData": { "resultUrl": "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png", "imageUrls": [ "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png" ], "width": 1024, "height": 1024, "durationMs": 13100 }, "error": null, "failureKind": null, "hasDownloadableAssets": true, "queuePosition": null, "etaSeconds": null, "etaCompleteSeconds": null, "elapsedSeconds": 13.1, "creditCost": 0.0, "createdAt": "2026-09-19T09:42:59Z", "processingAt": "2026-09-19T09:43:17Z", "completedAt": "2026-09-19T09:43:30Z", "model": "gpt-image-latest", "resolvedModel": "gpt-image-2.6", "server": "standard" } } ``` ### Error ```json { "success": true, "data": { "jobId": "acff47cbfacf4e27bbc08770c9ea246d", "status": "error", "step": null, "failureKind": "PROMPT_UNCLEAR", "error": "The prompt is not clear enough to create an image. Describe the image you want more specifically.", "creditCost": 0 } } ``` ## Errors | Status | errorCode | When | |--------|---------------|-----------------------------------| | 404 | JOB_NOT_FOUND | No job of yours with this jobId | A failed job is not an error of this call: it answers 200 with status "error" (above). ## Polling Intervals Wait about ten seconds before the first poll — nothing finishes faster than that, so an immediate one always comes back unfinished. | Job Type | Recommended Interval | Typical Duration | |--------------------|----------------------|---------------------| | GPT Image | 5 seconds | 30-90 seconds | ## Full Example (Python) ```python import requests import time API_KEY = "YOUR_API_KEY" BASE = "https://api.sangtao.ai/v2" # Step 1: Submit job resp = requests.post(f"{BASE}/images/generations", headers={ "X-Api-Key": API_KEY, "Content-Type": "application/json", }, json={ "model": "gpt-image-latest", "prompt": "A vintage travel poster of Ha Long Bay at sunset", "aspectRatio": "9:16", "resolution": "2k", }) job_id = resp.json()["data"]["jobId"] # Step 2: Poll until complete while True: status = requests.get(f"{BASE}/images/generations/{job_id}", headers={ "X-Api-Key": API_KEY, }).json()["data"] print(f"Status: {status['status']}, Progress: {status.get('progress', '-')}") if status["status"] == "complete": print("Images:", status["resultImages"]) break if status["status"] == "error": print("Error:", status["error"]) break time.sleep(5) ``` --- # 10. List Jobs ## GET /api/v2/jobs List all jobs for the current user with pagination and optional filters. Use this to build job history, dashboard, or management views. ## Query Parameters | Parameter | Type | Required | Description | |-----------|--------|----------|-------------------------------------------------------------------| | page | int | No | Page number. Default: 1 | | pageSize | int | No | Jobs per page. Default 10, at most 100. limit is accepted as the same thing | | type | string | No | Filter by job type: "AgentJob", "RenderVideo" (see table below) | | status | string | No | Filter by status: "pending", "processing", "complete", "error" | | blueprint | string | No | Filter by blueprint slug | ## Job Types | Type | Description | |-------------|-----------------------------------------------| | AgentJob | Image job created with POST /images/generations | | RenderVideo | Custom render from scenes | ## cURL ```bash curl "https://api.sangtao.ai/v2/jobs?page=1&pageSize=10&status=complete" \ -H "X-Api-Key: YOUR_API_KEY" ``` ## Response ```json { "success": true, "data": { "items": [ { "jobId": "acff47cbfacf4e27bbc08770c9ea246d", "type": "AgentJob", "typeDisplayName": "Agent Job", "category": "agent", "status": "complete", "step": null, "progress": 100, "error": null, "failureKind": null, "resultUrl": "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png", "resultImages": ["https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png"], "resultData": { "resultUrl": "https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png", "imageUrls": ["https://cdn.sangtao.ai/agent-jobs/acff47cb.../fa3c91.png"], "width": 1024, "height": 1024, "durationMs": 13100 }, "thumbnailUrl": null, "prompt": "A vintage travel poster of Ha Long Bay at sunset", "params": null, "creditCost": 0, "elapsedSeconds": 13.1, "createdAt": "2026-09-19T09:42:59Z", "processingAt": "2026-09-19T09:43:17Z", "completedAt": "2026-09-19T09:43:30Z", "retryCount": 0, "hasDownloadableAssets": true, "title": null, "queuePosition": null, "etaSeconds": null, "etaCompleteSeconds": null } ], "total": 42, "page": 1, "limit": 10 } } ``` data.items holds the jobs, with total, page and limit (the page size) beside it. Each item reads as GET /jobs/{jobId} does — creditCost, resultData and, for a failed job, failureKind included — except params, which is null in the list. resultData.thumbnailUrl is the job's thumbnail when it has one. ## DELETE /api/v2/jobs/{jobId} Delete a job and its result files, for good. > **Warning**: Deleting removes the job and its result files (images, videos) for good — their URLs stop working. It cannot be undone. ### Key Access The API key needs Write access to Jobs. A read-only key gets 403. ### cURL ```bash curl -X DELETE https://api.sangtao.ai/v2/jobs/img-456 \ -H "X-Api-Key: YOUR_API_KEY" ``` ### Response ```json { "success": true, "data": { "deleted": 1 } } ``` ### Errors | Status | errorCode | When | |--------|---------------|------------------------------------------------------------| | 403 | FORBIDDEN | The key has no Write access to Jobs | | 404 | JOB_NOT_FOUND | No job of yours with this jobId | | 409 | JOB_PUBLISHED | The job is public in the gallery — take it down first | --- # 11. Publishing Publish videos or images to social networks through the groups and accounts you connected on sangtao.ai. ## List Groups ### GET /api/v2/publishing/teams Your publishing groups — each holds the social accounts you post through. > **Note**: A group holds your connected social accounts — one account per network. Create groups and connect accounts on the Publishing page of sangtao.ai; connecting needs you to sign in to the network in a browser. Need two TikTok channels? Use two groups. ```bash curl https://api.sangtao.ai/v2/publishing/teams \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Response Fields An array, newest group first. | Field | Type | Description | |--------------|---------------|------------------------------------------------------| | id | string (guid) | The group's ID — the teamId of every other call | | name | string | Group name | | description | string? | Group description | | createdAt | string | When the group was created (ISO 8601, UTC) | | accountCount | number | Social accounts connected in this group | ```json { "success": true, "data": [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "Main channels", "description": null, "createdAt": "2026-11-01T10:00:00Z", "accountCount": 2 } ] } ``` --- ## Accounts of a Group ### GET /api/v2/publishing/teams/{teamId}/accounts The social accounts connected in one group, refreshed from the networks before they are returned — newest first. Accounts that were disconnected are left out. ```bash curl https://api.sangtao.ai/v2/publishing/teams/3fa85f64-5717-4562-b3fc-2c963f66afa6/accounts \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Response Fields | Field | Type | Description | |------------------|---------|---------------------------------------------------------------------------------------------------| | id | string | The account's id, sa_…. An opaque string: do not compare or compute on it | | platform | string | The network: TikTok, YouTube, Instagram, Facebook, Threads, Twitter | | displayName | string? | Account display name | | username | string? | Account username | | ownerDisplayName | string? | Name of the person who connected it | | avatarUrl | string? | Avatar URL | | channelId | string? | The Page or channel posts go to (a Facebook account has several Pages, a YouTube account several channels) | | channelName | string? | Name of that Page or channel | | page | object? | The Page or channel posts go to: { id, name, avatarUrl } (channelId / channelName are its old names). null where the network has none to pick | | needsAttention | boolean | true: the page changed or was lost without you picking it — posting to this account is refused (409 ACCOUNT_NEEDS_ATTENTION) until a page is picked again on sangtao.ai | | attentionReason | string? | CHANNEL_CHANGED or CHANNEL_LOST; null when nothing is wrong | | expectedChannel | object? | The page you picked, { id, name } — only when needsAttention | | actualChannel | object? | The page the network switched to, { id, name } — CHANNEL_CHANGED only | | attentionAt | string? | When it was noticed (ISO 8601, UTC) | | isConnected | boolean | Always true here — only connected accounts are listed | | connectedAt | string | When it was connected (ISO 8601, UTC) | | disconnectedAt | string? | When it was last disconnected, if ever | ```json { "success": true, "data": [ { "id": "sa_7mR2xLq5D", "platform": "Facebook", "displayName": "Your Page", "username": "yourpage", "ownerDisplayName": "Jane Doe", "avatarUrl": "https://cdn.example.com/avatar.jpg", "channelId": "1234567890", "channelName": "Your Page", "isConnected": true, "connectedAt": "2026-11-01T10:05:00Z", "disconnectedAt": null } ] } ``` #### Errors | Status | errorCode | When | |--------|----------------|-------------------------------------| | 404 | TEAM_NOT_FOUND | No group of yours with this teamId | --- ## Facebook Text Backgrounds ### GET /api/v2/publishing/facebook/text-presets The colour, gradient and illustration backgrounds Facebook offers for text posts. No API key needed, no credits. Send a preset's id as platforms[].textFormatPresetId in Publish a Post — on a Facebook text-only post (no jobId, videoUrl or imageUrls). With an image or a video Facebook drops the background; other networks ignore it. > **Note**: Facebook manages this list and can change it at any time — fetch it rather than hardcoding IDs. It is cached for 6 hours. An id that does not exist is accepted when you publish, but the post fails on Facebook. ```bash curl https://api.sangtao.ai/v2/publishing/facebook/text-presets ``` #### Response Fields | Field | Type | Description | |-------------------|----------|-----------------------------------------------------------------------------| | presets | object[] | The backgrounds. Empty when the list is temporarily unavailable | | presets[].id | string | Send it as platforms[].textFormatPresetId in Publish a Post | | presets[].label | string | Background name, in English, as Facebook names it | | presets[].kind | string | color (solid colour), gradient, or illustration | | presets[].color | string? | A CSS value to draw the preview with: rgb(…) for color, linear-gradient(…) for gradient. Can be null for any kind — Facebook gives the colour of only a few backgrounds (4 of 74 today), never of an illustration — draw an empty swatch or just the label when null | If the list is temporarily unavailable you still get 200, with presets: [] — post without a background, or try again later. ```json { "success": true, "data": { "presets": [ { "id": "1881421442117417", "label": "Black", "kind": "color", "color": "rgb(0, 0, 0)" }, { "id": "433967226963128", "label": "Purple 2", "kind": "color", "color": null }, { "id": "249307305544279", "label": "Red to blue gradient", "kind": "gradient", "color": "linear-gradient(45deg, rgb(255, 0, 71), rgb(44, 52, 199))" }, { "id": "200521337465306", "label": "Flame emojis", "kind": "illustration", "color": null } ] } } ``` --- ## TikTok Music ### GET /api/v2/publishing/tiktok/music Trending tracks from TikTok's commercial music library, for TikTok photo posts (carousels). No API key needed, no credits. Send a track's songClipId as platforms[].musicSoundId in Publish a Post. Leave it out and TikTok picks the music itself; video posts and other networks ignore it. > **Note**: The list is cached for 1 hour. ```bash curl "https://api.sangtao.ai/v2/publishing/tiktok/music?genre=POP&dateRange=7DAY" ``` #### Query Parameters | Parameter | Type | Required | Description | |-----------|--------|----------|----------------------------------------------------------------------| | genre | string | No | Music genre. Default POP; the valid values come back in genres | | dateRange | string | No | Trending over the last day, 7 days or 30 days: 1DAY, 7DAY, 30DAY. Default 7DAY | #### Response Fields | Field | Type | Description | |----------------------|----------|----------------------------------------------------------| | genre | string | The genre of this list | | dateRange | string | The date range of this list | | tracks | object[] | The tracks | | tracks[].songClipId | string | Send it as platforms[].musicSoundId in Publish a Post | | tracks[].title | string? | Track title | | tracks[].artist | string? | Artist | | tracks[].thumbnailUrl| string? | Cover image | | tracks[].previewUrl | string? | Link to a preview to listen to | | tracks[].durationSec | number? | Length, in seconds | | tracks[].genres | string[] | Its genres | | tracks[].rank | string? | Its trending rank | | genres | string[] | Valid values of the genre query, to build a filter | | dateRanges | string[] | Valid values of the dateRange query, to build a filter | ```json { "success": true, "data": { "genre": "POP", "dateRange": "7DAY", "tracks": [ { "songClipId": "7301234567890123456", "title": "Song title", "artist": "Artist", "thumbnailUrl": "https://…", "previewUrl": "https://…", "durationSec": 60, "genres": ["POP"], "rank": "1" } ], "genres": ["ROCK", "POP", "LATIN", "…"], "dateRanges": ["1DAY", "7DAY", "30DAY"] } } ``` #### Errors | Status | errorCode | When | |--------|--------------------|------------------------------------------| | 400 | INVALID_GENRE | genre is not one of the values in genres | | 400 | INVALID_DATE_RANGE | dateRange is not 1DAY, 7DAY or 30DAY | | 404 | NO_PUBLISHING_TEAM | The system has no publishing group yet (hardly ever happens) | --- ## Publish a Post ### POST /api/v2/publishing/posts Publish a video, images or text alone to one or more networks, now or at a scheduled time. > **Note**: 202 means accepted, not published yet. Uploading and posting run in the background — from tens of seconds to a few minutes, depending on the network and the video. Follow it with GET /api/v2/publishing/posts/{id} (Get a Post). #### Body | Parameter | Type | Required | Description | |--------------|---------------|----------|---------------------------------------------------------------------------------------------------| | teamId | string (guid) | Yes | The group to publish from — its connected accounts are used | | platforms | object[] | Yes | One item per network to publish to, at least one — fields in the next table | | jobId | string | No | A finished video job of yours on sangtao.ai: its video (and cover, if thumbnailUrl is left out) is posted. Takes priority over videoUrl | | videoUrl | string | No | Public URL of the video — it must download without login and not expire before it is posted | | imageUrls | string[] | No | Public image URLs, up to 35; each network takes only as many as it allows (TikTok 35, Instagram 20, Threads 20, Facebook 10, X 4) | | thumbnailUrl | string | No | Cover image for YouTube, Facebook and Instagram. TikTok takes a frame of the video instead — see coverTimeMs | | coverTimeMs | number | No | TikTok video: the moment of the video used as its cover, in milliseconds, 0–600000. Left out: the start | | scheduleAt | string | No | When to publish (ISO 8601, UTC). Left out: right away | | firstComment | string | No | A comment posted automatically once the post is up. Facebook only for now | | idempotencyKey | string | No | Sent again with the same key and body within 24 hours: the same post back (idempotentReplay: true), not published or charged twice. Another body with the same key: 422 IDEMPOTENCY_KEY_REUSED. Or send it as the Idempotency-Key header | | webhookUrl | string | No | A public https URL (port 443) to POST this post's result to when it is done — see 15. Webhooks. A bad URL: 422 INVALID_WEBHOOK_URL, before the post is made or charged | | webhookSecret | string | No | Signs those deliveries (X-Sangtao-Signature). Up to 200 characters. Without it they come unsigned | Send one of jobId, videoUrl or imageUrls — or none of them for a text-only post (see Text-only Posts). #### platforms[] Items | Parameter | Type | Required | Description | |--------------------|--------|----------|------------------------------------------------------------------------------------------------| | platform | string | Yes | The network, case-insensitive: TikTok, YouTube, Instagram, Facebook, Threads, Twitter. X is accepted for Twitter. It must be connected in the group | | caption | string | No | Post text — TikTok, Facebook, Instagram, Threads, X. Required on every platform of a text-only post | | title | string | No | Video title — YouTube | | description | string | No | Video description — YouTube | | type | string | No | Post type, to override the default (see Default Post Type): POST, REEL, STORY, SHORT, VIDEO, IMAGE | | privacy | string | No | TikTok: PUBLIC_TO_EVERYONE, SELF_ONLY, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR. YouTube: PUBLIC, PRIVATE, UNLISTED. Left out: public | | textFormatPresetId | string | No | Facebook text-only posts (no jobId, videoUrl or imageUrls): a coloured background behind the text. Left out: a plain text post. With an image or a video Facebook drops the background; other networks ignore it. IDs from GET /publishing/facebook/text-presets (Facebook Text Backgrounds). An unknown ID is accepted here and fails when posting | | musicSoundId | string | No | TikTok image posts: the songClipId of a track from GET /publishing/tiktok/music (TikTok Music). Left out: TikTok picks one. Ignored for video posts and other networks | #### Text-only Posts Leave out jobId, videoUrl and imageUrls to post text alone. Then every platform must be Facebook, X (Twitter) or Threads — TikTok, YouTube and Instagram need a video or images (400 VALIDATION_ERROR names them). Each platform needs a caption; type is POST or left out. On Facebook, textFormatPresetId adds a coloured background; without it the post is plain text. Priced as any other post. ```json { "teamId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "platforms": [ { "platform": "Facebook", "type": "POST", "caption": "Nội dung bài", "textFormatPresetId": "249307305544279" }, { "platform": "X", "caption": "Nội dung bài" } ] } ``` #### Default Post Type | Network | Video | Images only | Text only | |-------------|-------|-------------|-----------| | TikTok | VIDEO | IMAGE | — | | YouTube | VIDEO | — | — | | Instagram | REEL | POST | — | | Facebook | REEL | POST | POST | | Threads | POST | POST | POST | | Twitter (X) | POST | POST | POST | Each network sets its own limits — video length, aspect ratio, caption length. They are not checked before posting; a post that breaks one fails on that network, with the reason in platforms[].error. ```bash curl -X POST https://api.sangtao.ai/v2/publishing/posts \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "teamId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "videoUrl": "https://cdn.example.com/video.mp4", "thumbnailUrl": "https://cdn.example.com/cover.jpg", "platforms": [ { "platform": "TikTok", "caption": "Iced coffee in 3 steps #coffee", "privacy": "PUBLIC_TO_EVERYONE" }, { "platform": "YouTube", "title": "How to make iced coffee at home", "description": "A three-step recipe.", "type": "SHORT" } ], "scheduleAt": "2026-11-09T02:00:00Z", "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000" }' ``` #### Response Fields (202 Accepted) | Field | Type | Description | |---------------|---------------|--------------------------------------------------| | postId | string (guid) | The post's ID — follow it with Get a Post | | status | string | Pending: accepted and queued | | statusMessage | string? | A short note on the status | | idempotentReplay | boolean | Only there (true) when this answers a repeated idempotencyKey: the post was made earlier | ```json { "success": true, "data": { "postId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "Pending", "statusMessage": "Đang chờ xử lý..." } } ``` #### What a Post Costs One call to this endpoint is one post, however many networks it goes to. Scheduled posts are paid for when you make the call. | Billed in | How | |--------------|-------------------------------------------------------------------------------------------------------| | Credits | A fixed number of credits per post is locked when the call is accepted (402 INSUFFICIENT_CREDITS if your balance is short). It is charged once the post has been sent off for publishing — even if a network later rejects it — and released if it fails before that, or if you delete it while still Pending. | | Monthly plan | Each post uses one from the plan's monthly allowance (plus any posts bought on top). Failed and cancelled posts are not counted. When none are left: 400 QUOTA_EXCEEDED. | #### Errors | Status | errorCode | When | |--------|----------------------|--------------------------------------------------------------------------------------------------| | 400 | VALIDATION_ERROR | The body is empty, platforms is missing or empty, a platform name is unknown, coverTimeMs is out of range — or, with no jobId / videoUrl / imageUrls: TikTok, YouTube or Instagram is in platforms (they need media; error names them), a platform has no caption, or type is not POST | | 400 | JOB_NOT_READY | The job's video has not finished rendering | | 400 | NO_CONNECTED_ACCOUNT | A network in platforms is not connected in the group — connect it on sangtao.ai first | | 400 | QUOTA_EXCEEDED | Monthly publishing plan: no posts left this period | | 402 | INSUFFICIENT_CREDITS | Not enough credits for the post | | 403 | NO_API_ACCESS | Monthly publishing plan without API access | | 404 | TEAM_NOT_FOUND | No group of yours with this teamId | | 404 | JOB_NOT_FOUND | No job of yours with this jobId | | 422 | IDEMPOTENCY_KEY_REUSED | This idempotencyKey was already used for another body — use a new key | | 409 | ACCOUNT_NEEDS_ATTENTION | The posting page of a network changed or was lost — pick the page again on sangtao.ai; nothing was posted or charged. details[0].params: platform, reason | | 422 | INVALID_WEBHOOK_URL | webhookUrl is not a public https URL on port 443 — nothing was posted or charged | | 500 | NO_SUBSCRIPTION | A setup problem on our side, not yours — contact support | | 502 | PUBLISH_FAILED | Sending the post to the networks failed — try again | --- ## List Posts ### GET /api/v2/publishing/posts Your posts, newest first, filtered by group, status, date or title. #### Query Parameters | Parameter | Type | Required | Description | |-----------|---------------|----------|-----------------------------------------------------------------------------| | teamId | string (guid) | No | Only this group's posts | | status | string | No | Only posts in this status, case-insensitive (see Post Statuses). An unknown value is ignored | | from | string | No | Created at or after (ISO 8601) | | to | string | No | Created at or before (ISO 8601) | | search | string | No | Text the post title contains | | page | number | No | Page number, from 1. Default 1 | | pageSize | number | No | Posts per page, 1–50. Default 20 | ```bash curl "https://api.sangtao.ai/v2/publishing/posts?teamId=3fa85f64-5717-4562-b3fc-2c963f66afa6&status=PartiallyPublished&page=1&pageSize=20" \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Response Fields | Field | Type | Description | |----------|----------|---------------------------------------------------------------------------------------------------| | total | number | Posts matching the filters, across all pages | | page | number | This page | | pageSize | number | Posts per page, after the 1–50 limit | | items | object[] | The posts, newest first — fields as in Get a Post, except platforms[] carries no caption, title or description | ```json { "success": true, "data": { "total": 1, "page": 1, "pageSize": 20, "items": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "teamId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "teamName": "Main channels", "webJobId": null, "title": "How to make iced coffee at home", "thumbnailUrl": "https://cdn.example.com/cover.jpg", "status": "PartiallyPublished", "statusMessage": null, "scheduledAt": null, "createdAt": "2026-11-08T02:00:00Z", "platforms": [ { "id": "pp_3kT9qPz0B", "platform": "TikTok", "postType": null, "status": "Published", "externalUrl": "https://www.tiktok.com/@yourchannel/video/1234567890", "error": null, "publishedAt": "2026-11-08T02:03:00Z" }, { "id": "pp_9pW4nKt1H", "platform": "YouTube", "postType": "SHORT", "status": "Failed", "externalUrl": null, "error": "The video is too long for Shorts", "publishedAt": null } ] } ] } } ``` --- ## Get a Post ### GET /api/v2/publishing/posts/{id} One post and how it went on each network. > **Tip**: Poll every 10–15 seconds, and stop once status is final: Published, PartiallyPublished, Failed or Cancelled. PartiallyPublished is normal when posting to several networks — read platforms[].error for why one failed, then retry it. ```bash curl https://api.sangtao.ai/v2/publishing/posts/7c9e6679-7425-40de-944b-e07fc1f90ae7 \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Response Fields | Field | Type | Description | |---------------|---------------|---------------------------------------------------------------------------------------| | id | string (guid) | The post's ID | | teamId | string (guid) | The group it was published from | | teamName | string | That group's name | | webJobId | string? | The sangtao.ai job posted, when it was published with jobId | | title | string? | Display title: the YouTube title, else the first platform's caption | | thumbnailUrl | string? | Cover image | | status | string | The post as a whole (see Post Statuses). Final: Published, PartiallyPublished, Failed, Cancelled | | statusMessage | string? | A short note on the status | | scheduledAt | string? | The scheduled time (UTC), if scheduled | | createdAt | string | When the post was created (ISO 8601, UTC) | | platforms | object[] | One per network — fields in the next table | | webhook | object | Only when it was published with webhookUrl: how its latest delivery went (url, status, attemptCount, lastHttpStatus, lastError, deliveryId) — as on 9. Job Polling | #### platforms[] Items | Field | Type | Description | |-------------|---------|------------------------------------------------------------------------------------------| | id | string | This network's entry in the post, pp_…. An opaque string | | platform | string | The network: TikTok, YouTube, Instagram, Facebook, Threads, Twitter | | postType | string? | The type you sent (POST, REEL, STORY, SHORT, VIDEO, IMAGE); null when the default was used | | status | string | This network's status: Draft, Uploading, Scheduled, Publishing, Published, Failed, Cancelled, Retrying | | caption | string? | Post text sent | | title | string? | Title sent (YouTube) | | description | string? | Description sent (YouTube) | | externalUrl | string? | Link to the live post, once published | | error | string? | Why it failed, when Failed | | publishedAt | string? | When it went live (ISO 8601, UTC) | #### Post Statuses Pending, Processing, Draft, Uploading, Scheduled, Publishing, Published, PartiallyPublished, Failed, Cancelled ```json { "success": true, "data": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "teamId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "teamName": "Main channels", "webJobId": null, "title": "How to make iced coffee at home", "thumbnailUrl": "https://cdn.example.com/cover.jpg", "status": "PartiallyPublished", "statusMessage": null, "scheduledAt": null, "createdAt": "2026-11-08T02:00:00Z", "platforms": [ { "id": "pp_3kT9qPz0B", "platform": "TikTok", "postType": null, "status": "Published", "caption": "Iced coffee in 3 steps #coffee", "title": null, "description": null, "externalUrl": "https://www.tiktok.com/@yourchannel/video/1234567890", "error": null, "publishedAt": "2026-11-08T02:03:00Z" }, { "id": "pp_9pW4nKt1H", "platform": "YouTube", "postType": "SHORT", "status": "Failed", "caption": null, "title": "How to make iced coffee at home", "description": "A three-step recipe.", "externalUrl": null, "error": "The video is too long for Shorts", "publishedAt": null } ] } } ``` #### Errors | Status | errorCode | When | |--------|----------------|-------------------------------| | 404 | POST_NOT_FOUND | No post of yours with this id | --- ## Retry a Post ### POST /api/v2/publishing/posts/{id}/retry Publishes again to the networks whose status is Failed; the ones already published are left alone. No body. A retry is not charged as a new post. ```bash curl -X POST https://api.sangtao.ai/v2/publishing/posts/7c9e6679-7425-40de-944b-e07fc1f90ae7/retry \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Response Fields | Field | Type | Description | |------------------|----------|-------------------------------------------------------------------------------| | status | string | The post's status after the retry started (see Post Statuses) | | retriedPlatforms | string[] | The networks being retried — those that were Failed; they are now Retrying | ```json { "success": true, "data": { "status": "Publishing", "retriedPlatforms": ["YouTube"] } } ``` #### Errors The three 400s carry their reason in error, with no errorCode. | Status | errorCode | When | |--------|--------------|-----------------------------------------------------------------------------------------| | 400 | — | error "no_failed_platforms": no network of the post has failed | | 400 | — | error "missing_external_post": the post never got as far as publishing — publish it again instead | | 400 | — | error "retry_failed": the retry could not be started — try again later | | 404 | POST_NOT_FOUND | No post of yours with this id | --- ## Delete a Post ### DELETE /api/v2/publishing/posts/{id} Delete a post from your sangtao.ai history. It stays up on the networks. > **Warning**: Deletes the post from your history here only — what was already published stays on the networks; take it down there yourself if you need to. A post deleted while still Pending is cancelled before anything is sent, and its credits are released. Otherwise what it was charged is not refunded. ```bash curl -X DELETE https://api.sangtao.ai/v2/publishing/posts/7c9e6679-7425-40de-944b-e07fc1f90ae7 \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Response ```json { "success": true, "data": { "deleted": true } } ``` #### Errors | Status | errorCode | When | |--------|----------------|-------------------------------| | 404 | POST_NOT_FOUND | No post of yours with this id | --- # 12. User Assets ## Overview Upload and manage user media assets (videos, images, audio). Assets are used as intro/outro videos, logos, watermarks, background music, voiceovers, and thumbnails in video creation. Files here are kept until you delete them, within each category's limits. Only need an image as a reference for the next image you create? Use section 8 (Image Upload) — temporary, deleted after 1 day. ## Upload Flow ``` 1. POST /user-assets/presign -> uploadUrl, method, headers, fileKey 2. PUT uploadUrl (the file) -> with exactly those headers 3. POST /user-assets/confirm -> fileKey: the file becomes an asset ``` > **Note**: The presign response includes category, maxSizeBytes, and allowedContentTypes — use these for client-side validation before uploading. ## Step 1: Presign ### POST /api/v2/user-assets/presign | Parameter | Type | Required | Description | |-------------|--------|----------|------------------------------------------------| | fileName | string | Yes | Original file name (e.g. "logo.png") | | contentType | string | Yes | MIME type (e.g. "image/png", "video/mp4") | ```bash curl -X POST https://api.sangtao.ai/v2/user-assets/presign \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fileName": "my-logo.png", "contentType": "image/png"}' ``` ### Response ```json { "success": true, "data": { "uploadUrl": "https://…/user-assets/1e65….png?sv=…&sig=…", "method": "PUT", "headers": { "Content-Type": "image/png", "x-ms-blob-type": "BlockBlob" }, "url": "https://cdn.sangtao.ai/user-assets/1e65….png", "fileKey": "d15ed…/user-assets/1e65….png", "expiresIn": 600, "expiresAt": "2026-06-08T12:30:00Z", "category": "image", "maxSizeBytes": 52428800, "allowedContentTypes": ["image/png", "image/jpeg", "image/webp", "image/svg+xml", "image/gif"] } } ``` | Field | Type | Description | |---------------------|----------|-------------------------------------------------------------------| | uploadUrl | string | Where to send the file (step 2). Write access to this one file only; no X-Api-Key needed | | method | string | The method for that request: "PUT" | | headers | object | Send exactly these headers with the file (Content-Type, and x-ms-blob-type when it is there) — read them from the response, do not write them in your code | | url | string | Where the file can be read once uploaded | | fileKey | string | The file's key — send it to confirm (step 3) | | expiresIn | number | Seconds until uploadUrl stops working (600). Use this one | | expiresAt | string | The same moment as a UTC time (ISO 8601) — kept for compatibility | | category | string | Category worked out from contentType: video, image, audio, document | | maxSizeBytes | number | Largest file allowed for this category, in bytes | | allowedContentTypes | string[] | MIME types this category takes | ## Step 2: Upload the file Send the file to uploadUrl with the method and exactly the headers the presign response gives. Then confirm it with fileKey — until then it is not an asset. ```bash # The method and headers from the presign response above curl -X PUT "UPLOAD_URL" \ -H "Content-Type: image/png" \ -H "x-ms-blob-type: BlockBlob" \ --data-binary @my-logo.png ``` ## Step 3: Confirm ### POST /api/v2/user-assets/confirm | Parameter | Type | Required | Description | |---------------|--------|----------|---------------------------------------------------| | fileKey | string | Yes | The fileKey presign gave back | | fileName | string | Yes | Original file name | | contentType | string | Yes | MIME type | | fileSizeBytes | number | Yes | File size in bytes | | purpose | string | No | Asset purpose (optional — see purpose table below) | | label | string | No | Display label | | durationSec | number | No | Duration in seconds (video/audio) | | aspectRatio | string | No | Aspect ratio (e.g. "16:9") | ```bash curl -X POST https://api.sangtao.ai/v2/user-assets/confirm \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fileKey": "d15ed…/user-assets/1e65….png", "fileName": "my-logo.png", "contentType": "image/png", "fileSizeBytes": 245000, "purpose": "logo" }' ``` Response: the asset (fields under Response Fields below). ```json { "success": true, "data": { "id": "ua_3kT9qPz0B", "url": "https://blob.sangtao.ai/user-assets/abc123/my-logo.png", "fileName": "my-logo.png", "mimeType": "image/png", "fileSizeBytes": 245000, "category": "image", "purpose": "logo", "label": "My Brand Logo", "thumbnailUrl": null, "durationSec": null, "aspectRatio": null, "hasAudio": false, "createdAt": "2026-06-08T10:30:00Z" } } ``` ## List Assets ### GET /api/v2/user-assets | Parameter | Type | Required | Description | |-----------|--------|----------|--------------------------------------------------------| | category | string | No | Filter by category: video, image, audio, document | | purpose | string | No | Filter by purpose (see purpose table) | | page | int | No | Page number, from 1. Leave out page and pageSize to get everything | | pageSize | int | No | Items per page. Default 50, at most 200. limit is accepted as the same thing | The total, across all pages, is in the `X-Total-Count` header. ```bash curl "https://api.sangtao.ai/v2/user-assets?purpose=logo" \ -H "X-Api-Key: YOUR_API_KEY" ``` ### Response ```json { "success": true, "data": [ { "id": "ua_3kT9qPz0B", "url": "https://blob.sangtao.ai/user-assets/abc123/my-logo.png", "fileName": "my-logo.png", "mimeType": "image/png", "fileSizeBytes": 245000, "category": "image", "purpose": "logo", "label": "My Brand Logo", "thumbnailUrl": null, "durationSec": null, "aspectRatio": null, "hasAudio": false, "createdAt": "2026-06-08T10:30:00Z" } ] } ``` ## Response Fields | Field | Type | Required | Description | |---------------|---------|----------|----------------------------------------------------------| | id | string | Yes | Asset ID, ua_ + 9 letters/digits (e.g. "ua_3kT9qPz0B") | | url | string | Yes | Public URL of the asset | | fileName | string | Yes | Original file name | | mimeType | string | Yes | MIME type (e.g. "video/mp4") | | fileSizeBytes | number | Yes | File size in bytes | | category | string | Yes | Auto-detected: video, image, audio, document | | purpose | string | Yes | Asset purpose | | label | string | No | Display label (nullable) | | thumbnailUrl | string | No | Thumbnail URL (nullable) | | durationSec | number | No | Duration in seconds (video/audio) | | aspectRatio | string | No | Aspect ratio | | hasAudio | boolean | No | Whether file has audio track (video/audio) | | createdAt | string | No | ISO 8601 creation timestamp | ## Get Asset by ID ### GET /api/v2/user-assets/{id} `{id}` is the asset id (e.g. `ua_3kT9qPz0B`). Old numeric ids are still accepted. ```bash curl https://api.sangtao.ai/v2/user-assets/ua_3kT9qPz0B \ -H "X-Api-Key: YOUR_API_KEY" ``` Response: the asset (fields under Response Fields). ```json { "success": true, "data": { "id": "ua_3kT9qPz0B", "url": "https://blob.sangtao.ai/user-assets/abc123/my-logo.png", "fileName": "my-logo.png", "mimeType": "image/png", "fileSizeBytes": 245000, "category": "image", "purpose": "logo", "label": "My Brand Logo", "thumbnailUrl": null, "durationSec": null, "aspectRatio": null, "hasAudio": false, "createdAt": "2026-06-08T10:30:00Z" } } ``` ## Update Asset ### PUT /api/v2/user-assets/{id} `{id}` is the asset id (e.g. `ua_3kT9qPz0B`). Old numeric ids are still accepted. | Parameter | Type | Required | Description | |-----------|--------|----------|-----------------| | label | string | No | New display label | | purpose | string | No | New purpose | ```bash curl -X PUT https://api.sangtao.ai/v2/user-assets/ua_3kT9qPz0B \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"label": "New Label", "purpose": "watermark"}' ``` Response: the asset, updated (fields under Response Fields). ```json { "success": true, "data": { "id": "ua_3kT9qPz0B", "url": "https://blob.sangtao.ai/user-assets/abc123/my-logo.png", "fileName": "my-logo.png", "mimeType": "image/png", "fileSizeBytes": 245000, "category": "image", "purpose": "watermark", "label": "New Label", "thumbnailUrl": null, "durationSec": null, "aspectRatio": null, "hasAudio": false, "createdAt": "2026-06-08T10:30:00Z" } } ``` ## Delete Asset ### DELETE /api/v2/user-assets/{id} `{id}` is the asset id (e.g. `ua_3kT9qPz0B`). Old numeric ids are still accepted. ```bash curl -X DELETE https://api.sangtao.ai/v2/user-assets/ua_3kT9qPz0B \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "deleted": true } } ``` ## Bulk Delete ### POST /api/v2/user-assets/bulk-delete `ids`: string[] of asset ids (`ua_…`), at most 50. Old numeric ids are still accepted, and can be mixed in. ```bash curl -X POST https://api.sangtao.ai/v2/user-assets/bulk-delete \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ids": ["ua_3kT9qPz0B", "ua_7mR2xLq5D", "ua_9pW4nKt1H"]}' ``` ```json { "success": true, "data": { "deleted": 3 } } ``` | Field | Type | Description | |---------|--------|------------------------------------------------------------------------------| | deleted | number | How many assets were deleted. Ids that are not yours are skipped; none of them yours → 404 | ## Storage Usage ### GET /api/v2/user-assets/usage ```bash curl https://api.sangtao.ai/v2/user-assets/usage \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "video": { "count": 3, "quotaCount": 20, "usedBytes": 150000000, "quotaBytes": 2147483648 }, "image": { "count": 12, "quotaCount": 100, "usedBytes": 8500000, "quotaBytes": 524288000 }, "audio": { "count": 2, "quotaCount": 30, "usedBytes": 25000000, "quotaBytes": 524288000 }, "document": { "count": 0, "quotaCount": 20, "usedBytes": 0, "quotaBytes": 209715200 }, "total": { "usedBytes": 183500000, "quotaBytes": 3405774848, "count": 17 } } } ``` One object per category — video, image, audio, document: | Field | Type | Description | |-----------------|--------|--------------------------------------| | count | number | Assets of this category you have | | quotaCount | number | Most assets allowed in this category | | usedBytes | number | Storage used, in bytes | | quotaBytes | number | Storage allowed, in bytes | Then `total`, the four added up: | Field | Type | Description | |------------|--------|-------------------------------------------------------------------------------| | count | number | Assets you have, all categories | | usedBytes | number | Storage used, all categories, in bytes | | quotaBytes | number | Storage allowed for the four categories added up, in bytes. Limits still apply per category | ## Purpose Values | Purpose | Used for | |------------------|-------------------------------------------------------| | outro_video | Appended at end of rendered videos | | intro_video | Prepended at start of rendered videos | | logo | Overlay logo on video during render | | watermark | Watermark overlay in render pipeline | | background_music | Custom background music for all video types | | voiceover | User-uploaded voice file (replaces TTS) | | thumbnail | Custom thumbnail for published jobs | | general | General reference files | ## Category Limits | Category | Content Types | Max Size | Max Items | |----------|------------------------------------------|----------|-----------| | video | video/mp4, video/webm | 500 MB | 20 | | image | image/png, jpeg, webp, svg+xml, gif | 50 MB | 100 | | audio | audio/mpeg, wav, ogg, aac | 100 MB | 30 | | document | application/pdf, text/csv, application/json | 50 MB | 20 | ## Error Codes | Status | errorCode | When | |--------|-------------------------|----------------------------------------------------------------------| | 400 | UNSUPPORTED_FILE_TYPE | presign / confirm: the content type is not allowed for the category (see Category Limits) | | 400 | VALIDATION_ERROR | confirm: fileKey is missing; bulk-delete: ids missing or empty, or more than 50 | | 400 | UPLOAD_FAILED | confirm: the uploaded file could not be confirmed (storage error) — try again | | 404 | ASSET_NOT_FOUND | GET / PUT / DELETE /user-assets/{id}: asset not found or doesn't belong to user; bulk-delete: none of the ids is yours | | 409 | ASSET_ALREADY_CONFIRMED | confirm: this fileKey has already been confirmed | | 429 | QUOTA_EXCEEDED | presign / confirm: the category's file count or storage cap is reached | | 500 | UPLOAD_FAILED | presign: the upload link could not be created (storage error) — try again | --- # 13. Characters ## Overview A character is a reusable profile for making videos, so one character keeps the same look across scenes and across videos. A profile holds a name, an appearance description, a biography, other names, and up to 3 reference images. It is used in characterIds of Motion compose and Video Studio. > **Note**: The Characters endpoints cost no credits. An error has success: false, an errorCode (listed under each endpoint below), and error is a sentence saying what went wrong. ## Character Object | Field | Type | Description | |-----------------------|----------|-------------------------------------------------------------------------------------| | id | string | The character's id, ch_…. Use it in characterIds when creating a video | | name | string | Name, required, up to 100 characters | | appearanceDescription | string? | What the character looks like, so the AI draws it right — up to 1,000 characters. Best written in English, short, as a list of features | | biography | string? | Background or personality, which helps the AI place the character in the right scenes. Up to 2,000 characters | | aliases | string? | Other names, separated by commas, up to 200 characters. Helps the AI recognise the character when the story calls it something else | | publishStatus | string? | Community sharing status: null, "pending", "approved", "rejected" | | createdAt | string | When it was created (ISO 8601, UTC) | | updatedAt | string | When it was last changed (ISO 8601, UTC) | | refImages | object[] | Up to 3 reference images, in sortOrder order — fields in the next table | ### refImages[] Items | Field | Type | Description | |--------------|---------|--------------------------------------------------------------------------------| | id | string | The image's id, ci_… — the imageId of Delete a Reference Image | | imageUrl | string | URL of the image | | thumbnailUrl | string? | URL of a smaller copy. Can be null for a few seconds after the image is added | | sortOrder | number | Position among the character's images | A character can be used when creating a video if it has at least one of: a reference image, or an appearanceDescription. ## Using Characters When Creating a Video Pass their ids in characterIds — up to 5 characters for Motion. An id in the wrong format gets 400. The id of a character that is not yours gets 404 CHARACTER_NOT_FOUND (Motion), or is ignored (Video Studio). ## List Characters ### GET /api/v2/characters Your characters, most recently edited first. An array of Character. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | page | int | No | Page number, from 1. Leave out page and pageSize to get everything | | pageSize | int | No | Items per page. Default 50, at most 200. limit is accepted as the same thing | The total, across all pages, is in the `X-Total-Count` header. ```bash curl https://api.sangtao.ai/v2/characters \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "success": true, "data": [ { "id": "ch_3kT9qPz0B", "name": "Mèo Mướp", "appearanceDescription": "orange tabby cat, chubby body, half-closed drowsy eyes, white belly patch", "biography": "Chú mèo lười của tiệm sách, hay ngủ trên quầy thu ngân.", "aliases": "Mướp, Bé Mướp", "publishStatus": null, "createdAt": "2026-11-08T09:12:44Z", "updatedAt": "2026-11-08T09:20:01Z", "refImages": [ { "id": "ci_8hQ2mZr4K", "imageUrl": "https://…/a.png", "thumbnailUrl": "https://…/a_thumb.webp", "sortOrder": 0 } ] } ] } ``` --- ## Get a Character ### GET /api/v2/characters/{id} Get one of your characters. Returns one Character. ```bash curl https://api.sangtao.ai/v2/characters/ch_3kT9qPz0B \ -H "X-Api-Key: YOUR_API_KEY" ``` #### Errors | Status | errorCode | When | |--------|---------------------|----------------------------------------| | 404 | CHARACTER_NOT_FOUND | No such character, or it is not yours | --- ## Create a Character ### POST /api/v2/characters Create a character, to keep the same look across scenes and videos. #### Body | Parameter | Type | Required | Description | |-----------------------|--------|----------|-------------------------| | name | string | Yes | Up to 100 characters | | appearanceDescription | string | No | Up to 1,000 characters | | biography | string | No | Up to 2,000 characters | | aliases | string | No | Up to 200 characters | ```bash curl -X POST https://api.sangtao.ai/v2/characters \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Mèo Mướp", "appearanceDescription": "orange tabby cat, chubby body", "aliases": "Mướp, Bé Mướp" }' ``` #### Response The Character just created, with no images yet. Add images with the two reference image endpoints: Upload a Reference Image, or Add a Reference Image from a Link. ```json { "success": true, "data": { "id": "ch_3kT9qPz0B", "name": "Mèo Mướp", "appearanceDescription": "orange tabby cat, chubby body", "biography": null, "aliases": "Mướp, Bé Mướp", "publishStatus": null, "createdAt": "2026-11-08T09:12:44Z", "updatedAt": "2026-11-08T09:12:44Z", "refImages": [] } } ``` #### Errors | Status | errorCode | When | |--------|---------------------|----------------------------------------------------------| | 400 | VALIDATION_ERROR | Invalid data — name missing, or a field over its limit | --- ## Update a Character ### PUT /api/v2/characters/{id} Change a character's name, appearance, biography and other names — all at once. Same body as Create a Character. > **Warning**: Replaces everything: a field you leave out becomes null. To keep a value, send it again. Reference images are not changed. ```bash curl -X PUT https://api.sangtao.ai/v2/characters/ch_3kT9qPz0B \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Mèo Mướp", "appearanceDescription": "orange tabby cat, chubby body, green eyes", "biography": null, "aliases": "Mướp" }' ``` #### Response The Character after the change. ```json { "success": true, "data": { "id": "ch_3kT9qPz0B", "name": "Mèo Mướp", "appearanceDescription": "orange tabby cat, chubby body, green eyes", "biography": null, "aliases": "Mướp", "publishStatus": null, "createdAt": "2026-11-08T09:12:44Z", "updatedAt": "2026-11-08T09:20:01Z", "refImages": [ { "id": "ci_8hQ2mZr4K", "imageUrl": "https://…/a.png", "thumbnailUrl": "https://…/a_thumb.webp", "sortOrder": 0 } ] } } ``` #### Errors | Status | errorCode | When | |--------|---------------------|----------------------------------------------------------| | 400 | VALIDATION_ERROR | Invalid data — name missing, or a field over its limit | | 404 | CHARACTER_NOT_FOUND | No such character, or it is not yours | --- ## Delete a Character ### DELETE /api/v2/characters/{id} Deletes the character and its reference images. Videos already made are not affected. A Video Studio project that still uses this character treats it as not there. ```bash curl -X DELETE https://api.sangtao.ai/v2/characters/ch_3kT9qPz0B \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "deleted": true } } ``` #### Errors | Status | errorCode | When | |--------|---------------------|----------------------------------------| | 404 | CHARACTER_NOT_FOUND | No such character, or it is not yours | --- ## Upload a Reference Image ### POST /api/v2/characters/{id}/ref-images Upload a reference image to a character: JPEG, PNG or WEBP, up to 10 MB. Sent as multipart/form-data, the file in the field `file`. | Parameter | Type | Required | Description | |-----------|----------------------------|----------|-------------------------------------------| | file | file · multipart/form-data | Yes | The image: JPEG, PNG or WEBP, up to 10 MB | ```bash curl -X POST https://api.sangtao.ai/v2/characters/ch_3kT9qPz0B/ref-images \ -H "X-Api-Key: YOUR_API_KEY" \ -F "file=@meo.png" ``` #### Response The image added: { id, imageUrl, thumbnailUrl, sortOrder } — as the refImages[] items of the Character object. At most 3 images per character. ```json { "success": true, "data": { "id": "ci_8hQ2mZr4K", "imageUrl": "https://…/a.png", "thumbnailUrl": "https://…/a_thumb.webp", "sortOrder": 0 } } ``` #### Errors | Status | errorCode | When | |--------|---------------------|-----------------------------------------------------------------------------------------------| | 400 | VALIDATION_ERROR | No file, not a JPEG, PNG or WEBP, over 10 MB, or the character already has 3 images (the 4th is refused) | | 404 | CHARACTER_NOT_FOUND | No such character, or it is not yours | --- ## Add a Reference Image from a Link ### POST /api/v2/characters/{id}/ref-images/from-url Add a reference image to a character from a public link. | Parameter | Type | Required | Description | |-----------|--------|----------|---------------------------------------------------------------------------------------------------| | imageUrl | string | Yes | A public http or https link to a JPEG, PNG or WEBP image, up to 10 MB. It is downloaded and a copy is kept | ```bash curl -X POST https://api.sangtao.ai/v2/characters/ch_3kT9qPz0B/ref-images/from-url \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"imageUrl": "https://example.com/meo.png"}' ``` #### Response The image added: { id, imageUrl, thumbnailUrl, sortOrder }. thumbnailUrl is null at first, and filled in a few seconds later. ```json { "success": true, "data": { "id": "ci_8hQ2mZr4K", "imageUrl": "https://…/a.png", "thumbnailUrl": null, "sortOrder": 0 } } ``` #### Errors | Status | errorCode | When | |--------|---------------------|-------------------------------------------------------------------| | 400 | VALIDATION_ERROR | No imageUrl, the link is not http/https, it is a file of another account, it is not a JPEG, PNG or WEBP, or the character already has 3 images | | 400 | REFERENCE_IMAGE_UNREACHABLE | The image could not be downloaded from the link (error says why). 400 here; GPT Image answers the same code with 422 | | 400 | UPLOAD_FAILED | The image was downloaded but could not be saved — try again | | 404 | CHARACTER_NOT_FOUND | No such character, or it is not yours | --- ## Delete a Reference Image ### DELETE /api/v2/characters/{id}/ref-images/{imageId} Delete one reference image of a character. imageId is the image's refImages[].id (ci_…). ```bash curl -X DELETE https://api.sangtao.ai/v2/characters/ch_3kT9qPz0B/ref-images/ci_8hQ2mZr4K \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "success": true, "data": { "deleted": true } } ``` #### Errors | Status | errorCode | When | |--------|---------------------|----------------------------------------| | 404 | CHARACTER_NOT_FOUND | No such character, or it is not yours | | 404 | REF_IMAGE_NOT_FOUND | The character has no image with this imageId | --- # 14. Assets & References Public endpoints: no X-Api-Key needed. ## Voices ### GET /api/v2/assets/voices The voices you can use for AI voiceover, in English and Vietnamese (81 today). Send a voice's id as voiceId when creating a video. #### Query Parameters | Param | Type | Description | |----------|--------|-----------------------------------------------------------------------------| | language | string | Only voices that can read this language (e.g. "vi", "en"), voices native to it first. Many voices read several languages — see languages | | gender | string | male or female (case does not matter) | #### Response Fields | Field | Type | Description | |----------------------|----------|----------------------------------------------------------------------| | id | string | The voice's id, e.g. "el-adam" — send it as voiceId | | name | string | Display name | | language | string | The voice's own language, e.g. "vi", "en" | | languages | string[] | Every language the voice can read, its own first | | gender | string? | male, female or neutral | | age | string? | young, middleaged or old | | accent | string? | e.g. "american", "british" (English); "northern", "central", "southern" (Vietnamese) | | useCase | string? | What it suits best, e.g. "narrative_story", "social_media", "conversational", "advertisement" | | description | string? | A short description, in Vietnamese | | previewUrl | string? | A short sample to listen to (mp3); null for a few voices | | creditCostPer1kChars | number | Credits per 1,000 characters read — 30 for every voice | #### Pricing Every voice costs the same: 30 credits per 1,000 characters. #### Errors | Status | errorCode | When | |--------|------------------|---------------------------------------------------------------| | 400 | VALIDATION_ERROR | A query parameter has a value it does not take — error says which | #### Example Response ```json { "success": true, "data": [ { "id": "el-adam", "name": "Adam", "language": "en", "languages": ["en"], "gender": "male", "age": "middleaged", "accent": "american", "useCase": "social_media", "description": "Nam · Trung niên · giọng american · 1 ngôn ngữ", "previewUrl": "https://cdn.sangtao.ai/motion/system/voices/21f3658ec490b2a69e9d610292954dd3.mp3", "creditCostPer1kChars": 30.0 }, { "id": "el-an-nhien", "name": "An Nhiên", "language": "vi", "languages": ["vi"], "gender": "female", "age": "young", "accent": "southern", "useCase": "entertainment_tv", "description": "Nữ · Trẻ · giọng Nam · Giải trí / TV", "previewUrl": "https://cdn.sangtao.ai/motion/system/voices/09f381132574da1bfe36c5481b6cbf7b.mp3", "creditCostPer1kChars": 30.0 } ] } ``` --- ## Background Tracks ### GET /api/v2/assets/background-tracks The background music you can use (45 tracks today). Send a track's id as backgroundMusic.trackId. The whole list comes back — there are no filters. #### Response Fields | Field | Type | Description | |-----------------|---------|---------------------------------------------------------------------| | id | string | The track's id, e.g. "music_acoustic_warm" — send it as backgroundMusic.trackId | | name | string | Display name | | audioUrl | string | The track itself, to listen to (mp3) | | durationSeconds | number? | Length in seconds | | bpm | number? | Tempo, in beats per minute | #### Errors | Status | errorCode | When | |--------|------------------|---------------------------------------------------------------| | 400 | VALIDATION_ERROR | A query parameter has a value it does not take — error says which | #### Example Response ```json { "success": true, "data": [ { "id": "music_acoustic_warm", "name": "Acoustic Warm", "audioUrl": "https://cdn.sangtao.ai/motion/system/audio/5fc464fe045b5e772e78c32a08337fa8134cefd52117938f322f8a15c7b5e15d.mp3", "durationSeconds": 45.02, "bpm": 92.28 }, { "id": "music_bolero_vn", "name": "Bolero Vn", "audioUrl": "https://cdn.sangtao.ai/motion/system/audio/92f8b98a65cfbb0b1c356d26428f5955d0635bda627f440a450c69a86f41d136.mp3", "durationSeconds": 60.12, "bpm": 71.78 } ] } ``` --- # 15. Webhooks Instead of polling, give us a URL and we send an HTTPS POST to it when an image or a video finishes, a post goes out or a posting page needs picking again. An event usually arrives within 10 seconds. Managing endpoints needs a key with the `webhooks:write` scope. ## Two ways to receive | Way | Gets | Set up | |---|---|---| | A. Account endpoint | Every event you subscribed to, from all your images and posts — made through the API, on the website or in a batch — and the videos you make through the API | `POST /webhooks`. You can test it, see each delivery and retry it | | B. Per request | The events of that one image job, video job or post only | `webhookUrl` (and `webhookSecret`) in `POST /images/generations`, `POST /render/video`, `POST /creative-videos/create` or `POST /publishing/posts` | An event goes to both when both apply. An endpoint only gets events that happen after it was created — nothing from before. ## Events | type | When | data | |---|---|---| | `image.completed` | An image job finished | As `GET /images/generations/{jobId}`, plus `source` | | `image.failed` | An image job failed | As `GET /images/generations/{jobId}`, plus `source` | | `video.completed` | A video job made through the API finished: `POST /render/video` or `POST /creative-videos/create` | As `GET /jobs/{jobId}`, plus `source` | | `video.failed` | That video job failed (its credits refunded) | As `GET /jobs/{jobId}`, plus `source` | | `post.published` | A post went out on every network | As `GET /publishing/posts/{id}` | | `post.failed` | A post failed, or went out on only some networks (`data.status` is `PartiallyPublished`) | As `GET /publishing/posts/{id}` | | `social_account.needs_attention` | A posting page changed or was lost, or the account was disconnected: posting to it stops until a page is picked again | An item of `GET /publishing/teams/{teamId}/accounts`, plus `teamId` and `attentionReason` | | `webhook.test` | Sent by `POST /webhooks/{id}/test` only — not subscribed to | `{ "message": "test" }` | `source` says where the image was made: `api`, `web` or `batch`. Images made inside other jobs (a video's frames, for one) send no event. `attentionReason` is `CHANNEL_CHANGED`, `CHANNEL_LOST` or `DISCONNECTED`. ```json { "id": "evt_8Kq2mXa7T", "type": "image.completed", "createdAt": "2026-11-10T09:43:31Z", "apiVersion": "v2", "data": { "jobId": "acff47cbfacf4e27bbc08770c9ea246d", "status": "complete", "resultUrl": "https://cdn.sangtao.ai/…/fa3c91.png", "source": "api" } } ``` ## What a delivery carries ```http POST /webhooks/sangtao HTTP/1.1 Content-Type: application/json User-Agent: sangtao-webhooks/1 X-Sangtao-Event: image.completed X-Sangtao-Delivery: dlv_5Vt1kRm9P X-Sangtao-Timestamp: 1762767811 X-Sangtao-Signature: v1=5f2b1c… ``` | Header / field | Meaning | |---|---| | `X-Sangtao-Event` | The event type | | `X-Sangtao-Delivery` | This delivery, `dlv_…` — a new one on each retry | | `X-Sangtao-Timestamp` | Unix seconds, signed with the body | | `X-Sangtao-Signature` | `v1=`; two of them, comma-separated, for 24 hours after rotating the secret. Absent for way B without `webhookSecret` | | `id` | The event, `evt_…` — the same on every retry: deduplicate on it | | `truncated` | `true` when `data` was over 256 KB: `data` is then just `{ id }` — fetch the rest with GET | ## Checking the signature ```text v1 = hex( HMAC-SHA256( secret, "{X-Sangtao-Timestamp}.{raw body}" ) ) ``` 1. Read the body as raw bytes — parsing and re-serialising the JSON changes it. 2. Refuse a timestamp more than 5 minutes off your clock: that stops replays. 3. Compute the HMAC with the whole secret, `whsec_` included, and compare in constant time against each `v1=` value; one match is enough. ```python import hashlib, hmac, json, os, time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["SANGTAO_WEBHOOK_SECRET"].encode() # whsec_… @app.post("/webhooks/sangtao") def sangtao_webhook(): ts = request.headers.get("X-Sangtao-Timestamp", "") header = request.headers.get("X-Sangtao-Signature", "") if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(400) body = request.get_data() # the raw bytes, before any JSON parsing expected = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest() if not any(hmac.compare_digest(p.strip().removeprefix("v1="), expected) for p in header.split(",")): abort(400) event = json.loads(body) # skip if event["id"] was handled already, else queue the work return "", 200 ``` ## Delivery and retries - Answer 2xx within 10 seconds. Do the work after answering — queue it. - A network error, a timeout, 5xx, 429 or 408 is retried: at once, then after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Any other 4xx stops at once. - Redirects are not followed. - An event can arrive more than once: deduplicate on `id`. - Events can arrive out of order: rely on `data.status` and `createdAt`, not on arrival. - An endpoint that fails for 3 days in a row is turned off (`status` `disabled_failing`) and the account owner gets an email. PATCH it with `enabled: true` to turn it back on. - Deliveries are kept 30 days. ## Nothing arrives? 1. `GET /webhooks/{id}`: is `status` `active`, and is the event in `events`? 2. Did it happen after the endpoint was created? 3. `GET /webhooks/{id}/deliveries`, then `GET /webhooks/deliveries/{deliveryId}`: each attempt's HTTP status, the first 1 KB your URL answered, and `body` — the exact bytes signed, to check your signature code against. 4. `POST /webhooks/{id}/test` sends one now and returns the result. Fixed your URL? Send a failed delivery again with `POST /webhooks/{id}/deliveries/{deliveryId}/retry`. ## Endpoints All need `X-Api-Key`. `{id}` is the endpoint's `whk_…`, `{deliveryId}` a delivery's `dlv_…`. | Method | Path | Does | |---|---|---| | POST | `/webhooks` | Create an endpoint: `{ url, events, description? }` ⇒ 201, with `secret` (`whsec_…`, shown only here) | | GET | `/webhooks` | Your endpoints, oldest first — no secrets | | GET | `/webhooks/{id}` | One endpoint | | PATCH | `/webhooks/{id}` | Change `url`, `events`, `description`, `enabled` — send only what changes | | DELETE | `/webhooks/{id}` | `{ "deleted": true }`; deliveries still waiting are canceled | | POST | `/webhooks/{id}/rotate-secret` | A new `secret` (shown once) and `previousSecretExpiresAt`: the old one keeps signing for 24 hours | | POST | `/webhooks/{id}/test` | Sends `webhook.test` now: `{ deliveryId, eventId, status, httpStatus, durationMs, error }` | | GET | `/webhooks/event-types` | The event types `events` takes | | GET | `/webhooks/{id}/deliveries` | Deliveries, newest first. Query: `status`, `type`, `since`, `until`, `page`, `pageSize` (20, at most 100); total in `X-Total-Count` | | GET | `/webhooks/deliveries/{deliveryId}` | One delivery, with `attempts[]` and the `payload` sent | | POST | `/webhooks/{id}/deliveries/{deliveryId}/retry` | The same event again: a new `dlv_…`, the same `evt_…` ⇒ 202 | Create body: | Field | Type | Required | Description | |---|---|---|---| | `url` | string | yes | A public https URL on port 443, without a user or password in it. Up to 500 characters | | `events` | string[] | yes | The event types to get, or `["*"]` for all | | `description` | string | no | A note for yourself. Up to 200 characters | Endpoint object: `id`, `url`, `events`, `description`, `enabled`, `status` (`active` / `disabled` / `disabled_failing`), `createdAt`, `lastDeliveryAt`, `lastSuccessAt`, `lastFailureAt`. Delivery object: `id`, `eventId`, `eventType`, `status` (`pending` / `succeeded` / `failed` / `canceled`), `attemptCount`, `lastHttpStatus`, `lastError`, `nextAttemptAt` (only while pending), `createdAt`, `completedAt`. A delivery's detail adds `attempts[]` (`at`, `httpStatus`, `durationMs`, `responseSnippet` — the first 1 KB of your answer, `error`), `payload` (the body parsed), `body` (the body exactly as sent — check a signature against this, not against `payload`) and, for way B only, `targetUrl` (the webhookUrl it went to). Errors: 404 `WEBHOOK_NOT_FOUND`, 404 `DELIVERY_NOT_FOUND`, 409 `WEBHOOK_DISABLED` (test or retry on an endpoint that is off), 422 `INVALID_WEBHOOK_URL`, 422 `INVALID_WEBHOOK_EVENT` (allowed types in `details[0].params.allowed`), 422 `WEBHOOK_LIMIT_REACHED` (10 endpoints per account), 422 `VALIDATION_ERROR` (description over 200 characters). --- # End of API Reference