# wavebird API integration guide for LLM coding agents

Use this document as the implementation brief for adding wavebird sponsored moments to an AI app. The goal is to let the app request a sponsored placement from the backend while the model response stays untouched, then render the placement in a separate frontend slot.

## Integration goal

Add wavebird to the host app with the recommended Server API path:

1. Create or reuse a backend route that calls wavebird with a server-side secret key.
2. Add a frontend sponsored slot near the AI experience.
3. Load the hosted renderer.
4. Pass the returned `placement`, `decision`, and `authoritative_consent` to `window.wavebird.renderPlacement()`.
5. Keep ads clearly separate from model output.
6. Hide or collapse the slot when there is no fill.

Prefer this path unless the existing app is intentionally browser-only.

## Required project values

Ask the app owner for these values or read them from existing environment configuration:

- `WAVEBIRD_SECRET_KEY`: server-only secret key. Never expose this in browser code.
- `WAVEBIRD_CLIENT_ID`: wavebird project/client id, for example `wbproj_...`.
- `NEXT_PUBLIC_WAVEBIRD_PUBLISHABLE_KEY`: publishable key, only needed for Script Tag or browser-first flows.
- Allowed origins: production and preview origins configured in the wavebird dashboard.

Recommended environment file:

```bash
WAVEBIRD_SECRET_KEY=sk_live_your_key
WAVEBIRD_CLIENT_ID=wbproj_your_client_id
WAVEBIRD_API_BASE_URL=https://api.wavebird.ai
NEXT_PUBLIC_WAVEBIRD_PUBLISHABLE_KEY=pk_publishable_your_browser_key
```

## Privacy and product rules

Follow these rules in the implementation:

- Do not send prompts, full chat history, user IDs, email addresses, account data, or sensitive details to wavebird by default.
- Send only broad request context such as `job_type`, `session_id`, slot dimensions, allowed formats, timing, consent state, and broad topic category if the app owner explicitly wants it.
- Keep the model response path independent from the sponsor path.
- Do not insert sponsored text into the AI answer.
- Render sponsored moments in a separate slot controlled by the app.
- If wavebird returns no fill or an error, the AI app must continue normally.

## Recommended architecture

Backend:

- Calls `POST https://api.wavebird.ai/v1/placements?wait_ms=1500`.
- Uses `Authorization: Bearer ${WAVEBIRD_SECRET_KEY}`.
- Returns the wavebird JSON response to the browser.

Frontend:

- Loads `https://api.wavebird.ai/v1/render.js`.
- Adds a dedicated sponsored slot outside the model answer.
- Calls the publisher's same-origin backend route, handles no-fill, and passes a filled canonical response to `window.wavebird.renderPlacement()`.
- Treats rendering as successful only when `renderPlacement()` resolves to a cleanup function. `null`, `ad:render_failed`, or `data-wavebird-status="frame_error"` means the render failed.
- Lets the hosted renderer mount `placement.render` and manage media, sizing, clicks, and beacons.

Publisher Content Security Policy:

- Load `https://api.wavebird.ai/v1/render.js` directly.
- Allow `https://api.wavebird.ai` in `script-src`, `frame-src`, and `connect-src`.
- Do not proxy the renderer, hosted creative frame, or Wavebird beacons through the publisher app.

## Backend example: Next.js App Router

Create `app/api/wavebird/sponsor-slot/route.ts`:

```ts
import { NextResponse } from "next/server";

const API_BASE = process.env.WAVEBIRD_API_BASE_URL ?? "https://api.wavebird.ai";

type SponsorSlotRequest = {
  session_id?: string;
  job_type?: string;
  slot_hint?: {
    position?: string;
    max_width?: number;
    max_height?: number;
  };
  overrides?: Record<string, unknown>;
  consent?: Record<string, unknown>;
};

export async function POST(request: Request) {
  if (!process.env.WAVEBIRD_SECRET_KEY || !process.env.WAVEBIRD_CLIENT_ID) {
    return NextResponse.json(
      { error: "Missing WAVEBIRD_SECRET_KEY or WAVEBIRD_CLIENT_ID" },
      { status: 500 },
    );
  }

  const body = (await request.json().catch(() => ({}))) as SponsorSlotRequest;

  const wavebirdResponse = await fetch(`${API_BASE}/v1/placements?wait_ms=1500`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WAVEBIRD_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      client_id: process.env.WAVEBIRD_CLIENT_ID,
      session_id: body.session_id ?? `sess_${crypto.randomUUID()}`,
      job_type: body.job_type ?? "chat",
      slots_requested: 1,
      slot_hint: body.slot_hint ?? {
        position: "below",
        max_width: 728,
        max_height: 90,
      },
      overrides: body.overrides ?? {
        allowed_formats: ["banner", "clip", "native"],
        timing: "during",
      },
      consent: {
        semantic_targeting: false,
        prompt_shared: false,
        gdpr_applies: false,
        consent_source: "wavebird_consent",
        ...body.consent,
      },
    }),
  });

  const data = await wavebirdResponse.json().catch(() => null);
  return NextResponse.json(data, { status: wavebirdResponse.status });
}
```

Implementation notes:

- Replace the generated `session_id` with a stable anonymous session id if the app already has one.
- Keep `WAVEBIRD_SECRET_KEY` server-only.
- Do not pass the user's raw message into this route unless the app owner explicitly approves a stricter targeting mode.
- `gdpr_applies: false` is only a synthetic Test default. For real-user traffic, derive jurisdiction and consent from the actual request or CMP and complete the required legal setup first.
- Dashboard-created Test projects require a current lifecycle consent signal. Include the request-level `consent` object above or sync current consent through `/v1/consent`; omitting both can return `403 consent_not_current`.

## Backend example: Express

```ts
import express from "express";

const app = express();
app.use(express.json());

app.post("/api/wavebird/sponsor-slot", async (req, res) => {
  if (!process.env.WAVEBIRD_SECRET_KEY || !process.env.WAVEBIRD_CLIENT_ID) {
    res.status(500).json({ error: "Missing wavebird environment variables" });
    return;
  }

  const response = await fetch("https://api.wavebird.ai/v1/placements?wait_ms=1500", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WAVEBIRD_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      client_id: process.env.WAVEBIRD_CLIENT_ID,
      session_id: req.body.session_id ?? `sess_${crypto.randomUUID()}`,
      job_type: req.body.job_type ?? "chat",
      slots_requested: 1,
      slot_hint: req.body.slot_hint ?? {
        position: "below",
        max_width: 728,
        max_height: 90,
      },
      overrides: req.body.overrides ?? {
        allowed_formats: ["banner", "clip", "native"],
        timing: "during",
      },
      consent: {
        semantic_targeting: false,
        prompt_shared: false,
        gdpr_applies: false,
        consent_source: "wavebird_consent",
        ...req.body.consent,
      },
    }),
  });

  const data = await response.json().catch(() => null);
  res.status(response.status).json(data);
});
```

## Frontend example: plain HTML

Add this near the AI app surface:

```html
<script src="https://api.wavebird.ai/v1/render.js"></script>

<section
  id="wavebird-slot"
  hidden
  aria-label="Sponsored placement"
></section>

<script type="module">
  let cleanupPlacement;

  function classifyWavebirdPlacementResponse(payload) {
    if (!payload || typeof payload !== "object") return "invalid_response";
    if (payload.placement && payload.decision?.fill === true) return "filled";
    if (!payload.placement && payload.decision?.fill === false) return "no_fill";
    if (!payload.placement && (payload.decision == null || payload.status === "pending")) return "not_ready";
    return "invalid_response";
  }

  async function requestAndRenderSponsor() {
    const slot = document.querySelector("#wavebird-slot");
    if (!window.wavebird?.renderPlacement) return { status: "renderer_unavailable" };
    const response = await fetch("/api/wavebird/sponsor-slot", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ session_id: crypto.randomUUID() })
    });
    const payload = await response.json();
    if (!response.ok) return { status: "request_rejected" };
    const placementState = classifyWavebirdPlacementResponse(payload);
    if (placementState !== "filled") {
      cleanupPlacement?.();
      cleanupPlacement = undefined;
      slot.replaceChildren();
      slot.hidden = true;
      return { status: placementState };
    }
    cleanupPlacement?.();
    slot.hidden = false;
    cleanupPlacement = await window.wavebird.renderPlacement({
      target: slot,
      placement: payload.placement,
      decision: payload.decision,
      authoritative_consent: payload.authoritative_consent
    });
    if (typeof cleanupPlacement !== "function") {
      slot.hidden = true;
      return { status: "render_failed" };
    }
    return { status: "rendered" };
  }

  async function onUserMessage(message) {
    const answer = sendChatMessage(message);
    void requestAndRenderSponsor();
    return answer;
  }
</script>
```

The browser calls only the publisher's same-origin sponsor-slot endpoint. The server owns the Wavebird secret and
request-level policy. Do not put prompts, chat text, identities, API keys, or other secrets in this browser body.

## Frontend example: React or Next.js

```tsx
"use client";

import Script from "next/script";

async function requestAndRenderSponsor() {
  const slot = document.querySelector("#wavebird-slot");
  if (!slot || !window.wavebird?.renderPlacement) return;
  const response = await fetch("/api/wavebird/sponsor-slot", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ session_id: crypto.randomUUID() })
  });
  const payload = await response.json();
  if (!response.ok || !payload.placement) {
    slot.replaceChildren();
    slot.setAttribute("hidden", "");
    return;
  }
  slot.removeAttribute("hidden");
  const cleanup = await window.wavebird.renderPlacement({
    target: slot,
    placement: payload.placement,
    decision: payload.decision,
    authoritative_consent: payload.authoritative_consent
  });
  if (typeof cleanup !== "function") slot.setAttribute("hidden", "");
}

export function AiChatSurface() {
  async function handleSend(message: string) {
    const answer = sendChatMessage(message);
    void requestAndRenderSponsor();
    return answer;
  }

  return (
    <>
      <Script src="https://api.wavebird.ai/v1/render.js" strategy="afterInteractive" />

      <section
        id="wavebird-slot"
        hidden
        aria-label="Sponsored placement"
      />

      {/* Existing chat UI calls handleSend(message). */}
    </>
  );
}
```

Optional TypeScript declaration:

```ts
declare global {
  interface Window {
    wavebird?: {
      renderPlacement(input: {
        target: string | Element;
        placement: unknown;
        decision?: unknown;
        authoritative_consent?: unknown;
      }): Promise<(() => void) | null>;
    };
  }
}
```

## Response handling contract

The Server API response has this shape:

```json
{
  "slot_id": "slot_demo_123",
  "status": "ready",
  "placement": {
    "format": "banner",
    "width": 728,
    "height": 90,
    "ad_label_text": "Sponsored",
    "render": {
      "strategy": "hosted_frame",
      "frame_url": "https://api.wavebird.ai/v1/render/wbat_asset_demo",
      "script_url": "https://api.wavebird.ai/v1/render.js",
      "media_type": "image",
      "width": 728,
      "height": 90,
      "label_text": "Sponsored",
      "sponsor_name": "Demo Sponsor",
      "click_url": "https://sponsor.example"
    }
  },
  "decision": {
    "fill": true,
    "format": "banner"
  }
}
```

The canonical API response does not contain a top-level `filled` field. A fill has `placement` and normally
`decision.fill: true`. If `placement` is null and `decision.fill` is false, treat the response as no-fill, inspect the
controlled `decision.no_fill_reason`, hide the slot, and keep the AI experience unchanged. If no placement is present
without a final false decision, treat it as not ready rather than claiming no-fill or render success.
Classify successful responses into exactly four placement states before rendering: `filled`, `no_fill`, `not_ready`,
or `invalid_response`. Only `filled` may enter `renderPlacement()`.

## Why the consent parameters are required

- `semantic_targeting: false` requests context-only, non-personalized matching.
- `prompt_shared: false` keeps raw prompts and chat text out of matching and partner requests.
- `gdpr_applies` states whether GDPR applies to the active request. The synthetic Test example uses `false`; real traffic must derive the correct value.
- `consent_source` identifies the authority: `wavebird_consent`, `wrapper_cmp`, or `none` where project policy permits it.

The request-level object is enough for the placement request; a separate `/v1/consent` call is optional unless the app
wants to persist session/user consent. A `403 consent_not_current` response means the project requires a current
request-level or stored lifecycle consent record. It does not mean that `semantic_targeting` must be enabled.

## Browser-first fallback: Script Tag

Use this only when the app owner wants a browser-first integration without a backend route:

```html
<script
  src="https://wavebird.ai/wavebird.js"
  data-client-id="wbproj_your_client_id"
  data-publishable-key="pk_publishable_your_key"
  data-job-type="chat"
  data-native-template="default">
</script>

<div
  data-wavebird-slot
  data-wavebird-position="between"
  data-wavebird-formats="banner,native">
</div>
```

Script Tag uses a publishable key and the browser origin must be allowed in the wavebird dashboard.

## SDK path

Use the SDK only when the app already has an SDK-based integration pattern or needs the package layer for compatibility. New production integrations should start with:

- Backend: `POST /v1/placements?wait_ms=1500`
- Frontend: `https://api.wavebird.ai/v1/render.js`
- Rendering: `window.wavebird.renderPlacement()` with the canonical placement response

## Placement choices

Start conservative:

- `position: "below"` for a slot below or between AI responses.
- `max_width: 728`, `max_height: 90` for a banner-sized first test.
- `allowed_formats: ["banner", "clip", "native"]` if the product supports all visual formats.
- `timing: "during"` when the placement should appear while the AI response is being generated.

Avoid UI patterns that make the placement look like model-generated text.

## Test checklist

After implementation, verify:

- The AI response still appears when wavebird is unavailable.
- The secret key is never present in browser bundles, HTML, logs, or network calls from the browser.
- The backend route returns a JSON response from `/v1/placements`.
- `render.js` loads without CSP errors.
- Browser extensions do not block `render.js`. If DevTools reports `ERR_BLOCKED_BY_CLIENT`, allow the Wavebird renderer
  origin for the Test domain or temporarily disable the blocking extension there; do not weaken the site's CSP.
- The sponsored slot is hidden on no-fill.
- A filled placement renders in a separate slot, not inside the answer text.
- The placement label remains visible.
- Mobile layout does not overflow.
- The app owner can disable the slot or categories without code changes.
- Dashboard integration verification attributes the request to the deployed publisher app. Local simulator,
  Wavebird-generated Test, and Production simulation activity must not satisfy that verification.

## LLM implementation checklist

When applying this document to a codebase:

1. Detect the framework and routing layer.
2. Add the server route using the app's existing API style.
3. Add the environment variables to the app's env schema or deployment docs.
4. Add the frontend slot near the AI response surface.
5. Load `render.js` once.
6. Request the placement independently of the model response and call `window.wavebird.renderPlacement()` only when `placement` exists.
7. Preserve all existing chat behavior on error or no-fill.
8. Add a small integration test or smoke test if the repo has a matching test setup.
9. Do not refactor unrelated AI, auth, billing, or ad-format code.
