Server API

Server API is the recommended default backend path. Use it when your backend already owns session state, orchestration, or rollout policy, while wavebird owns hosted media rendering in the browser.

.env.local

bash

SERVER ENV
1# Project ID (client_id, WAVEBIRD_CLIENT_ID): wbproj_your_project_id2WAVEBIRD_API_BASE_URL=https://api.wavebird.ai3WAVEBIRD_SECRET_KEY=sk_test_your_server_test_key4WAVEBIRD_CLIENT_ID=wbproj_your_project_id

create-placement.sh

bash

RECOMMENDED
1curl -X POST https://api.wavebird.ai/v1/placements?wait_ms=1500 \2  -H "Authorization: Bearer sk_test_wavebird_demo_secret" \3  -H "Content-Type: application/json" \4  -d '{5    "client_id": "wbproj_demo_8jK42",6    "session_id": "sess_demo_123",7    "job_type": "chat",8    "locale": "en",9    "slots_requested": 1,10    "slot_hint": {11      "position": "below",12      "max_width": 728,13      "max_height": 9014    },15    "consent": {16      "semantic_targeting": false,17      "prompt_shared": false,18      "gdpr_applies": false,19      "consent_source": "wavebird_consent"20    }21  }'

app/api/sponsor-slot/route.ts

typescript

SERVER ROUTE
1export async function POST(request: Request) {2  const input = await request.json();3  const apiOrigin = (process.env.WAVEBIRD_API_BASE_URL || "https://api.wavebird.ai")4    .replace(/\/$/, "")5    .replace(/\/v1$/, "");67  const upstream = await fetch(apiOrigin + "/v1/placements?wait_ms=1500", {8    method: "POST",9    headers: {10      Authorization: "Bearer " + process.env.WAVEBIRD_SECRET_KEY,11      "Content-Type": "application/json"12    },13    body: JSON.stringify({14      client_id: process.env.WAVEBIRD_CLIENT_ID,15      session_id: String(input.session_id),16      job_type: "chat",17      slots_requested: 1,18      slot_hint: input.slot_hint,19      overrides: { allowed_formats: input.allowed_formats },20      consent: input.consent21    })22  });2324  const payload = await upstream.json();25  if (!upstream.ok) {26    return Response.json({ reason: "placement_rejected" }, { status: upstream.status });27  }2829  return Response.json({30    placement: payload.placement ?? null,31    decision: payload.decision ?? null,32    authoritative_consent: payload.authoritative_consent ?? null33  });34}

chat.html

html

REST RENDER
1<script src="https://api.wavebird.ai/v1/render.js"></script>2<section id="wavebird-slot" hidden aria-label="Sponsored content"></section>34<script type="module">5  let cleanupPlacement;67  function classifyWavebirdPlacementResponse(payload) {8    if (!payload || typeof payload !== "object") return "invalid_response";9    if (payload.placement && payload.decision?.fill === true) return "filled";10    if (!payload.placement && payload.decision?.fill === false) return "no_fill";11    if (!payload.placement && (payload.decision == null || payload.status === "pending")) return "not_ready";12    return "invalid_response";13  }1415  async function requestAndRenderSponsor() {16    cleanupPlacement?.();17    const slot = document.querySelector("#wavebird-slot");18    const response = await fetch("/api/sponsor-slot", {19      method: "POST",20      headers: { "Content-Type": "application/json" },21      body: JSON.stringify({22        session_id: crypto.randomUUID(),23        allowed_formats: ["banner", "native"],24        slot_hint: { position: "below", max_width: 728, max_height: 250 },25        consent: {26          semantic_targeting: false,27          prompt_shared: false,28          gdpr_applies: true,29          consent_source: "wavebird_consent"30        }31      })32    });33    const payload = await response.json();3435    if (!response.ok) return { status: "request_rejected" };36    const placementState = classifyWavebirdPlacementResponse(payload);37    if (placementState !== "filled") {38      slot.replaceChildren();39      slot.hidden = true;40      return { status: placementState };41    }4243    slot.hidden = false;44    slot.addEventListener("ad:render_failed", () => {45      slot.dataset.renderState = "frame_error";46    }, { once: true });47    cleanupPlacement = await window.wavebird.renderPlacement({48      target: slot,49      placement: payload.placement,50      decision: payload.decision,51      authoritative_consent: payload.authoritative_consent52    });53    if (typeof cleanupPlacement !== "function") {54      slot.hidden = true;55      return { status: "render_failed" };56    }57    return { status: "rendered", cleanup: cleanupPlacement };58  }59</script>

Credentials

Server Test Key

Start with a Server Test Key from Dashboard API Keys. The full sk_test_... value is shown only when the key is created or rotated, so copy it immediately into WAVEBIRD_SECRET_KEY. If the dashboard only shows a masked preview, create or rotate a Server Test Key before running the Server API examples. Test requests are non-billable.

REST placement

Controlled request fields

Send controlled format, size, position, and consent values to your same-origin sponsor-slot endpoint. That server route validates the body and forwards only supported fields to wavebird. Never put API keys, raw prompts, chat text, identities, or other secrets in the browser request. The renderer does not read adata-wavebird-request attribute.

Required fields

What the first Test request needs

Project and session

client_id selects the wavebird project and must match the Server Test Key. session_id is a stable anonymous identifier for one test conversation. Do not use an email address, account ID, prompt, or other personal identifier.

Request type

job_type describes the product surface, such as chat, code, image, voice, or agent. It does not contain the user's message. slots_requested defaults to one.

Consent is not optional for this Test setup

Dashboard-created Test projects use an authoritative consent lifecycle. Include the request-level consent object shown above or sync a current record first. Omitting both can return 403 consent_not_current.

Synthetic Test boundary

The example uses gdpr_applies: false only for synthetic Test sessions that do not represent real users. For real-user traffic, derive jurisdiction and consent from the actual request and complete the required legal setup first.

Request controls

Consent fields

The example disables semantic targeting and raw prompt sharing. Derive gdpr_applies from the actual request and use the appropriate consent_source. Request-level consent can be included in the placement request; use /v1/consent to synchronize a decision separately.

Choose the consent path and permitted fallback

Browser lifecycle

Filled, no-fill, and render status

Filled

The canonical API response has no top-level filled field. A fill has placement and normally decision.fill: true. Pass placement, optional decision, and authoritative_consent to renderPlacement.

No-fill

When placement is absent and decision.fill is false, hide or clear the slot and continue the normal app flow. Read decision.no_fill_reason for the controlled reason. No-fill is not an integration error.

Classify every successful response as filled, no_fill, not_ready, or invalid_response. Only filled may be passed to the hosted renderer.

To exercise this branch deterministically in Test, use /v1/placements?wait_ms=1500&test_outcome=no_fill with a Server Test Key. Remove the parameter for ordinary fill requests; Production keys reject it.

Render failure

Listen for the controlled ad:render_failed event or inspect data-wavebird-status="frame_error". Do not invent a successful render signal.

The hosted renderer waits for the creative medium before sending positive render and visibility signals. Treat the render as successful only when renderPlacement() returns a cleanup function. Anull result, ad:render_failed, or data-wavebird-status="frame_error" is a controlled render failure. A mounted frame alone is not proof of a completed render. withTurn()is an optional Script Tag lifecycle helper and is not required for this REST path.

Hosted renderer

Publisher Content Security Policy

Load https://api.wavebird.ai/v1/render.js directly and allow https://api.wavebird.aiin the publisher's script-src, frame-src, and connect-src directives. Do not proxy the renderer, hosted creative frame, or beacons through the publisher app.

Hosted renderer

Measurement

The hosted renderer sends browser beacons to /public/wrapper/v1/beacons. A refused, expired, or revoked lifecycle decision prevents rendering and beacons.

Direct /v1/beacons calls are for custom rendering or QA. They require the issued asset_token, a fresh occurred_at, and idempotent beacon_id values. Keep the asset token out of logs and client-visible debug output.

Beacon request and measurement rules

After testing

Production credentials

Use sk_dry_... for non-billable production dry-run. Enable live delivery and complete payout setup before using a Server Production Key. Browser Publishable Keys are only for browser activation and Script Tag flows.

Do not add production_dry_run, production_billing_dry_run, billing_suppressed, or production_live_approved to request bodies.

Key classes and authentication · Testing checklist

Advanced request controls and direct beacons

advanced-policy-controls.json

json

ADVANCED
1{2  "overrides": {3    "allowed_formats": ["banner", "clip", "native"],4    "native_template_id": "card",5    "timing": "during",6    "bidfloor": 0.5,7    "bidfloor_currency": "EUR",8    "publisher": {9      "app_name": "MyAIChatApp",10      "app_domain": "mychatapp.com",11      "categories": ["IAB19"]12    },13    "blocked_categories": ["IAB7"]14  }15}

direct-server-beacon.mjs

javascript

ADVANCED
1const occurred_at = new Date().toISOString();23await fetch("https://api.wavebird.ai/v1/beacons", {4  method: "POST",5  headers: {6    Authorization: "Bearer " + process.env.WAVEBIRD_SECRET_KEY,7    "Content-Type": "application/json"8  },9  body: JSON.stringify({10    beacon_id: "bcn_example_rendered_001",11    slot_id: "slot_...",12    asset_token: "wbat_...",13    event: "rendered",14    occurred_at,15    metadata: {}16  })17});

Troubleshooting

Timestamp freshness

Avoid BEACON_TOO_LATE

occurred_at must be close to when the event actually happened. Copying an old static sample timestamp can return BEACON_TOO_LATE. Generate it at runtime with const occurred_at = new Date().toISOString();.

Need help with your integration?

Share the affected endpoint, request ID, and the behavior you expected. Leave out keys and user content.

Contact the team