# 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**.

## 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
# {"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/guides/jobs-api#validation). |
| `num_outputs` | Optional, must be `1`: one output per job. |
| `webhook_url` | Optional. A public https URL that gets the final job, signed. Needs your product's Jobs API signing secret first (see [Webhooks](https://docs.wandura.ai/developers/guides/jobs-api#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/guides/jobs-api#limits)). |

- **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`.

## 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. **Create your product's Jobs API signing secret once**: in the [console](https://open.wandura.ai/webhooks), "Jobs API signing secret" → Create. It is shown once; Roll replaces it at once. Until it exists, 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.

| 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. Reserved: not returned today. |
| 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 | `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. |

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`.

```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`. A job still waiting at its maximum job time ends `timeout`, not charged. |
| 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/guides/jobs-api#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.
