On this page
TL;DR —
POST /v2/jobswith a model, a style and your inputs returns ajob_idin about a second. Wandura generates in the background and POSTs the signed final job to yourwebhook_url;GET /v2/jobs/{job_id}is the fallback. Test keys (wdr_test_…) are a free sandbox. A bad body is refused at once with422and 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_urland anIdempotency-Key→202 {job_id, status: "queued"}→ Wandura POSTs the signed final job towebhook_urland you answer200→ if the webhook never arrives, pollGET /v2/jobs/{job_id}→ downloadoutput[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'smax_job_secondsplus 10 minutes. - Cancel works while the job is
queued:200, statuscancelled, not charged, and the webhook fires. Once the job isprocessingit runs to the end, and cancel answers409 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 exactPOST /v2/jobsbody 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 inGET /v2/stylesor the model schema; withoutinputsit gets the same422as an unknown style. GET /v2/models/{model_id}/schemalists only the fields of the table above;laneandinputsare 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,durationorseed, so sending one returns422with the reason. Send only what the style'sinputslists, 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, headerIdempotent-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 | timeoutThe 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"}urlis 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_atsays 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,durationandfps(video),size_bytes,seed. A value that cannot be read isnull. - Test-key samples carry
nullmetadata 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.
- 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_urlis refused with422. - Verify every request on the raw body:
X-Signatureis the hex HMAC-SHA256 ofX-Timestamp + "." + raw bodywith your secret, andX-Timestamp(unix seconds) is within 5 minutes of now. - Answer
2xxwithin 10 seconds, then do your work. Dedupe onjob_id(or theWandura-Event-Idheader, 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 "", 200Use 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
succeededjob 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-v1is 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.codeas non-retryable unlessretryablesays otherwise.
Contact
- Status and maintenance: wandura.instatus.com.
- Questions, higher limits, incidents: [Wandura integration contact: to be added].
- Send the
job_idand theX-Request-Idof the call.
Updated 17 Oct 2026 · Change history (6)