# Asset Library

Video generation and other multimodal endpoints accept references to files you've uploaded to the **Asset Library**. Upload a file once, then use `asset://<ASSET_ID>` anywhere a URL is expected. **The format is identical on both the China and Overseas editions.**

Compared to sending a 1-hour pre-signed URL on every request, asset library references give you:

- Permanent references — drafts won't expire mid-save
- Reuse across generations — same reference image, many requests
- Reuse across models — attach the same asset to different tasks

## Three Steps

### 1. Create an AssetGroup (first time only)

Every asset hangs off an AssetGroup. A single user can own multiple groups.

```bash
curl -X POST https://api.xrtoken.ai/v1/asset-groups \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "Name": "my-assets" }'
```

Response:

```json
{ "GroupId": "asset-group-20260423-abc12", "Name": "my-assets" }
```

Keep the `GroupId` around — you reuse it every time you add an asset.

One API key serving many end-users: add `external_user_id` in the body. See [Third-party SaaS](#external-user-id).

### 2. Get a URL for the file

Two options:

**Option A — upload via our `/v1/files` endpoint** (recommended; OpenAI-compatible):

```bash
curl -X POST https://api.xrtoken.ai/v1/files \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -F 'file=@/path/to/reference.png' \
  -F 'purpose=assistants'
```

Response:

```json
{
  "id": "file-xxxxxxxx",
  "object": "file",
  "url": "https://xrtoken-storage-*.tos-*.bytepluses.com/uploads/<user>/<uuid>/reference.png?X-Tos-...",
  "bytes": 123456,
  "filename": "reference.png",
  "purpose": "assistants"
}
```

The returned `url` is a 24h presigned URL — pass it as the `URL` field in the next step. If you don't register within 24h, fetch a fresh signature via `GET /v1/files/{id}`.

**Option B — pass any publicly-GET-able URL you already host** (must return raw image/video bytes, not HTML).

### 3. Register the URL as an Asset

```bash
curl -X POST https://api.xrtoken.ai/v1/assets \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "GroupId":    "asset-group-20260423-abc12",
    "URL":        "<URL from step 2>",
    "AssetType":  "Image",
    "Name":       "female lead reference",
    "Moderation": { "Strategy": "Skip" }
  }'
```

`AssetType` can be `Image` / `Video` / `Audio`. `Moderation.Strategy=Skip` bypasses upstream content moderation (Overseas only; the field is ignored on the China edition).

Response:

```json
{ "AssetId": "asset-20260423183015-xk4m2", ... }
```

### 4. Reference it from a generation request

Replace `image_url.url` / `video_url.url` / `audio_url.url` with `asset://<AssetId>`; leave everything else unchanged:

```json
{
  "model": "doubao-seedance-2-0-260128",
  "content": [
    { "type": "text", "text": "place the female lead into this scene and have her walk toward camera" },
    {
      "type": "image_url",
      "role": "reference_image",
      "image_url": { "url": "asset://asset-20260423183015-xk4m2" }
    },
    {
      "type": "image_url",
      "role": "reference_image",
      "image_url": { "url": "asset://asset-20260419234137-txt6j" }
    }
  ],
  "duration": 5,
  "ratio": "16:9",
  "resolution": "720p"
}
```

## Where `asset://` works

Only inside URL-shaped fields:

| Field | Purpose |
|---|---|
| `image_url.url` | Reference image (`reference_image`), first frame (`first_frame`), last frame (`last_frame`) |
| `video_url.url` | Reference video (`reference_video`, Seedance 2.0) |
| `audio_url.url` | Reference audio (`reference_audio`, Seedance 2.0) |

Putting `asset://` inside `content[].text` has no effect — it won't be resolved.

## Mixing with raw URLs

Within a single request you can mix `asset://` references and regular `https://` URLs freely, e.g. one reference image from the library, another from a temporary signed URL:

```json
"content": [
  { "type": "text", "text": "..." },
  { "type": "image_url", "role": "reference_image",
    "image_url": { "url": "asset://asset-20260423183015-xk4m2" } },
  { "type": "image_url", "role": "reference_image",
    "image_url": { "url": "https://your-cdn.com/temp/xxx.png" } }
]
```

## Limits

- At most **10 asset references per request** (upstream Ark limit)
- Assets have a **90-day TTL** (upstream Ark limit). Expired `asset://` references fail to resolve — re-register the URL before day 90 for long-lived assets
- Assets are scoped to the **user who created them**. Using someone else's `AssetId` returns 403
- The `asset://` prefix is **only** valid inside URL fields — putting it in `text` does nothing

## Volcengine-native format (drop-in)

If you already integrate the asset library through the official Volcengine Ark SDK / Open API, point the base URL at us and swap the AK/SK signing helper for a Bearer token. Paths, parameters, and response shapes stay identical to the official API:

```bash
# Create asset (official Action=CreateAsset)
curl -X POST "https://api.xrtoken.ai/?Action=CreateAsset&Version=2024-01-01" \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "GroupId": "group-2026**********-*****",
    "URL": "https://example.com/image.jpg",
    "Name": "test",
    "AssetType": "Image",
    "ProjectName": "default"
  }'

# Get asset (official Action=GetAsset)
curl -X POST "https://api.xrtoken.ai/?Action=GetAsset&Version=2024-01-01" \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "Id": "asset-20260423183015-xk4m2" }'
```

`ProjectName` is optional: when omitted, the gateway project (currently `xrtoken`) is used automatically; when explicitly provided (including `default`), the value is passed through as-is. `Name` is optional too. Create, get, list, update, delete, and real-person verification all use the official `POST /?Action=...` path. Responses use the official Volcengine `ResponseMetadata` / `Result` envelope and keep the asset library's tenant isolation — you can only operate on assets under groups you own. See [ARK SDK compatibility](/docs/ark-compatibility) for details.

<a id="external-user-id"></a>

## Third-party SaaS: external_user_id

You serve N end-users behind **one API key**. Pass that user's `external_user_id` on every call so asset groups and verified faces bind to that person. Others under the same key cannot see or use them.

Omit the field and the whole API key shares one pool (our Dashboard uses this).

### Field rules

| Item | Value |
|---|---|
| Name | `external_user_id` |
| Meaning | Your own user id. We store it as an opaque string. |
| Length | Max 128 characters; longer values are truncated |
| POST / PUT | JSON body |
| GET / DELETE | Query: `?external_user_id=...` |
| Auth | Your API key: `Authorization: Bearer tr-xxx` |

**Use the same value on create, poll, list, get, update, and delete.** Passing it when you open a session does **not** attach it to `BytedToken`. We do not store `BytedToken → external_user_id`.

CN host: `https://api.xrtoken.net`. Global: `https://api.xrtoken.ai`. Examples use `$XRT_BASE`.

### Create groups / upload

```bash
curl -X POST "$XRT_BASE/v1/asset-groups" \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "Name": "user-42 assets",
    "external_user_id": "end-user-42"
  }'
```

```bash
curl "$XRT_BASE/v1/asset-groups?external_user_id=end-user-42" \
  -H "Authorization: Bearer $XRT_API_KEY"
```

Uploads take `external_user_id` in the body. `GroupId` must belong to that end-user. Video generation uses `asset://<AssetId>`.

### Real-person scan: poll only (start here)

`external_user_id` on `validate-session` is **not** written onto `BytedToken`. Binding happens when `validate-result` succeeds. **You must send it again on every poll.**

Asset CRUD (create group, upload) does not need the next two items. Real-person scan requires **trusted_creator** plus **enterprise verification** — contact sales; not self-serve.

```bash
curl -X POST "$XRT_BASE/v1/asset-groups/validate-session" \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_user_id": "end-user-42"
  }'
```

Response (top-level or under `Result`):

```json
{
  "BytedToken": "eyJhbGci...",
  "H5Link": "https://h5-v2.kych5.com?...",
  "QRCodeDataURL": "data:image/png;base64,..."
}
```

Put `QRCodeDataURL` in `<img src>`, or open `H5Link`. `BytedToken` lasts about 120 seconds. Poll every 5 seconds until success, failure, or timeout:

```bash
curl -X POST "$XRT_BASE/v1/asset-groups/validate-result" \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bytedToken": "eyJhbGci...",
    "external_user_id": "end-user-42"
  }'
```

| Response | Meaning |
|---|---|
| `GroupId` + `"status": "active"` | Done. Group is bound to `end-user-42`. |
| No `GroupId` | Still scanning. Keep polling. |
| `ResponseMetadata.Error` | Failed. |

Omit `external_user_id` here and the group is bound as `NULL`. A later list with `end-user-42` **will not** see it.

Real-person groups:

```bash
curl "$XRT_BASE/v1/asset-groups?type=real_person&external_user_id=end-user-42" \
  -H "Authorization: Bearer $XRT_API_KEY"
```

First-party scan flow: [Real-person liveness](/docs/realperson-liveness).

### Real-person scan: callback redirect

Use this if you have a landing page after the H5. No webhook to host. Send both fields when you open the session:

```bash
curl -X POST "$XRT_BASE/v1/asset-groups/validate-session" \
  -H "Authorization: Bearer $XRT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://your.app/face-done",
    "external_user_id": "end-user-42"
  }'
```

`callback_url` must be `http` / `https` with a host. Your existing query string is kept.

After the scan, Volcengine hits our `/v1/asset-groups/face-callback` (do not call it yourself). We verify, fetch `GroupId`, bind with the `external_user_id` from create, then **302** to your page:

- Success: `https://your.app/face-done?status=success&group_id=group-...&external_user_id=end-user-42`
- Failure: `https://your.app/face-done?status=failed` (no `group_id`)

Omitting `callback_url` falls back to our dashboard — for first-party playground use.

Callback and poll may write the same group. If you also poll, still send the same `external_user_id`. If you omit it and poll wins the race, the row is `NULL` until callback fills it.

### Do not

| What | What happens |
|---|---|
| Pass the id only on `validate-session`, not on poll | Group bound as `NULL`; list by that id misses it |
| Different ids on create / poll / list | They cannot see each other |
| Put the id in a GET body | GET / DELETE only read the query string |
| List without the id | Isolation is off; you may see other end-users under the key |
| Call `/v1/asset-groups/face-callback` yourself | Public Volcengine redirect target, not a client API |

## Related API

- [Create AssetGroup](/docs/api/createAssetGroup) · `POST /v1/asset-groups`
- [List AssetGroups](/docs/api/listAssetGroups) · `GET /v1/asset-groups`
- [Create Asset](/docs/api/createAsset) · `POST /v1/assets`
- [List Assets](/docs/api/listAssets) · `GET /v1/assets`
- [Delete Asset](/docs/api/deleteAsset) · `DELETE /v1/assets/{id}`
- [Open real-person session](/docs/api/createVisualValidateSession) · `POST /v1/asset-groups/validate-session`
- [Poll real-person result](/docs/api/getVisualValidateResult) · `POST /v1/asset-groups/validate-result`
- [Create Video Generation](/docs/api/createVideoGeneration) · `POST /v1/videos/generations`
