Make an ad through the session API
Create a hosted agent session, post a brief with attachments and deliverables, follow events, then download and verify each delivery.
Updated
This is the path any outside backend uses to have Wavemaker's hosted agent make an ad: Adwave, Waverunner or your own service. It is the same session service the web app's chat runs on. There is no private integration API and no adapter.
You describe the work in plain language and state what must come back as deliverables. The agent chooses the method: models, graphs, edits, review and finishing. Each finished file arrives in the turn's outcome.deliveries[], checked against the destination you named.
- Upload
Put logos, photos and references in the workspace.
- Brief
Create a session and post the brief with deliverables.
- Follow
Read events until the turn ends or asks you something.
- Collect
Read the outcome, download each delivery, check its hash.
- Revise
Post another turn in the same session.
The older agents.* operations and productions.run under /api/v1/ops are not served. They answer HTTP 501 operation_not_ported. Use /v1/sessions.
What you need
- Base URL:
https://api.wavemaker.io. Paths answer directly, with no redirects. - Credential: a workspace credential sent as
Authorization: Bearer {key}. The key fixes the workspace, and every call is scoped to it. Don't send a workspace id. - Scopes:
runto create sessions, post turns, decide approvals, interrupt and close (older keys that carryagentalso work);readto read sessions, events and turns and to download files;writeto upload attachments. See Credentials. - Credits in the workspace balance. Credits granted at sign-up can't be spent through an API key or an app you connected until the workspace has paid for credits or a plan. Such a turn is refused with
insufficient_creditsanddetails.reason: "starter_credits_need_payment".
Keep keys and controller tokens on your backend, encrypted at rest. Never put them in browser code, prompts, URLs or logs. A write sent from a web page on another site is refused with origin_rejected (403).
Check a key before you start: GET /v1/identity returns its workspace, scopes, mode (live or test) and expiry.
Responses
Every /v1 response is the envelope {ok, data, error, job, cost, hints}. On failure data is null, error is {code, message, retryable, retry_after_s?, card?, details?}, and the HTTP status follows the code. Branch on error.code and error.retryable, never on the message. Each data object carries a schema literal, such as wmcore.session/1 or wmcore.turn/1. Ignore response fields you don't know. Unknown request fields are refused with invalid_request and details.path.
Money is in integer microcredits: 1 credit is 1,000,000 microcredits. Times are Unix seconds. Ids are opaque strings.
The examples below use this Node.js 22 helper:
import {createHash} from 'node:crypto';
const base = 'https://api.wavemaker.io';
const key = process.env.WAVEMAKER_API_KEY;
async function api(method, path, body) {
const response = await fetch(base + path, {
method,
redirect: 'error',
headers: {Authorization: `Bearer ${key}`, 'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
});
const envelope = await response.json(); // a failure in front of the service can be non-JSON
if (!envelope.ok) {
throw Object.assign(new Error(envelope.error.message), envelope.error, {status: response.status});
}
return envelope.data;
}A client timeout stops your observation, not the remote work. JSON bodies are limited to 2 MB, so upload media as bytes and never inline it in a brief.
1. Upload brand files and references
A turn references attachments by sha256, and their bytes must already be in the workspace. Upload each file with the write tool in three steps: ask for an upload link, PUT the bytes, then commit.
import {readFile} from 'node:fs/promises';
async function upload(path, mime) {
const bytes = await readFile(path);
const sha256 = createHash('sha256').update(bytes).digest('hex');
const begin = await api('POST', '/v1/tools/write', {
args: {uri: 'wm://artifact/', upload: {size: bytes.length, sha256, mime}},
});
const put = await fetch(begin.upload.url, {
method: begin.upload.method, // PUT
headers: begin.upload.headers,
body: bytes,
});
if (!put.ok) throw new Error(`upload failed: HTTP ${put.status}`);
const done = await api('POST', '/v1/tools/write', {
args: {uri: 'wm://artifact/', commit: begin.next.commit},
});
if (done.sha256 !== sha256) throw new Error('upload hash mismatch');
return done; // {uri: 'wm://artifact/{sha256}', revision: null, sha256, size, mime}
}
const logo = await upload('./logo.png', 'image/png');
const photo = await upload('./can.jpg', 'image/jpeg');- The upload link is an HTTPS PUT on
api.wavemaker.iothat needs no credential and expires atupload.expires_at, 600 seconds after it was issued. - One upload can be up to 5 GiB. A larger
sizeis refused withtoo_large. - The PUT must carry exactly
sizebytes whose sha256 matches what you declared. Bytes that don't match are refused withconflict, and the commit is refused until matching bytes arrive. - A file removed after a legal or safety request can't be uploaded again; it is refused with
content_removed(451).
As an alternative, POST /api/v1/inputs takes the raw bytes in one request with a positive Content-Length, X-Content-SHA256 and X-Filename (chunked transfer encoding is refused). It replies 201 in the older wmlab.result/1 envelope, with the input's sha256 in data.
Any artifact the workspace owns can be attached, whether it came from an upload, another session or an earlier delivery. Another workspace's sha256 is not_found.
2. Create a session
const session = await api('POST', '/v1/sessions', {
request_id: 'campaign-123:session',
title: 'Northstar cold brew launch',
});
let controller = session.controller.token; // a secret: store it encrypted
// → 201 {schema: 'wmcore.session/1', id: 'ses_…', project: '…', state: 'active', …,
// controller: {token: 'wmc_…', expires_at, generation: 1}}The reply (HTTP 201) is the session, with its id, its project and the creator's controller lease. A new project is created unless you pass project to continue earlier work. Creating a session is free; nothing runs until the first turn.
| Field | Meaning |
|---|---|
request_id | Required. Your key for this create; a replay returns the same session. |
project | An existing project to continue, for example to edit an earlier ad (^[A-Za-z0-9_-]{1,64}$). Omitted: a new project. |
title | Up to 200 characters. |
collaboration | autopilot (the default) or step_by_step. See Approvals and questions. |
max_microcredits | A spending cap for the whole session. Omitted: no session cap. |
deadline_at | Unix seconds. A turn posted after it closes the session and is refused with session_closed. It doesn't stop a turn that is already running. |
controller_seconds | Lease length, 30–900 seconds. Default 300. |
harness | The hosted agent: claude (the default), codex or opencode on production; staging also runs qwen and deepseek. An agent the service doesn't run, including the retired grok, is refused with invalid_request and details.reason: "harness_not_enabled", with details.enabled listing what is available. |
model | The model the agent runs, fixed for the session. claude offers claude-opus-5-5 (its default), claude-sonnet-5-5 and claude-haiku-5-5. Another value is refused with details.reason: "model_not_offered". |
metadata | Up to 20 string labels for your own use, such as adwave.campaign_id. Keys match ^[a-z0-9_.-]{1,64}$; values are strings up to 500 characters. Stored and echoed; the agent never reads them. |
A session continuing a project keeps that project's agent. Naming a different one is refused with details.reason: "harness_fixed".
The controller lease
Only the current controller can post turns, decide approvals, interrupt or close. Reads need only the key. Each accepted controller write extends the lease to its full length, so an active client never renews separately.
- A second client can't take a live lease:
claimwithouttakeovergetscontroller_conflict. - A worker that restarted and lost its token calls
POST /v1/sessions/{id}/claimwith{request_id, takeover: true}for a new token. The old token then getscontroller_conflict. - The token rotates on every claim. To renew on purpose,
claimwith your currentcontroller.seconds(30–900, default 300) sets the new lease's length. - Once a lease expires, its token is refused with
controller_conflict, even onclaim.claimwithoutcontroller(notakeoverneeded) for a new token. - A lease that expires never stops work that is already running.
3. Post the brief with deliverables
const requestId = 'campaign-123:brief-v1';
const turn = await api('POST', `/v1/sessions/${session.id}/turns`, {
request_id: requestId,
controller,
text: `Launch ads for Northstar Coffee's new cold brew. Audience: commuters aged 25-40.
Tone: warm, energetic, premium. Use the attached logo and product photo exactly.
Don't invent discounts, health claims or product features.`,
attachments: [
{sha256: logo.sha256, role: 'logo', filename: 'logo.png'},
{sha256: photo.sha256, role: 'photo', filename: 'can.jpg'},
],
deliverables: [
{id: 'reel', kind: 'video', aspect: '9:16', duration_s: 15, destination: 'instagram/reels'},
{id: 'feed', kind: 'image', width: 1080, height: 1080, destination: 'facebook/feed',
copy: {headline: 'Cold brew, warm mornings', cta: 'Shop now'}},
{id: 'banners', kind: 'display_set', formats: ['html5'], destination: 'google/display-html5',
sizes: ['300x250', '728x90', '160x600'],
copy: {headline: 'Cold brew, warm mornings', cta: 'Shop now'}},
],
});
// → 202 {schema: 'wmcore.turn/1', id: 'trn_…', state: 'queued' | 'running', …}The turn body
text(required, up to 64,000 characters): the brief in plain language.attachments(up to 40):{sha256, role?, filename?, note?}; onlysha256is required,filenameis up to 255 characters andnoteup to 2,000.roleis one oflogo,photo,brand_file,reference,source_ad,audio,fontorother.deliverables(up to 50): what must come back. See below.models(optional): media model pins{images, video, voice, sound}. See Choosing models.collaboration: overrides the session's setting for this turn.max_microcredits: this turn's cap.quote_id: accept a quote (step 4).answer: the reply to a decision card (step 8).
A session runs one turn at a time. A turn posted while another runs is queued and starts when the earlier one ends.
Deliverables
Each deliverable needs an id (^[a-z0-9_-]{1,40}$) and a kind: video, image, audio or display_set. Every delivery names the deliverable_id it satisfies.
| Field | Meaning |
|---|---|
count | How many, 1–50. Default 1. |
width, height | Exact pixels, 16–8,192. |
aspect | A ratio such as 9:16, instead of exact pixels. |
duration_s | Seconds, up to 3,600. |
max_bytes | A file-size ceiling. |
formats | Any of mp4, mov, webm, png, jpg, webp, gif, mp3, wav, aac, html5. Every delivered file must be one of them. |
destination | "platform/placement". Spec checks run against it. |
sizes | For a display_set: sizes such as 300x250. |
copy | Exact text to use, under keys such as headline, body, cta and legal, each up to 2,000 characters. |
notes | Up to 4,000 characters of extra direction. |
optional | true makes it an aim rather than a requirement. Only required deliverables decide outcome.status. |
When a turn declares required deliverables, every delivery must name one of their ids. Each kind delivers under one role: video as delivery_video, image as delivery_image, audio as delivery_audio, display_set as delivery_set. A turn that asked for an HTML5 banner can't count PNGs as delivered.
HTML5 banners
A display_set with formats: ["html5"] and destination: "google/display-html5" asks for HTML5 ZIPs ready for a Google Ads upload. Each size comes back as its own ZIP (application/zip), with a backup image of the final frame (PNG or JPEG) beside it. The agent builds and checks each banner against these rules:
index.htmlat the top of the ZIP, with anad.sizemeta tag.- At most 40 local files of
.html,.css,.js,.png,.jpg,.jpeg,.gifand.svg, and only local references. - A complete HTML document, in a ZIP of at most 600,000 bytes, in one of the supported display sizes.
- No
clickTag: Google adds the click. - Animation that stops within 30 seconds.
For a banner that declares its own clickTag, use google/ad-manager (ZIP and unpacked files up to 1,000,000 bytes each). For a banner that links to your own landing page, use web/banner (ZIP up to 1,000,000 bytes).
Destinations
| Family | Destinations |
|---|---|
| YouTube | youtube/skippable-instream, youtube/non-skippable, youtube/non-skippable-ctv, youtube/bumper, youtube/shorts, youtube/thumbnail |
| Meta | facebook/feed, facebook/stories, facebook/feed-video, facebook/reels, instagram/reels |
| TikTok | tiktok/in-feed |
google/display-html5, google/ad-manager, google/demand-gen-video, google/demand-gen-image | |
| Display and web | display/{unit} (for example display/medium_rectangle), web/banner, web/landscape, web/mobile-video |
| CTV | ctv/standard |
| Pinterest and Reddit | pinterest/standard-video, pinterest/standard-image, reddit/video, reddit/image |
| Audio | spotify/audio, audio/streaming |
meta is accepted for facebook, yt for youtube, iab for display and tv for ctv. Outputs always name the canonical platform, as {platform, placement}.
Choosing models
The agent picks its own method. Unless a turn pins a video model or the brief names one, it makes video with Wavemaker's self-hosted H3 model. It moves a shot to a partner video model when you pin or name one, or when H3 fails that shot, and it says which shots moved and why.
To pin, send models: {images, video, voice, sound}, each up to 300 characters. Each value is a model from search with kinds: ["model"], a model profile, a supplier model id or a route. Inside the turn, a call that makes a pinned kind with another model is refused with model_pin_conflict; a pin is never substituted. If a pinned model can't do the work, the agent stops and tells you instead of switching. The one exception is adult (18+) work: its stills go to a permissive image model whatever the image pin, and the agent says so.
4. Price it first (optional)
Post the same body with quote_only: true. It needs no request_id or controller, and answers 200 with a wmcore.quote/1:
const quote = await api('POST', `/v1/sessions/${session.id}/turns`, {
text: '...', deliverables: [/* as above */], quote_only: true,
});
// → 200 {schema: 'wmcore.quote/1', quote_id, p50_microcredits, p90_microcredits,
// hold_microcredits, balance_microcredits, basis: [{kind, label, p50_microcredits}],
// expires_at, microcredits_per_cent}Without a session, POST /v1/quotes with {text, deliverables, attachments} returns the same quote. basis lines are by kind: model, generation, edit, compose, perception, review, run or search. A quote is valid for 1,800 seconds, and it starts and holds nothing.
To accept it, post the turn with quote_id. Its hold is reserved and becomes the turn's cap, unless you set max_microcredits yourself or a session cap applies; then that cap is reserved instead. A quote isn't required: without one, the turn runs under any max_microcredits, the session cap and the balance. An expired quote is refused with quote_expired.
The final charge is measured usage. It can be lower than p50, and it never exceeds the turn's cap. A cap too small for the agent's first model calls is refused when you post, with cap_exceeded and details.minimum_microcredits. A session's first turn needs a larger minimum than later turns.
5. Follow events
let after = (await loadCursor(session.id)) ?? 0;
let end = null;
while (!end) {
const page = await api('GET',
`/v1/sessions/${session.id}/events?after=${after}&wait_s=25&limit=200`);
for (const event of page.items) {
// Deduplicate by (session_id, seq); handling an event twice must be harmless.
if (event.turn_id === turn.id &&
['turn.completed', 'turn.failed', 'turn.interrupted'].includes(event.type)) {
end = event;
}
}
after = page.next_after;
await saveCursor(session.id, after);
}seqis per session, starts at 1 and has no gaps, soafteris a complete cursor.wait_s(up to 25) holds the request until the next event arrives. An empty page after the wait is normal.- The page is
{items, next_after, more, session_state, active_turn_id}. Read again at once whilemoreistrue. limitis 1–500, default 200.turn_idkeeps that turn's events plus session-level events such assession.claimed.
For a stream, send Accept: text/event-stream. Each event arrives as id: {seq}, event: {type} and data: {event JSON}, with a : keepalive comment every 15 seconds while nothing happens. The stream ends after 300 seconds, or once the session is closed with no active turn and every event sent. Reconnect with Last-Event-ID (or after).
An event is {seq, session_id, type, at, turn_id, request_id, data}. Ignore types you don't know.
| Type | What it tells you |
|---|---|
session.created, session.claimed, session.closed | The session started, the lease moved, or the session closed. |
turn.queued, turn.started | The turn is waiting (with its position) or running. |
message.delta, message.completed | The agent's reply text, in pieces and then whole. |
tool.started, tool.completed | A tool call, its ok, error_code and charged_microcredits. |
job.updated | A job the turn started changed state, with progress and eta_s when known. |
artifact.created | Intermediate media as it lands. Drafts are marked draft. |
delivery.created | One finished delivery, with a fresh signed url. |
approval.requested, approval.resolved | The agent asks before a step, and the decision. |
cost.updated | held_microcredits, charged_microcredits and max_microcredits so far. |
turn.completed, turn.failed, turn.interrupted | The turn ended, with its outcome or error and its cost. |
delivery.created carries the same row the outcome will list in deliveries (deliverable_id, sha256, delivery_id, destination, spec_checks, ...) with a signed url minted when you read or receive it. Show or ship each piece of a set when its event arrives instead of waiting for turn.completed; the outcome still lists every delivery, with the same ids.
Webhooks (optional)
POST /v1/webhooks/endpoints with {url, events} (exact types, a family such as turn.*, or *) returns the endpoint and its signing secret once. Session milestones (session.created, session.closed, turn.completed, turn.failed, turn.interrupted, approval.requested, artifact.created, delivery.created) are pushed with the same data as the events above. Each push carries Wavemaker-Signature: t={unix},v1={hex}, an HMAC-SHA256 over {t}.{raw body}; deduplicate on Wavemaker-Delivery. Polling the event stream remains the contract. See HTTP API.
6. Read the outcome
const final = await api('GET',
`/v1/sessions/${session.id}/requests/${encodeURIComponent(requestId)}`);Turn states
| State | Meaning |
|---|---|
queued | Accepted, waiting for an earlier turn in the session to end. |
running | The agent is working. |
awaiting_approval | The agent asked before a step and is waiting for your decision. |
completed | Ended with an outcome. |
failed | The turn itself couldn't continue. error says why. |
interrupted | Stopped by interrupt or close. outcome.status is stopped. |
Outcome status
outcome.status | Meaning |
|---|---|
delivered | Every required deliverable was delivered. |
partial | Some were. |
answered | No deliverables were requested, and the agent replied. |
needs_input | The agent asks questions, and possibly typed cards. Post another turn. |
stopped | Ended early. reason is budget, deadline, interrupted, policy, failed or closed. |
Errors inside a running turn, such as a provider failure or a refused tool call, are the agent's to handle. They appear as tool.completed events with ok: false. A tool call or model call refused for money ends the turn completed with partial or stopped and reason: "budget": running out of credits keeps what was made. A turn ends failed only when it can't continue, for example when its runner stops answering for 180 seconds (timeout, retryable).
Deliveries and cost
outcome.message is the agent's final reply. Each entry in outcome.deliveries[] has:
deliverable_id,role,kind,delivery_id;sha256,uri(wm://artifact/{sha256}),mime,bytes,width,height,duration_s;destination({platform, placement}, ornullwhen nothing was checked) andspec_checks[]({check, ok, detail?, ...});membersfor a display set, by sha256;copy: the exact copy that appears in the piece;charged_microcredits: the spend attributed to that deliverable;urlandurl_expires_at: a signed download link.
HTML5 banners arrive as ZIP files (application/zip) under the display set's deliverable_id, each beside its backup image.
Turn.cost has the settled charged_microcredits (final once settled is true), a breakdown[] by kind with call counts (rows carry deliverable_id; shared spend such as the agent's own model calls has deliverable_id: null), and a receipt you can read. Session.cost sums the session's turns.
7. Download and verify
Fetch the bytes either way:
- With the key:
GET /api/v1/files/artifact/{sha256}streams the bytes with no redirect, supportsRange, and sendsX-Content-SHA256. - With the signed link: the delivery's
url, which looks likehttps://api.wavemaker.io/v1/downloads/{sha256}/{token}. It needs no credential and is valid for at most an hour. Every read of the turn mints a fresh link, so re-read for a new one instead of storing it.
POST /v1/tools/read with {"args": {"uri": "wm://artifact/{sha256}"}} also returns a link.
import {writeFile} from 'node:fs/promises';
for (const d of final.outcome.deliveries) {
const response = await fetch(`${base}/api/v1/files/artifact/${d.sha256}`, {
headers: {Authorization: `Bearer ${key}`},
redirect: 'error',
});
if (!response.ok) throw new Error(`download ${d.sha256}: HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
const actual = createHash('sha256').update(bytes).digest('hex');
if (actual !== d.sha256 || bytes.length !== d.bytes) throw new Error('artifact mismatch');
await writeFile(`./out/${d.deliverable_id}-${d.sha256.slice(0, 12)}`, bytes, {flag: 'wx'});
}Artifacts are immutable and content-addressed, so the sha256 identifies the exact bytes. Stream large files to disk while hashing. Copy the bytes into your own storage. If a delivered file is missing from storage, the turn still reads and that delivery has no url.
Before launch, read spec_checks and look at the work yourself. A delivery passed its destination's spec checks, but Wavemaker doesn't publish ads or buy media.
8. Revise, answer, approve, stop
Revise
Post another turn in the same session, with a new request_id and the change you want. To edit an ad from a closed session, create a session with that project and attach the ad with role: "source_ad".
Approvals and questions
- Approvals. In
autopilotthe agent asks only for spend over the turn's cap. Instep_by_stepit proposes each consequential step; reading, searching and quoting run without asking. A step that can spend carries its quote incost_microcredits. - An approval arrives as an
approval.requestedevent and in the session'spending_approvals. It is{id, turn_id, kind, summary, params, cost_microcredits, state, expires_at, card?}, withkindspendoraction. - Decide with
POST /v1/sessions/{id}/approvals/{approval_id}and{request_id, controller, decision: "accept" | "decline", note?, option_id?}. When the approval has acard,option_idmust be one of its options and agree with yourdecision. - An approval is
pending,accepted,declinedorexpired. It expires 15 minutes after it is asked, and the agent treats that as a decline. Deciding one that is no longer pending isconflict(409). - Questions. When the outcome is
needs_input, post the answer as the next turn. Whenoutcome.cardsholds a typed card (choice,intake,take,audio_takeorbudget), addanswer: {card_id, option_id}, oranswer: {card_id, values}for an intake card.
Interrupt and close
- Interrupt:
POST /v1/sessions/{id}/interruptwith{request_id, controller, turn_id}. It answers 202 with the turn. A queued turn endsinterruptedat once; a running one endsinterruptedmoments later. Then post the new direction as a turn. - Close:
POST /v1/sessions/{id}/closewith{request_id, controller}. Closing interrupts an active turn, ends queued ones withreason: "closed", and settles their holds. History, files and deliveries stay, and reads of a closed session keep working.
A session closes only when you close it, or when a turn is posted after its deadline_at. An idle session stays open. Any write to a closed session is refused with session_closed; create a new session with the same project to continue.
9. Retries and recovery
- Every write takes a
request_id(^[A-Za-z0-9._:-]{1,200}$). The same id with the same body is a replay: it returns the original outcome with the headerWavemaker-Replayed: true, and nothing runs or is charged twice. The same id with a different body isrequest_id_mismatch(409). - A replay of a turn returns that turn in its current state:
queued,running,awaiting_approvalor its final state with outcome and cost. - After a network error or your own timeout, resend the identical request, or read
GET /v1/sessions/{id}/requests/{request_id}. Never mint a new id for the same intent. - A retryable refusal of the write (
rate_limited,busy,policy_unavailable,timeout) recorded nothing. Waitretry_after_s, then retry with the samerequest_id. - A non-retryable refusal (
insufficient_credits,cap_exceeded,quote_expired,policy_refused,consent_required) is recorded, and a replay returns it again. Fix the cause, then post with a newrequest_id. - A turn that ended
failedwitherror.retryable: trueis retried as a new turn with a newrequest_id. The same id only returns the failure. - Inside one turn, the agent may make 10 tool calls per second of requested output media, at least 300. Past that, its tool calls are refused with
rate_limitedanddetails.limit: "tool_call_budget", not retryable within that turn. - In production a workspace runs up to 24 agent turns at once. A turn that would start beyond that is refused with
rate_limited,details.limit: "hosted_turns_per_workspace"andretry_after_s: 30. A turn that queues behind its own session's running turn doesn't count.
Resume after a restart
GET /v1/sessions/{id}: readstate,active_turn_id,last_seq,pending_approvalsandturnskeyed byrequest_id.- Resend any request you sent that has no entry in
turns, with its originalrequest_id. - For each finished turn,
GET /v1/sessions/{id}/requests/{request_id}returns the full turn with deliveries and cost. - Resume events from your saved cursor.
- If the controller token was lost,
claimwithtakeover: true.
Full codes: Errors, limits and safe retries.
Check your integration before launch
Run one static, one video and one HTML5 deliverable: upload, post, disconnect and reconnect, resume events, read the outcome, download and verify, then revise. Confirm that a replay doesn't create a second turn.
The scripted value of harness in the schema is a test stand-in that the production service doesn't offer; it is refused with details.reason: "harness_not_enabled".