Companion Ads

A mediated ad slot on the inference response. Enable Companion Ads on a FreeRouter key, send an ad_request sibling (the same object on every API shape), and read ads or ads_error next to the model payload.

How it works

Companion Ads is ad mediation for inference — the same idea as LLM routing, for ads. Obtain API keys for the ad networks FreeRouter supports, store them under Settings → Ad Networks (BYOK), turn Companion Ads on for a FreeRouter key, and keep calling the same chat endpoint. Ad-network secrets never appear on the inference request. FreeRouter:

  1. Strips ad_request so the LLM gateway never sees it.
  2. Fetches ads in parallel with the LLM call, matching against the inbound conversation (messages / Google contents) — not the assistant reply. Ads do not block first token.
  3. Translates each network's payload into FreeRouter's canonical ads shape. Unknown keys are dropped.
  4. On a successful LLM response, attaches ads or ads_error. On streams, emits one extra SSE event before data: [DONE].
Inference always wins. Missing or invalid ad_request, a down ad network, or a no-fill never fails the LLM call. You still get the model response. Ads problems surface as ads_error, or as ads: [] when the network had nothing to serve. If the LLM call itself fails (4xx/5xx), FreeRouter does not wait on ads.

When Companion Ads is off for the key, ad_request is still stripped (so gateways never see it) and the response has neither ads nor ads_error.

Set it up

  1. Obtain API keys for the ad networks FreeRouter supports. With each network, create a placement and note its slot id and type (for example chat-main, below_response). The slot must be live on the same publisher account as the key you store.
  2. In FreeRouter, open Settings → Ad Networks, enable a network, paste its publisher key, and save. That writes the workspace-default ad routing rule. Every key inherits it until it overrides.
  3. On API Keys, enable Companion Ads when creating a key or on an existing key. If no ad network is saved yet, FreeRouter stays on the API Keys page and opens a modal that points at Settings — it does not redirect, so a just-minted fr_live_… secret is still shown once on the keys page.
  4. Optionally override ad routing on that key (priority failover or %-split). An empty override inherits the Settings default.

The dashboard playground does not send ad_request (playground runs are never logged). Use curl or your app to exercise ads — see the example below.

Which ad-network key is used

The sample or client sends only the FreeRouter key (fr_live_…). FreeRouter looks that key up, then decrypts the ad-network publisher key from the workspace (the Settings row, or the key's routing override). Settings shows each stored key's last four characters so you can confirm which account is in play.

The ad_request object

ad_request is a sibling of the usual request body — it is not part of the OpenAI, OpenRouter, Anthropic, or Google shape. All four keys take the same object. Do not nest it inside messages or contents.

When Companion Ads is on, pass ad_request on every call you want ads for. Omitting it still returns the model response, plus ads_error with code missing_ad_request.

FieldRequiredDescription
placementYesYour slot id. 1–32 characters: letters, numbers, underscore, or hyphen; no spaces. Echoed on the ad as placement_id. Must match a live placement for the publisher key stored in Settings.
session_idYesSession identifier for frequency capping and experiment bucketing. Required on every ad call. Alias: sessionId.
ipYesEnd-user IP, not your server's. Used for fraud detection and geo.
uaYesEnd-user User-Agent, not your server's.
placement_typeNoWhere the slot sits in the UI. One of below_response (default), above_response, inline_response, left_response, right_response, search_result, top_page, bottom_page, center_page, left_page, right_page. Alias: placementType.
emailNoEnd-user email. FreeRouter SHA-256 hashes it (strip + lower) before the network sees it (user.email_hash).
phoneNoEnd-user phone. Hashed as SHA-256 of digits-only (user.hashed_phone).
user_idNoYour stable per-user id. Used for frequency capping and attribution; not required to serve ads. Alias: userId.

Matching uses the inbound conversation. A request with no usable messages (or Google contents) returns ads_error invalid_ad_request rather than calling the network.

Example — OpenAI shape

curl https://api.freerouter.com/v1/chat/completions \
  -H "Authorization: Bearer $FREEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{ "role": "user", "content": "How do I set up PostgreSQL?" }],
    "ad_request": {
      "placement": "chat-main",
      "placement_type": "below_response",
      "session_id": "sess_abc123",
      "ip": "203.0.113.42",
      "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...",
      "user_id": "user_789",
      "email": "alex@example.com"
    }
  }'

The same ad_request object works on POST /v1/messages (Anthropic) and POST /v1/models/{model}:generateContent (Google). Google streaming (:streamGenerateContent) is not supported yet — Companion Ads on a google-shaped key is JSON-only.

The ads response

On a match, the inference JSON gains an ads array as a sibling of choices (OpenAI / OpenRouter), the Anthropic envelope, or the Google envelope. The object is FreeRouter's canonical ads shape. Network payloads are translated into it; unknown keys are dropped.

{
  "id": "chatcmpl-...",
  "choices": [ ... ],
  "ads": [
    {
      "adText": "Serverless Postgres that scales to zero. Start free.",
      "title": "Neon Serverless Postgres",
      "brandName": "Neon",
      "cta": "Try Neon Free",
      "url": "https://neon.tech",
      "favicon": "https://icons.duckduckgo.com/ip3/neon.tech.ico",
      "clickUrl": "https://…/track/click?p=...",
      "impUrl": "https://…/track/imp?p=...",
      "placement": "below_response",
      "placement_id": "chat-main"
    }
  ]
}

Always render the ad with clickUrl (not url) and fire impUrl when the creative becomes visible — that is how the network bills and attributes. Null fields are omitted. The ad may also include campaignId, leadForm, renderer_spec, or feedbackPrompt; FreeRouter forwards those when present.

When FreeRouter sends ads: [] (no matching campaign, an unregistered placement, or a filtered request), hide the slot. That is not an error — it is a no-fill.

ads_error

If Companion Ads is on and FreeRouter cannot attach an ad, the model payload is unchanged and we add:

{
  "ads_error": {
    "code": "missing_ad_request",
    "message": "Companion Ads is on — pass ad_request with placement, session_id, ip, and ua."
  }
}
CodeWhen
missing_ad_requestad_request was omitted.
invalid_ad_requestA required field is missing or malformed (placement, session_id, ip, ua, placement_type), or the body has no conversation messages to match against.
no_ad_networkCompanion Ads is on but the workspace has no active ad-network key.
ad_network_errorEvery configured network failed (timeout, 5xx, 429, or a 4xx FreeRouter could not recover from).

ads and ads_error are mutually exclusive. A no-fill is ads: [], never ads_error.

Streaming

For stream: true on OpenAI, OpenRouter, and Anthropic keys, token chunks are unchanged. After the upstream stream finishes, FreeRouter writes one extra event, then data: [DONE]:

data: {"id":"...","choices":[{"delta":{"content":"..."}}]}

data: {"ads":[{ "adText": "...", "clickUrl": "...", "impUrl": "...", "placement": "below_response", "placement_id": "chat-main" }]}

data: [DONE]

OpenAI-compatible SDKs that only read choices[].delta ignore the ads event. Clients that want ads should listen for a payload with an ads or ads_error key. Anthropic streams get the same trailing data: line after the translated events. Google keys have no streaming path yet, so Companion Ads there is JSON-only.

Ad-network routing

Ad routing uses the same two strategies as LLM routing:

  • Priority failover — try networks in listed order. Advance on timeout, 429, 5xx, or no-fill (204), so a second network can still fill the slot.
  • Percentage split — pick the first network by weight, then fail over through the rest in order. Weight 0 is failover-only.

Settings holds the workspace default. A FreeRouter key inherits that default unless you save an override on the key. Changing Settings updates every key that has not overridden.

Privacy

Send the end user's IP and User-Agent. FreeRouter requires both on ad_request (invalid_ad_request if either is missing). Forwarding your server's IP/UA is syntactically valid but FreeRouter may treat the request as a bot and return a no-fill. Raw email and phone never leave FreeRouter: they are hashed (SHA-256) before the ad request is sent. ad_request is stripped before the body is forwarded to the LLM gateway and is not stored in request logs.

Troubleshooting

What you seeWhat it means
No ads or ads_error fieldCompanion Ads is off for that FreeRouter key, or the LLM call failed before ads were attached.
ads: []No-fill. FreeRouter accepted the ad request and attached an empty array. Hide the slot. Typical causes: placement is not a live slot on this publisher key, no campaign matched the conversation, or the device was filtered. Confirm placement / placement_type match a live slot, and that Settings last-four matches the ad-network account that owns it.
ads_error.missing_ad_requestCompanion Ads is on and the body had no ad_request.
ads_error.invalid_ad_requestA required field failed validation, or there were no conversation messages to match.
ads_error.no_ad_networkNo active ad-network key under Settings → Ad Networks (or the routing rule points at a disabled key).
ads_error.ad_network_errorThe ad network timed out, rate-limited, or returned 5xx/4xx. Inference still succeeded.
Next API Reference