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.

From brief to verified download
  1. Upload

    Put logos, photos and references in the workspace.

  2. Brief

    Create a session and post the brief with deliverables.

  3. Follow

    Read events until the turn ends or asks you something.

  4. Collect

    Read the outcome, download each delivery, check its hash.

  5. 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: run to create sessions, post turns, decide approvals, interrupt and close (older keys that carry agent also work); read to read sessions, events and turns and to download files; write to 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_credits and details.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.io that needs no credential and expires at upload.expires_at, 600 seconds after it was issued.
  • One upload can be up to 5 GiB. A larger size is refused with too_large.
  • The PUT must carry exactly size bytes whose sha256 matches what you declared. Bytes that don't match are refused with conflict, 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.

FieldMeaning
request_idRequired. Your key for this create; a replay returns the same session.
projectAn existing project to continue, for example to edit an earlier ad (^[A-Za-z0-9_-]{1,64}$). Omitted: a new project.
titleUp to 200 characters.
collaborationautopilot (the default) or step_by_step. See Approvals and questions.
max_microcreditsA spending cap for the whole session. Omitted: no session cap.
deadline_atUnix 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_secondsLease length, 30–900 seconds. Default 300.
harnessThe 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.
modelThe 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".
metadataUp 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: claim without takeover gets controller_conflict.
  • A worker that restarted and lost its token calls POST /v1/sessions/{id}/claim with {request_id, takeover: true} for a new token. The old token then gets controller_conflict.
  • The token rotates on every claim. To renew on purpose, claim with your current controller. seconds (30–900, default 300) sets the new lease's length.
  • Once a lease expires, its token is refused with controller_conflict, even on claim. claim without controller (no takeover needed) 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?}; only sha256 is required, filename is up to 255 characters and note up to 2,000. role is one of logo, photo, brand_file, reference, source_ad, audio, font or other.
  • 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.

FieldMeaning
countHow many, 1–50. Default 1.
width, heightExact pixels, 16–8,192.
aspectA ratio such as 9:16, instead of exact pixels.
duration_sSeconds, up to 3,600.
max_bytesA file-size ceiling.
formatsAny 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.
sizesFor a display_set: sizes such as 300x250.
copyExact text to use, under keys such as headline, body, cta and legal, each up to 2,000 characters.
notesUp to 4,000 characters of extra direction.
optionaltrue 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.html at the top of the ZIP, with an ad.size meta tag.
  • At most 40 local files of .html, .css, .js, .png, .jpg, .jpeg, .gif and .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

FamilyDestinations
YouTubeyoutube/skippable-instream, youtube/non-skippable, youtube/non-skippable-ctv, youtube/bumper, youtube/shorts, youtube/thumbnail
Metafacebook/feed, facebook/stories, facebook/feed-video, facebook/reels, instagram/reels
TikToktiktok/in-feed
Googlegoogle/display-html5, google/ad-manager, google/demand-gen-video, google/demand-gen-image
Display and webdisplay/{unit} (for example display/medium_rectangle), web/banner, web/landscape, web/mobile-video
CTVctv/standard
Pinterest and Redditpinterest/standard-video, pinterest/standard-image, reddit/video, reddit/image
Audiospotify/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);
}
  • seq is per session, starts at 1 and has no gaps, so after is 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 while more is true.
  • limit is 1–500, default 200. turn_id keeps that turn's events plus session-level events such as session.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.

TypeWhat it tells you
session.created, session.claimed, session.closedThe session started, the lease moved, or the session closed.
turn.queued, turn.startedThe turn is waiting (with its position) or running.
message.delta, message.completedThe agent's reply text, in pieces and then whole.
tool.started, tool.completedA tool call, its ok, error_code and charged_microcredits.
job.updatedA job the turn started changed state, with progress and eta_s when known.
artifact.createdIntermediate media as it lands. Drafts are marked draft.
delivery.createdOne finished delivery, with a fresh signed url.
approval.requested, approval.resolvedThe agent asks before a step, and the decision.
cost.updatedheld_microcredits, charged_microcredits and max_microcredits so far.
turn.completed, turn.failed, turn.interruptedThe 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

StateMeaning
queuedAccepted, waiting for an earlier turn in the session to end.
runningThe agent is working.
awaiting_approvalThe agent asked before a step and is waiting for your decision.
completedEnded with an outcome.
failedThe turn itself couldn't continue. error says why.
interruptedStopped by interrupt or close. outcome.status is stopped.

Outcome status

outcome.statusMeaning
deliveredEvery required deliverable was delivered.
partialSome were.
answeredNo deliverables were requested, and the agent replied.
needs_inputThe agent asks questions, and possibly typed cards. Post another turn.
stoppedEnded 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}, or null when nothing was checked) and spec_checks[] ({check, ok, detail?, ...});
  • members for a display set, by sha256;
  • copy: the exact copy that appears in the piece;
  • charged_microcredits: the spend attributed to that deliverable;
  • url and url_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, supports Range, and sends X-Content-SHA256.
  • With the signed link: the delivery's url, which looks like https://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 autopilot the agent asks only for spend over the turn's cap. In step_by_step it proposes each consequential step; reading, searching and quoting run without asking. A step that can spend carries its quote in cost_microcredits.
  • An approval arrives as an approval.requested event and in the session's pending_approvals. It is {id, turn_id, kind, summary, params, cost_microcredits, state, expires_at, card?}, with kind spend or action.
  • Decide with POST /v1/sessions/{id}/approvals/{approval_id} and {request_id, controller, decision: "accept" | "decline", note?, option_id?}. When the approval has a card, option_id must be one of its options and agree with your decision.
  • An approval is pending, accepted, declined or expired. It expires 15 minutes after it is asked, and the agent treats that as a decline. Deciding one that is no longer pending is conflict (409).
  • Questions. When the outcome is needs_input, post the answer as the next turn. When outcome.cards holds a typed card (choice, intake, take, audio_take or budget), add answer: {card_id, option_id}, or answer: {card_id, values} for an intake card.

Interrupt and close

  • Interrupt: POST /v1/sessions/{id}/interrupt with {request_id, controller, turn_id}. It answers 202 with the turn. A queued turn ends interrupted at once; a running one ends interrupted moments later. Then post the new direction as a turn.
  • Close: POST /v1/sessions/{id}/close with {request_id, controller}. Closing interrupts an active turn, ends queued ones with reason: "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 header Wavemaker-Replayed: true, and nothing runs or is charged twice. The same id with a different body is request_id_mismatch (409).
  • A replay of a turn returns that turn in its current state: queued, running, awaiting_approval or 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. Wait retry_after_s, then retry with the same request_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 new request_id.
  • A turn that ended failed with error.retryable: true is retried as a new turn with a new request_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_limited and details.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" and retry_after_s: 30. A turn that queues behind its own session's running turn doesn't count.

Resume after a restart

  1. GET /v1/sessions/{id}: read state, active_turn_id, last_seq, pending_approvals and turns keyed by request_id.
  2. Resend any request you sent that has no entry in turns, with its original request_id.
  3. For each finished turn, GET /v1/sessions/{id}/requests/{request_id} returns the full turn with deliveries and cost.
  4. Resume events from your saved cursor.
  5. If the controller token was lost, claim with takeover: 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".

Related guidance