# Tạo ảnh và video: Jobs API

> **TL;DR** — `POST /v2/jobs` với một model, một style và dữ liệu đầu vào của bạn sẽ trả về `job_id` sau khoảng một giây. Wandura tạo kết quả ở nền rồi POST job cuối cùng (có ký) tới `webhook_url` của bạn; `GET /v2/jobs/{job_id}` là đường dự phòng. Key test (`wdr_test_…`) là sandbox miễn phí. Body sai bị từ chối ngay bằng `422` kèm lý do; job `failed`, `timeout`, bị huỷ hoặc bị moderation chặn thì **không bị tính tiền**.

## Cơ bản
- **Base URL**: `https://api.wandura.ai` (vùng Singapore).
- **OpenAPI 3.1**: [api.wandura.ai/v2/openapi.json](https://api.wandura.ai/v2/openapi.json), không cần key. Jobs API là các operation gắn tag `Jobs`.
- **Luồng**: submit kèm `webhook_url` và `Idempotency-Key` → `202 {job_id, status: "queued"}` → Wandura POST job cuối cùng (có ký) tới `webhook_url`, bạn trả `200` → nếu webhook không tới, poll `GET /v2/jobs/{job_id}` → tải `output[0].url`.
- Mọi request là JSON qua HTTPS.

| Endpoint | Làm gì |
|---|---|
| `POST /v2/jobs` | Gửi một job. |
| `GET /v2/jobs/{job_id}` | Trạng thái và output. Cùng body với webhook. |
| `POST /v2/jobs/{job_id}/cancel` | Huỷ job còn `queued`. |
| `GET /v2/models` | Danh sách model. |
| `GET /v2/models/{model_id}/schema` | JSON Schema của body submit cho một model. |
| `GET /v2/styles?model=` | Các style đang chạy, kèm bản xem trước. |

## Xác thực và key
Mọi lời gọi gửi `Authorization: Bearer <key>`. Tạo key ở [console](https://open.wandura.ai/keys); một product có thể có nhiều key và xoay vòng được. Giữ key ở máy chủ của bạn.

| Key | Làm gì | Chi phí |
|---|---|---|
| `wdr_test_…` | **Sandbox.** Cùng API, cùng validation, status và webhook; job trả về một file mẫu thay vì tạo thật | $0 |
| `wdr_live_…` | Tạo thật | Giá của style, chỉ khi job thành công |

Job thuộc về product của key đã gửi nó: key của product khác nhận `404`.

## Model và schema
Hai model, mỗi loại đầu ra một model.

| `model` | Đầu ra |
|---|---|
| `wandura-image-v1` | ảnh |
| `wandura-video-v1` | video |

`v1` trong model id là version của **model**, không phải version của API. Id cố định: thay đổi phá vỡ tương thích sẽ có id mới (`…-v2`), không bao giờ đổi âm thầm. Style được thêm và gỡ dưới một model mà không đổi model.

```bash
curl https://api.wandura.ai/v2/models -H "Authorization: Bearer $WANDURA_KEY"

# JSON Schema (2020-12) của body submit cho một model: kiểu, field bắt buộc, min/max,
# độ dài, danh sách style id, và với mỗi style: field nào bắt buộc, field nào không nhận
curl https://api.wandura.ai/v2/models/wandura-video-v1/schema -H "Authorization: Bearer $WANDURA_KEY"
```

Đọc schema lúc build hoặc theo lịch để dựng form nhập; nó đổi khi style được thêm hoặc gỡ.

## Style
Style là một "kiểu" đã đóng gói sẵn: prompt, các bước tạo và cài đặt của nó nằm ở phía Wandura. `GET /v2/styles?model=wandura-video-v1` liệt kê các style đang chạy mà key của bạn dùng được:

```json
{
  "id": "sty_motion_clip_test", "object": "style", "model": "wandura-video-v1", "name": "Motion Clip",
  "description": "Bring a still image to life with gentle camera motion.",
  "input_hint": "One clear image of the subject you want to bring to life.",
  "preview_url": "https://<wandura-storage-host>/samples/v1/motion-clip.mp4",
  "inputs": { "image_url": "required", "prompt": "optional" },
  "max_job_seconds": 300, "price": { "credits": 23.4, "usd": 0.234 }
}
```

| Field | Ý nghĩa |
|---|---|
| `preview_url` | Link https tới ảnh hoặc video mẫu làm bằng style này. Hiển thị trong màn chọn style của bạn. `null` nếu style chưa có mẫu. |
| `inputs` | Các field của `POST /v2/jobs` mà style này nhận, mỗi field là `required` hoặc `optional`. Field không có trong danh sách sẽ bị từ chối. |
| `max_job_seconds` | Thời gian tối đa của style. Quá mức này job kết thúc `timeout`, không tính tiền. |
| `price` | Giá của một job live **thành công** (1 credit = $0.01). |

## Gửi, poll, huỷ
```bash
curl -X POST https://api.wandura.ai/v2/jobs \
  -H "Authorization: Bearer $WANDURA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: your-job-123" \
  -d '{
    "model": "wandura-video-v1",
    "style": "sty_motion_clip_test",
    "image_url": "https://your-bucket.example.com/u/42/photo.jpg?X-Amz-Signature=...",
    "prompt": "a girl smiling at the camera",
    "webhook_url": "https://your-backend.example.com/hooks/wandura",
    "end_user_id": "user_42"
  }'
# HTTP 202
# X-RateLimit-Limit: 60
# X-RateLimit-Remaining: 59
# X-RateLimit-Reset: 1791897360
# X-Concurrency-Limit: 20
# X-Concurrency-Running: 3
# X-Concurrency-Waiting: 0
# {"job_id":"run_QVqsXwr7VGSP","status":"queued","model":"wandura-video-v1","created_at":"2026-10-13T13:15:40.825Z"}

curl https://api.wandura.ai/v2/jobs/run_QVqsXwr7VGSP -H "Authorization: Bearer $WANDURA_KEY"

curl -X POST https://api.wandura.ai/v2/jobs/run_QVqsXwr7VGSP/cancel -H "Authorization: Bearer $WANDURA_KEY"
```

| Field | Ghi chú |
|---|---|
| `model` | Bắt buộc. `wandura-image-v1` hoặc `wandura-video-v1`. |
| `style` | Bắt buộc. Id style của model đó (`GET /v2/styles`). |
| `prompt` | Đoạn chữ mà style đưa vào kết quả. Tuỳ chọn, trừ khi style liệt kê nó là bắt buộc; `""` được coi như không gửi. |
| `image_url`, `image_urls`, `video_url` | URL https công khai (URL presigned GET dùng được, tối đa 8192 ký tự). Ảnh JPEG, PNG hoặc WEBP tới 20 MB; video MP4, MOV hoặc WEBM tới 50 MB. |
| `negative_prompt`, `aspect_ratio`, `resolution`, `duration`, `seed` | Chỉ cho style có liệt kê trong `inputs`. Xem [Validation](https://docs.wandura.ai/vi/developers/api-v2/jobs#validation). |
| `num_outputs` | Tuỳ chọn, phải là `1`: mỗi job một output. |
| `webhook_url` | Tuỳ chọn. URL https công khai nhận job cuối cùng, có ký. Cần có Jobs API signing secret của product trước (xem [Webhook](https://docs.wandura.ai/vi/developers/api-v2/jobs#webhooks)). |
| `end_user_id` | Tuỳ chọn. Id của người dùng trong hệ thống của bạn; khi gửi, giới hạn theo ngày cho từng người sẽ áp dụng (xem [Giới hạn](https://docs.wandura.ai/vi/developers/api-v2/jobs#limits)). |

- **URL đầu vào được tải khi job bắt đầu chạy**, không phải lúc submit. URL không tải được sẽ làm job `failed`, không tính tiền. Giữ URL presigned còn hạn ít nhất bằng `max_job_seconds` của style cộng 10 phút.
- **Huỷ** được khi job còn `queued`: `200`, status `cancelled`, không tính tiền, và webhook vẫn được gửi. Khi job đã `processing` nó chạy tới cùng, và huỷ trả `409 JOB_NOT_CANCELLABLE`.

## Validation chặt và đồng bộ
Body sai không bao giờ trở thành job. Wandura kiểm tra cả body ngay lúc submit và liệt kê **mọi** lỗi:

```json
HTTP 422
{"error":{"code":"INVALID_INPUT","message":"Validation failed.","retryable":false,"details":[
  {"field":"num_outputs","reason":"must be 1 (one output per job)"},
  {"field":"aspect_ratio","reason":"style sty_motion_clip_test does not take this field"},
  {"field":"image_url","reason":"required by style sty_motion_clip_test"}]}}
```

- **Style tự cố định tỉ lệ khung hình, độ phân giải và thời lượng.** Các style hiện tại không nhận `aspect_ratio`, `resolution`, `duration` hay `seed`, nên gửi một trong số đó sẽ trả `422` kèm lý do. Chỉ gửi những gì `inputs` của style liệt kê, và dùng schema của model để biết đó là gì.
- Field lạ, thiếu field bắt buộc, URL sai định dạng và style không tồn tại đều là `422`.

## Idempotency
Gửi `Idempotency-Key` (1 đến 255 ký tự) ở mọi submit để retry không bao giờ chạy hai lần.

- Cùng key và cùng body: cùng một job (`200`, header `Idempotent-Replayed: true`, status hiện tại của job).
- Cùng key nhưng body khác: `409 IDEMPOTENCY_KEY_REUSED`. Dùng key mới cho job mới.
- Không gửi header: Wandura tự sinh key từ API key và body, nên cùng body trong 10 phút là cùng một job.

## Status
```text
queued → processing → succeeded | failed | cancelled | timeout
```

Bốn status cuối là status cuối cùng và không bao giờ đổi nữa.

| `status` | Cuối | Tính tiền (key live) | `error` |
|---|---|---|---|
| `queued` | không | chưa | `null` |
| `processing` | không | chưa | `null` |
| `succeeded` | có | có, giá của style | `null` |
| `failed` | có | **không** | `GENERATION_FAILED`, `INPUT_UNUSABLE` hoặc `CONTENT_MODERATION_BLOCKED` |
| `timeout` | có | **không** | `TIMEOUT` |
| `cancelled` | có | **không** | `null` |

Lỗi nằm trong một job đã kết thúc (`error.code`):

| `status` | `code` | `retryable` | Ý nghĩa |
|---|---|---|---|
| `failed` | `CONTENT_MODERATION_BLOCKED` | false | Đầu vào hoặc kết quả bị content moderation chặn. Không tính tiền. |
| `failed` | `INPUT_UNUSABLE` | false | Một file đầu vào không dùng được (không tải được, định dạng không hỗ trợ, hoặc quá dài). Không tính tiền. |
| `failed` | `GENERATION_FAILED` | true | Tạo kết quả thất bại ở phía Wandura. Không tính tiền; gửi lại thường là được. |
| `timeout` | `TIMEOUT` | true | Job không xong trong thời gian tối đa của style. Không tính tiền. |

Bị moderation chặn là một job cuối cùng bình thường, không phải lỗi HTTP:

```json
{"job_id":"run_4kT9wQx2LmPa","status":"failed","model":"wandura-video-v1","style":"sty_motion_clip_test","output":null,
 "error":{"code":"CONTENT_MODERATION_BLOCKED","message":"The input or the result was blocked by content moderation. Not charged.","retryable":false},
 "created_at":"2026-10-13T13:12:02.410Z","completed_at":"2026-10-13T13:12:03.902Z"}
```

## Output
`output` là danh sách có **một** file, và là `null` cho tới khi job `succeeded`.

```json
{"job_id":"run_…","status":"succeeded","model":"wandura-image-v1","style":"sty_…",
 "output":[{"url":"https://<wandura-storage-host>/outputs/2026-10-10/<random name>.png","type":"image/png",
   "width":48,"height":32,"duration":null,"fps":null,"size_bytes":136,"seed":null,"expires_at":"2026-10-13T00:00:00.000Z"}],
 "error":null,"created_at":"2026-10-10T00:00:00.000Z","completed_at":"2026-10-10T00:00:00.000Z"}
```

- `url` là link HTTPS trên storage của Wandura (Wandura CDN), tải thẳng bằng GET thường. Đó không phải link của bên thứ ba nào.
- **Lưu 72 giờ.** `expires_at` cho biết chính xác khi nào hết hạn. Hãy chép file về storage của bạn trước đó.
- Metadata đọc từ chính file: `type`, `width`, `height`, `duration` và `fps` (video), `size_bytes`, `seed`. Giá trị không đọc được là `null`.
- File mẫu của key test có metadata `null` và không hết hạn.
- Bản ghi job được giữ lâu hơn 72 giờ nhiều: `GET /v2/jobs/{job_id}` vẫn trả lời cho job cũ.

## Webhook
Mỗi job có `webhook_url` nhận **một** POST khi đạt status cuối cùng (không có webhook trung gian). Body đúng bằng thứ `GET /v2/jobs/{job_id}` trả về tại thời điểm đó.

1. **Tạo Jobs API signing secret của product một lần**: ở [console](https://open.wandura.ai/webhooks), mục "Jobs API signing secret" → Create. Secret chỉ hiện một lần; Roll thay ngay lập tức. Khi chưa có secret, submit kèm `webhook_url` bị từ chối bằng `422`.
2. **Verify mọi request** trên **raw body**: `X-Signature` là HMAC-SHA256 dạng hex của `X-Timestamp + "." + raw body` bằng secret của bạn, và `X-Timestamp` (unix giây) lệch hiện tại không quá 5 phút.
3. **Trả `2xx` trong 10 giây**, rồi mới xử lý việc của bạn. Dedupe theo `job_id` (hoặc header `Wandura-Event-Id`, giống nhau ở mọi lần retry): giao tin ít nhất một lần.

```http
POST /hooks/wandura HTTP/1.1
Content-Type: application/json
User-Agent: Wandura-Webhooks/1
X-Timestamp: 1791897341
X-Signature: 6f1c…(64 hex characters)
Wandura-Event-Id: evt_…
Wandura-Event-Type: job.ended

{"job_id":"run_QVqsXwr7VGSP","status":"succeeded","model":"wandura-video-v1", …}
```

### Chính sách retry
Trả non-2xx (kể cả redirect), timeout hoặc lỗi mạng sẽ được retry sau 30 giây, 2 phút, 10 phút, 1 giờ, 6 giờ và 24 giờ: **7 lần thử trong khoảng 31 giờ**, cùng body và `job_id`. URL có host trỏ về địa chỉ riêng hoặc dành riêng sẽ không bao giờ được gọi. Nếu mọi lần thử đều thất bại, job vẫn còn đó: hãy poll.

### Verify chữ ký
Node.js (Express):

```js
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

// raw = the body exactly as received (before JSON.parse)
export function verifyWandura(raw, headers, secret, toleranceS = 300) {
  const ts = headers['x-timestamp'], sig = String(headers['x-signature'] || '');
  if (!/^\d+$/.test(ts || '') || Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) return false;
  const want = createHmac('sha256', secret).update(`${ts}.${raw}`).digest('hex');
  return sig.length === want.length && timingSafeEqual(Buffer.from(sig), Buffer.from(want));
}

const app = express();
app.post('/hooks/wandura', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyWandura(raw, req.headers, process.env.WANDURA_JOBS_SECRET)) return res.sendStatus(401);
  const job = JSON.parse(raw); // { job_id, status, output, error, ... }
  // store it, dedupe on job.job_id, then:
  res.sendStatus(200);
});
```

Python (Flask):

```python
import hashlib, hmac, os, time
from flask import Flask, request

def verify_wandura(raw: bytes, headers, secret: str, tolerance_s: int = 300) -> bool:
    ts, sig = headers.get("X-Timestamp", ""), headers.get("X-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance_s:
        return False
    want = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, sig)

app = Flask(__name__)

@app.post("/hooks/wandura")
def wandura_hook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_wandura(raw, request.headers, os.environ["WANDURA_JOBS_SECRET"]):
        return "", 401
    job = request.get_json()  # dedupe on job["job_id"]
    return "", 200
```

Dùng sandbox để thử endpoint của bạn: job `wdr_test_…` cũng bắn webhook có ký y như thật.

## Lỗi
Mọi câu trả lời lỗi có dạng `{"error": {"code", "message", "retryable"}}`, thêm `details` cho `INVALID_INPUT`. Rẽ nhánh theo `code`; khi nhờ hỗ trợ, đưa kèm header `X-Request-Id`.

| HTTP | `code` | `retryable` | Ý nghĩa |
|---|---|---|---|
| 422 | `INVALID_INPUT` | false | Body hoặc header sai; `details[]` liệt kê từng `{field, reason}`. |
| 401 | `UNAUTHORIZED` | false | Thiếu key, key lạ, đã thu hồi hoặc hết hạn. |
| 402 | `INSUFFICIENT_CREDITS` | false | Ví của product không đủ trả cho job. Nạp thêm rồi thử lại. |
| 403 | `FORBIDDEN` | false | Key hợp lệ nhưng không được làm việc này. Dự phòng: hiện chưa trả. |
| 404 | `NOT_FOUND` | false | Không có job, model hoặc endpoint đó cho key này. |
| 409 | `IDEMPOTENCY_KEY_REUSED` | false | `Idempotency-Key` này đã dùng với body khác. |
| 409 | `JOB_NOT_CANCELLABLE` | false | Chỉ huỷ được job đang queued. |
| 429 | `RATE_LIMITED` | true | Product gửi quá nhiều job trong phút này. Thử lại sau `Retry-After`. |
| 429 | `PRODUCT_BUSY` | true | Product đã hết suất chạy và hàng chờ đầy. Thử lại sau `Retry-After` (khoảng 5 s). |
| 429 | `END_USER_LIMIT_REACHED` | true | End user này đã chạm giới hạn job trong ngày. Thử lại sau `Retry-After` (reset 00:00 UTC). |
| 500 | `INTERNAL_ERROR` | true | Phía Wandura có sự cố. Thử lại với cùng `Idempotency-Key`. |
| 503 | `SERVICE_UNAVAILABLE` | true | Style này tạm thời không chạy được trên key live (key test vẫn trả file mẫu). Hoặc hàng đợi job tạm thời không nhận: không có gì được tạo hay tính tiền; thử lại sau `Retry-After` với cùng `Idempotency-Key`. |

`429` luôn kèm header `Retry-After` (giây). Chờ đúng khoảng đó, cộng thêm một chút jitter ngẫu nhiên, rồi retry với **cùng** `Idempotency-Key`. Chỉ retry những lỗi có `retryable`. Câu trả lời submit, kể cả `429`, cũng có `X-Concurrency-Limit`, `X-Concurrency-Running` và `X-Concurrency-Waiting`: xem [Hàng chờ và nghẽn](https://docs.wandura.ai/vi/developers/api-v2/queue-and-congestion).

```http
HTTP/1.1 429
Retry-After: 12
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1791897360

{"error":{"code":"RATE_LIMITED","message":"Too many jobs submitted this minute for this product. Retry after the Retry-After header.","retryable":true}}
```

## Giới hạn
Đây là mức mặc định hiện tại, và **được nâng theo từng khách hàng khi có yêu cầu**: hãy báo cho Wandura đỉnh tải dự kiến của bạn (số job mỗi phút, tỉ lệ video) và giới hạn cho product của bạn sẽ được nâng.

| Gì | Hiện tại | Ghi chú |
|---|---|---|
| Tốc độ submit | 60 job mỗi phút mỗi product | Key live và key test tính riêng. Vượt mức: `429 RATE_LIMITED` kèm `Retry-After`. Mỗi submit trả `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. |
| Job chạy cùng lúc | 20 mỗi product | Job thừa chờ ở `queued`. Hàng chờ chứa số job bằng số suất chạy; quá mức đó, `429 PRODUCT_BUSY` kèm `Retry-After`. Job còn chờ tới hết thời gian tối đa của nó sẽ kết thúc `timeout`, không tính tiền. Câu trả lời submit cho thấy các số này (`X-Concurrency-*`). |
| Mỗi end user | 20 job mỗi ngày | Chỉ khi bạn gửi `end_user_id`. Reset 00:00 UTC; `429 END_USER_LIMIT_REACHED`. |
| Thời gian job tối đa | ảnh 60 s, video 300 s | Theo style (`max_job_seconds`). Quá mức này job kết thúc `timeout`, không tính tiền. |
| Thời gian trả lời submit | dưới 3 giây | Submit chỉ kiểm tra, giữ chỗ giá và xếp job vào hàng. |
| File output | 72 giờ | Xem [Output](https://docs.wandura.ai/vi/developers/api-v2/jobs#output). |

Trạng thái và thông báo bảo trì: [wandura.instatus.com](https://wandura.instatus.com). Chưa cam kết SLA uptime.

## Tính tiền thế nào
- **Tính tiền**: job `succeeded` trên key live, theo giá của style.
- **Không tính tiền**: `failed` (lỗi tạo, đầu vào không dùng được, bị moderation chặn), `timeout`, `cancelled`, và mọi thứ bị từ chối ngay lúc submit (`4xx`). Giá được giữ khi job bắt đầu và được trả lại khi job kết thúc mà không có kết quả.
- **Key test** không bao giờ tính tiền.

## Phiên bản
- Đường dẫn có version (`/v2/…`). Model id mang version riêng (`wandura-image-v1` là version của model, không phải version của API).
- **Thay đổi phá vỡ tương thích** đến dưới dạng version đường dẫn mới hoặc model id mới, báo trước ít nhất **30 ngày**. Bản cũ vẫn chạy trong thời gian đó.
- **Thay đổi cộng thêm** có thể ra bất cứ lúc nào: style mới, field tuỳ chọn mới, mã lỗi mới, field mới trong câu trả lời. Bỏ qua field bạn không biết, và coi `error.code` lạ là không retry được trừ khi `retryable` nói khác.

## Liên hệ
- Trạng thái và bảo trì: [wandura.instatus.com](https://wandura.instatus.com).
- Câu hỏi, nâng giới hạn, sự cố: _[đầu mối tích hợp của Wandura: sẽ bổ sung]_.
- Gửi kèm `job_id` và `X-Request-Id` của lời gọi.
