Companion Ads
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:
- Strips
ad_requestso the LLM gateway never sees it. - Fetches ads in parallel with the LLM call, matching against the inbound conversation (
messages/ Googlecontents) — not the assistant reply. Ads do not block first token. - Translates each network's payload into FreeRouter's canonical
adsshape. Unknown keys are dropped. - On a successful LLM response, attaches
adsorads_error. On streams, emits one extra SSE event beforedata: [DONE].
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
- 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. - 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.
- 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. - 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.
| Field | Required | Description |
|---|---|---|
placement | Yes | Your 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_id | Yes | Session identifier for frequency capping and experiment bucketing. Required on every ad call. Alias: sessionId. |
ip | Yes | End-user IP, not your server's. Used for fraud detection and geo. |
ua | Yes | End-user User-Agent, not your server's. |
placement_type | No | Where 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. |
email | No | End-user email. FreeRouter SHA-256 hashes it (strip + lower) before the network sees it (user.email_hash). |
phone | No | End-user phone. Hashed as SHA-256 of digits-only (user.hashed_phone). |
user_id | No | Your 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."
}
}
| Code | When |
|---|---|
missing_ad_request | ad_request was omitted. |
invalid_ad_request | A 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_network | Companion Ads is on but the workspace has no active ad-network key. |
ad_network_error | Every 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
0is 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 see | What it means |
|---|---|
No ads or ads_error field | Companion 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_request | Companion Ads is on and the body had no ad_request. |
ads_error.invalid_ad_request | A required field failed validation, or there were no conversation messages to match. |
ads_error.no_ad_network | No active ad-network key under Settings → Ad Networks (or the routing rule points at a disabled key). |
ads_error.ad_network_error | The ad network timed out, rate-limited, or returned 5xx/4xx. Inference still succeeded. |