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.
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. Needs your product's Jobs API signing secret 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). |
- 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.
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.- Kept for 72 hours.
expires_atsays exactly when. Copy the file to your own storage before then. - 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 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.
- Create your product's Jobs API signing secret once: in the console, "Jobs API signing secret" → Create. It is shown once; Roll replaces it at once. Until it exists, 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.
| 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 | 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 | 72 hours | 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 15 Oct 2026 · Change history (2)