Bản lưu ngày / Snapshot of 2026-10-20 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaThese docs are in beta and change often. Something wrong, unclear or missing? Tell us.
Image and video API (v2)

Image and video generation: the Jobs API

Markdown
On this page

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, 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; 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.

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:

{
  "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

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.
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).
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).
lane Rolling out. Optional: priority (default) or relax. See Relax lane and a style's own 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.
  • 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.

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 (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:

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

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:

{"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.

{"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.
  • Works for at least 7 days. expires_at says until when the link is guaranteed. Copy the file to your own storage before then if you need it longer.
  • 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 7 days: 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, 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.
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):

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):

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 page, and the headers Wandura-Error-Code and Wandura-Should-Retry (see Error body and headers).

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

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 at least 7 days See Output.

Live status and maintenance notices: 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.
  • Questions, higher limits, incidents: [Wandura integration contact: to be added].
  • Send the job_id and the X-Request-Id of the call.

Updated 17 Oct 2026 · Change history (6)

↑↓ selectEnter openEsc close

Send feedback

Your message goes straight to the Wandura team. It includes the address of this page.

0 / 4000