# Image and video generation: the Jobs API

> **TL;DR** — `POST /v2/jobs` with a model, a style and your inputs returns a `job_id` in about a second. Wandura generates in the background and POSTs the signed final job to your `webhook_url`; `GET /v2/jobs/{job_id}` is the fallback. Test keys (`wdr_test_…`) are a free sandbox. A bad body is refused at once with `422` and the reason; a job that fails, times out, is cancelled or is blocked by moderation is **not charged**. This is the recommended way in for every new integration.

## Basics
- **Base URL**: `https://api.wandura.ai` (region Singapore).
- **OpenAPI 3.1**: [api.wandura.ai/v2/openapi.json](https://api.wandura.ai/v2/openapi.json), no key needed. The Jobs API is the operations tagged `Jobs`.
- **Flow**: submit with `webhook_url` and an `Idempotency-Key` → `202 {job_id, status: "queued"}` → Wandura POSTs the signed final job to `webhook_url` and you answer `200` → if the webhook never arrives, poll `GET /v2/jobs/{job_id}` → download `output[0].url`.
- Every request is JSON over HTTPS.

| Endpoint | What it does |
|---|---|
| `POST /v2/jobs` | Submit a job. |
| `GET /v2/jobs/{job_id}` | Status and output. Same body as the webhook. |
| `POST /v2/jobs/{job_id}/cancel` | Cancel a job that is still `queued`. |
| `GET /v2/models` | The models. |
| `GET /v2/models/{model_id}/schema` | JSON Schema of the submit body for one model. |
| `GET /v2/styles?model=` | The live styles, with previews. |

## Authentication and keys
Every call sends `Authorization: Bearer <key>`. Create keys in the [console](https://open.wandura.ai/keys); a product can have several and rotate them. Keep keys on your server.

| Key | What it does | Cost |
|---|---|---|
| `wdr_test_…` | **Sandbox.** The same API, validation, statuses and webhooks; the job returns a sample file instead of generating | $0 |
| `wdr_live_…` | Real generation | The style price, only when the job succeeds |

A job belongs to the product of the key that submitted it: another product's key gets `404` for it.

## Models and the schema
Two models, one per output kind.

| `model` | Output |
|---|---|
| `wandura-image-v1` | image |
| `wandura-video-v1` | video |

The `v1` in a model id is the **model** version, not the API version. Ids are fixed: a breaking change gets a new id (`…-v2`), never a silent change. Styles are added and retired under a model without changing it.

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

# JSON Schema (2020-12) of the submit body for one model: types, required fields, min/max,
# lengths, the list of style ids, and per style which fields it requires and which it does not take
curl https://api.wandura.ai/v2/models/wandura-video-v1/schema -H "Authorization: Bearer $WANDURA_KEY"
```

Read the schema at build time or on a schedule to build your input forms; it changes when styles are added or retired.

## Styles
A style is a packaged look: its prompt, its generation steps and its settings stay on Wandura's side. `GET /v2/styles?model=wandura-video-v1` lists the live styles your key can run:

```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 | Meaning |
|---|---|
| `preview_url` | An https link to a sample image or video made with the style. Show it in your picker. `null` when the style has none yet. |
| `inputs` | The fields of `POST /v2/jobs` this style takes, each `required` or `optional`. Fields not listed are refused. |
| `max_job_seconds` | The longest this style takes. Past it the job ends `timeout`, not charged. |
| `price` | What a **succeeded** live job costs (1 credit = $0.01). |

## Submit, poll, cancel
```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 | Notes |
|---|---|
| `model` | Required. `wandura-image-v1` or `wandura-video-v1`. |
| `style` | Required. A style id of that model (`GET /v2/styles`). |
| `prompt` | The text the style works into the result. Optional unless the style lists it as required; `""` counts as not sent. |
| `image_url`, `image_urls`, `video_url` | Public https URLs (a presigned GET URL works, up to 8192 characters). Images JPEG, PNG or WEBP up to 20 MB; videos MP4, MOV or WEBM up to 50 MB. |
| `negative_prompt`, `aspect_ratio`, `resolution`, `duration`, `seed` | Only for styles that list them in `inputs`. See [Validation](https://docs.wandura.ai/developers/api-v2/jobs#validation). |
| `num_outputs` | Optional, must be `1`: one output per job. |
| `webhook_url` | Optional. A public https URL that gets the final job, signed with your product's Jobs API signing secret or, if it has none, its Webhook endpoint's signing secret. Needs one of the two first (see [Webhooks](https://docs.wandura.ai/developers/api-v2/jobs#webhooks)). |
| `end_user_id` | Optional. Your id for the person the job is for; when you send it, a per-person daily cap applies (see [Limits](https://docs.wandura.ai/developers/api-v2/jobs#limits)). |
| `lane` | **Rolling out.** Optional: `priority` (default) or `relax`. See [Relax lane and a style's own inputs](https://docs.wandura.ai/developers/api-v2/jobs#lane-and-inputs). |
| `inputs` | **Rolling out.** Optional object: the style's own inputs that no field above carries. See [Relax lane and a style's own inputs](https://docs.wandura.ai/developers/api-v2/jobs#lane-and-inputs). |

- **Input URLs are downloaded when the job starts**, not when you submit. A URL that cannot be downloaded ends the job `failed`, not charged. Keep a presigned URL valid for at least the style's `max_job_seconds` plus 10 minutes.
- **Cancel** works while the job is `queued`: `200`, status `cancelled`, not charged, and the webhook fires. Once the job is `processing` it runs to the end, and cancel answers `409 JOB_NOT_CANCELLABLE`.

## Relax lane and a style's own inputs
**Rolling out.** Two optional fields of `POST /v2/jobs`, so every style a key can run also runs as a job. A body without them is answered exactly as before.

**`lane`**: `priority` (default) or `relax`.

|  | `priority` | `relax` |
|---|---|---|
| Price | The style's Priority price | The style's Relax price, never above Priority |
| Deadline | `max_job_seconds`; past it the job ends `timeout`, not charged | None: the job waits in line until it runs |
| When the product is full | `429 PRODUCT_BUSY` | Never refused for that; the job waits as `queued` |

The job body is the same for both lanes (it has no `lane` field). Both prices are in the public catalog (`GET /v2/public/styles`, `price.priority` and `price.relax`).

**`inputs`**: an object of the style's own inputs that no field of the table above carries, named as in the style's input schema. Examples: a `keep_audio` switch, a second text field (`prompt` fills the first), a `mask_url`, or a `video_url` on an image style.

```bash
curl -X POST https://api.wandura.ai/v2/jobs \
  -H "Authorization: Bearer $WANDURA_KEY" -H "Content-Type: application/json" \
  -d '{"model": "wandura-video-v1", "style": "<style id>", "image_urls": ["https://…/photo.jpg"], "lane": "relax", "inputs": {"keep_audio": false}}'
```

- **Where to find a style's inputs**: `GET /v2/public/styles/{style}` → `inputs` (no key). Each style's OpenAPI, `GET /v2/public/styles/{style}/openapi.json`, carries the exact `POST /v2/jobs` body for that style, with an example.
- An input that a field above carries goes in that field, not in `inputs`: `{"field": "inputs.image_urls", "reason": "send it as image_urls"}`.
- A style whose **required** input is not one of the fields above runs only when the body sends `inputs`. It is not listed in `GET /v2/styles` or the model schema; without `inputs` it gets the same `422` as an unknown style.
- `GET /v2/models/{model_id}/schema` lists only the fields of the table above; `lane` and `inputs` are in the [OpenAPI](https://api.wandura.ai/v2/openapi.json) (`JobRequest`).
- Audio styles are not on the Jobs API yet.

Problems with these fields come back as `422 INVALID_INPUT` details, like every other field:

| `field` | `reason` |
|---|---|
| `lane` | `must be priority or relax` |
| `inputs` | `must be an object of the style's own inputs` |
| `inputs.<name>` | `send it as <field>` · `style <id> does not take this input` · `must be true or false` · `must be a public https URL (at most 8192 characters)` · `must be a list of 1 to 10 https URLs` · `must be a string of at most 4000 characters` · `required by style <id>` |

## Validation is strict and synchronous
A wrong body never becomes a job. Wandura checks the whole body at submit and lists **every** problem:

```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"}]}}
```

- **Styles fix their own aspect ratio, resolution and duration.** Today's styles do not take `aspect_ratio`, `resolution`, `duration` or `seed`, so sending one returns `422` with the reason. Send only what the style's `inputs` lists, and use the model schema to know which.
- An unknown field, a missing required field, a malformed URL and an unknown style are all `422`.

## Idempotency
Send an `Idempotency-Key` (1 to 255 characters) on every submit so a retry never runs twice.

- Same key and same body: the same job (`200`, header `Idempotent-Replayed: true`, the job's current status).
- Same key and another body: `409 IDEMPOTENCY_KEY_REUSED`. Use a new key for a new job.
- No header: Wandura derives a key from your API key and the body, so the same body within 10 minutes is the same job.

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

The last four are final and never change again.

| `status` | Final | Charged (live key) | `error` |
|---|---|---|---|
| `queued` | no | none | `null` |
| `processing` | no | none | `null` |
| `succeeded` | yes | yes, the style price | `null` |
| `failed` | yes | **no** | `GENERATION_FAILED`, `INPUT_UNUSABLE` or `CONTENT_MODERATION_BLOCKED` |
| `timeout` | yes | **no** | `TIMEOUT` |
| `cancelled` | yes | **no** | `null` |

Errors inside a finished job (`error.code`):

| `status` | `code` | `retryable` | Meaning |
|---|---|---|---|
| `failed` | `CONTENT_MODERATION_BLOCKED` | false | The input or the result was blocked by content moderation. Not charged. |
| `failed` | `INPUT_UNUSABLE` | false | An input file could not be used (unreachable, unsupported format, or too long). Not charged. |
| `failed` | `GENERATION_FAILED` | true | Generation failed on Wandura's side. Not charged; submitting again usually works. |
| `timeout` | `TIMEOUT` | true | The job did not finish within the maximum job time of its style. Not charged. |

A moderation block is a normal final job, not an HTTP error:

```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` is a list with **one** file, and `null` until the 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` is an HTTPS link on Wandura storage (the Wandura CDN), downloadable directly with a plain GET. It is not a link to any third party.
- **Kept for 72 hours.** `expires_at` says exactly when. Copy the file to your own storage before then.
- Metadata is read from the file: `type`, `width`, `height`, `duration` and `fps` (video), `size_bytes`, `seed`. A value that cannot be read is `null`.
- Test-key samples carry `null` metadata and do not expire.
- The job record itself is kept well past 72 hours: `GET /v2/jobs/{job_id}` keeps answering for old jobs.

## Webhooks
Each job with a `webhook_url` gets **one** POST when it reaches a final status (no intermediate webhooks). The body is exactly what `GET /v2/jobs/{job_id}` returns at that moment.

1. **Have a signing secret once**: in the [console](https://open.wandura.ai/webhooks), either save a Webhook endpoint URL (its signing secret then signs job webhooks too), or create a dedicated "Jobs API signing secret" (Create; it is shown once; Roll replaces it at once). When the product has a Jobs API signing secret, that one signs job webhooks. Until it has one of the two, a submit with `webhook_url` is refused with `422`.
2. **Verify every request** on the **raw body**: `X-Signature` is the hex HMAC-SHA256 of `X-Timestamp + "." + raw body` with your secret, and `X-Timestamp` (unix seconds) is within 5 minutes of now.
3. **Answer `2xx` within 10 seconds**, then do your work. Dedupe on `job_id` (or the `Wandura-Event-Id` header, the same on every retry): delivery is at least once.

```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", …}
```

### Retry policy
A non-2xx answer (redirects included), a timeout or a network error is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours: **7 attempts over about 31 hours**, with the same body and `job_id`. A URL whose host resolves to a private or reserved address is never called. If every attempt fails, the job is still there: poll it.

### Verify the signature
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
```

Use the sandbox to test your endpoint: a `wdr_test_…` job fires the same signed webhook.

## Errors
Every error answer is `{"error": {"code", "message", "retryable"}}`, plus `details` for `INVALID_INPUT`. Branch on `code`; quote the `X-Request-Id` header when you ask for support. **Rolling out:** each error also carries `docs_url`, a link to its section on the [Errors](https://docs.wandura.ai/developers/api-v2/errors#jobs-errors) page, and the headers `Wandura-Error-Code` and `Wandura-Should-Retry` (see [Error body and headers](https://docs.wandura.ai/developers/api-v2/errors#error-body)).

| HTTP | `code` | `retryable` | Meaning |
|---|---|---|---|
| 422 | `INVALID_INPUT` | false | The body or a header is wrong; `details[]` lists each `{field, reason}`. |
| 401 | `UNAUTHORIZED` | false | Missing, unknown, revoked or expired API key. |
| 402 | `INSUFFICIENT_CREDITS` | false | The product's wallet cannot cover the job. Add funds, then retry. |
| 403 | `FORBIDDEN` | false | The key is valid but may not do this. Rolling out: a key with the Read only permission submitting or cancelling, or a key limited to other styles (see [API keys](https://docs.wandura.ai/developers/api-v2/concepts#api-keys)). Not returned before that. |
| 404 | `NOT_FOUND` | false | No such job, model or endpoint for this key. |
| 409 | `IDEMPOTENCY_KEY_REUSED` | false | This `Idempotency-Key` was already used with a different body. |
| 409 | `JOB_NOT_CANCELLABLE` | false | Only queued jobs can be cancelled. |
| 429 | `RATE_LIMITED` | true | Too many jobs submitted this minute for this product. Retry after `Retry-After`. |
| 429 | `PRODUCT_BUSY` | true | Your product's concurrency slots and its queue are full. Retry after `Retry-After` (about 5 s). |
| 429 | `END_USER_LIMIT_REACHED` | true | This end user reached the daily job cap. Retry after `Retry-After` (resets 00:00 UTC). |
| 500 | `INTERNAL_ERROR` | true | Something went wrong on Wandura's side. Retry with the same `Idempotency-Key`. |
| 503 | `SERVICE_UNAVAILABLE` | true | This style cannot run on a live key right now (test keys still return a sample). Or the job queue is briefly unavailable: nothing was created or charged; retry after `Retry-After` with the same `Idempotency-Key`. |

A `429` always carries a `Retry-After` header (seconds). Wait that long, add a little random jitter, and retry with the **same** `Idempotency-Key`. Retry only the errors marked `retryable`. Submit answers, `429` included, also carry `X-Concurrency-Limit`, `X-Concurrency-Running` and `X-Concurrency-Waiting`: see [Queue and congestion](https://docs.wandura.ai/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}}
```

## Limits
These are today's defaults, and **they are raised per customer on request**: tell Wandura your expected peak (jobs per minute, video share) and the limits for your product are raised.

| What | Today | Notes |
|---|---|---|
| Submit rate | 60 jobs per minute per product | Live and test keys count apart. Over it: `429 RATE_LIMITED` with `Retry-After`. Every submit answers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. |
| Jobs running at once | 20 per product | Further jobs wait as `queued`. As many jobs can wait as you have slots; past that, `429 PRODUCT_BUSY` with `Retry-After`. A job still waiting at its maximum job time ends `timeout`, not charged. Submit answers show the numbers (`X-Concurrency-*`). |
| Per end user | 20 jobs per day | Only when you send `end_user_id`. Resets 00:00 UTC; `429 END_USER_LIMIT_REACHED`. |
| Maximum job time | image 60 s, video 300 s | Per style (`max_job_seconds`). Past it the job ends `timeout`, not charged. |
| Submit answer time | well under 3 seconds | The submit only validates, reserves the price and queues the job. |
| Output files | 72 hours | See [Output](https://docs.wandura.ai/developers/api-v2/jobs#output). |

Live status and maintenance notices: [wandura.instatus.com](https://wandura.instatus.com). No uptime SLA is committed yet.

## What is charged
- **Charged**: a `succeeded` job on a live key, at the style price.
- **Not charged**: `failed` (generation error, unusable input, moderation block), `timeout`, `cancelled`, and anything refused at submit (`4xx`). The price is held when the job starts and released when it ends without a result.
- **Test keys** never charge.

## Versioning
- Paths are versioned (`/v2/…`). Model ids carry their own version (`wandura-image-v1` is the model version, not the API version).
- A **breaking change** comes as a new path version or a new model id, announced at least **30 days** ahead. The old one keeps working during that time.
- **Additive changes** can ship at any time: new styles, new optional fields, new error codes, new fields in answers. Ignore fields you do not know, and treat an unknown `error.code` as non-retryable unless `retryable` says otherwise.

## Contact
- Status and maintenance: [wandura.instatus.com](https://wandura.instatus.com).
- Questions, higher limits, incidents: _[Wandura integration contact: to be added]_.
- Send the `job_id` and the `X-Request-Id` of the call.
