For a native app — Flutter, React Native, Swift, Kotlin — or any server that would rather call HTTP than run our JavaScript. The SDK is a convenience over these four endpoints, not a requirement: everything it does, it does by calling them.
Base URL: https://exchange.zeekend.com
Nothing here is versioned by your client. Fields get added; existing ones do not change meaning or disappear.
Before you start: leave origins empty on your key
This is the one setting that will break a native integration, and it fails closed with a 403 rather than anything descriptive.
A publisher key on the web ships to the browser, so anyone can read it in devtools and paste it into their own site. An origin allowlist is what makes the key mean "this app" rather than "whoever copied this string". A browser proves its origin with the Origin header; a native HTTP client sends no Origin and no Referer, so it cannot satisfy an allowlist at all.
origins empty every request is accepted
origins set a request with no Origin header is 403
Ask us to register your key with no origins. If you also run a web surface on the same key, use a second key for it — one with origins set — rather than opening the allowlist on both.
What protects a native key instead: a per-key rate limit, a daily impression cap, and revocation. A key that leaks is disabled, not re-scoped.
1. Ask for a placement
POST /v1/slot
content-type: application/json
{
"publisherKey": "pub_live_...",
"placementId": "chat-answer",
"sessionId": "ses-8f21c04b",
"turn": 3,
"context": {
"type": "conversation",
"question": "I have a first date on Saturday. What should I wear?",
"answer": "Smart casual works best: dark jeans with a fitted shirt.",
"conversationId": "thread-8f21"
}
}
| Field | ||
|---|---|---|
publisherKey | required | Your key. |
context | required | See below. |
sessionId | send it | How often placements appear is counted against this. See below — leaving it out switches pacing off entirely. |
turn | send it | Which user turn this is, counting from 1. Without it the turn gap cannot be applied. |
placementId | Names the surface, e.g. chat-answer. Defaults to default. Used for your own reporting. | |
relevance | 0–1 floor for this request only. Raises whatever we have stored for you, never lowers it. | |
dimensions | { "maxHeight": 96 } — a slot under 120px is served text instead of a card. Omit if the space is flexible. | |
blockCategories | Added to what you have blocked in your dashboard. | |
v | Your client's version, e.g. "flutter-1.0". Please send it — see Telling us you exist. Start it with a name, not a bare number: a value like "0.8.0" is read as our own SDK's version. |
sessionId is what stops us showing an ad on every turn
Not optional in practice, though nothing will reject you for omitting it.
Your dashboard settings — the first turn a placement may appear on, turns between placements, placements per session — are applied here, on our side, and they are counted against sessionId and turn. If you have not set a first turn, a direct integration like yours can be served from turn 1; our own SDK is held to turn 2 unless the dashboard says otherwise. With no sessionId there is nothing to count, so nothing is refused:
with sessionId turn 3 200
turn 3 204 x-zeekend-skip: frequency_cap
turn 4 200
turn 5 200
turn 6 204 x-zeekend-skip: session_cap
without sessionId turn 3 200
turn 3 200
turn 3 200
turn 3 200 ← every turn, up to the daily cap
Generate one per conversation, keep it for that conversation's life, and send it on every request. Any stable opaque string; it is never shown to a user and we do not join it to anything.
conversationId inside context is a different thing — it ties follow-up turns together for the auction and does not pace anything. Send both if you have both.
context
For a chat surface, send the conversation:
{ "type": "conversation", "question": "...", "answer": "...", "conversationId": "..." }
answer is optional and worth sending when you have it. The auction reads the assistant's reply as well as the question, and a placement scored against both matches better than one scored against the question alone. If you are streaming, see Two passes below.
previous is optional too: the user's message before this one, which we clip to 500 characters. It lets a follow-up like "something classic" match the watch it is about, and the scorer reads it as context only. It is one more turn of the user's text reaching us, so send it only if your privacy notice covers that. A request with nothing but previous is refused like an empty one.
For anything that is not a conversation, send the text you have — text, title and content are all read.
What you get back
200 with the placement:
{
"slotId": "slot_k29fj2mx1p",
"format": "card",
"advertiser": "Modern Gents Trading Co",
"headline": "Modern Gents Silicone Ring",
"body": "A low-profile band that survives the gym and the shower.",
"price": "$38.00",
"image": "https://.../ring.jpg",
"cta": "View",
"url": "https://moderngents.com/products/...",
"clickUrl": "https://exchange.zeekend.com/v1/click/slot_k29fj2mx1p",
"relevance": 0.82,
"stage": "decision",
"disclosure": "Sponsored"
}
204 No Content when there is nothing to show. This is the common case and it is not an error. The body is empty — do not hand it to a JSON parser without checking the status first. Roughly 85% of requests are a 204 today.
When a 204 was caused by your own frequency settings rather than by the auction, the response carries x-zeekend-skip with the reason: frequency_cap, session_cap, warmup or paused. A 204 with no such header means the auction simply found nothing worth showing. Both are normal; the header is there so you can tell them apart while debugging.
401 unknown or disabled key. 403 origin not allowed — see above. 429 rate limited; retry-after says how many seconds.
Rate limit: 120 requests per minute per key. Sandbox keys: 20 per minute per IP.
format
card | A bordered unit below the answer. Use headline, body, price, image. |
text | The same thing without the image, for a short slot. |
inline | A sponsored line after the answer. The sentence is in inline; see below. |
catalog | Several products, in items. |
The advertiser chooses the format in their campaign. Render what arrives. headline and body are always present, including on an inline slot, so you can fall back to a card without asking again.
Rendering inline
An inline placement is three parts, and who owns which one is the whole design:
| the lead-in | your words, in your assistant's voice |
slot.inline | the advertiser's words, all of them inside the link |
slot.disclosure | after the claim, not before it |
You might also want to look at a low-profile silicone band that survives the gym. Sponsored
Write the lead-in yourself. An advertiser buying the words that introduce their own ad is what this separation exists to prevent. Never present the placement as your assistant's own recommendation.
2. Report the impression
POST /v1/event
{ "type": "impression", "slotId": "slot_k29fj2mx1p", "publisherKey": "pub_live_..." }
This is what you are paid for, so the two rules that decide whether it counts:
Fire on 50% visible for one continuous second. Not on render. That is the IAB standard and it is what we bill against. In Flutter, a VisibilityDetector and a one-second timer that resets if the widget leaves the viewport.
Wait at least one second after the slot was issued. An impression posted sooner is recorded as invalid with reason dwell and is not paid. It will not error — it will simply not appear in your earnings.
Duplicates are deduplicated server-side; sending twice is safe and returns { "ok": true, "deduped": true }.
publisherKey is checked against the slot's owner, so include it.
3. Handle the click
Simplest: open slot.clickUrl in the user's browser. It records the click and 302s to the product page. Nothing else to do.
GET /v1/click/{slotId} → 302 to the product
If you would rather open slot.url yourself, post the click too:
{ "type": "click", "slotId": "slot_k29fj2mx1p", "publisherKey": "pub_live_..." }
A click is only counted after that slot's impression. Out of order it returns click without impression, not billed. Send the impression first.
4. Report a problem (optional)
{ "type": "report", "slotId": "slot_...", "reason": "irrelevant" }
A "Report this ad" affordance costs you nothing and is the clearest signal to your users that you did not sell them out. We read these.
What the SDK does that you now own
Four behaviours live in the client, not the exchange. Skipping them is allowed; knowing you skipped them is the point.
Don't place on the first turn. The SDK waits for two user turns. A placement before the assistant has been any use reads as a trap. Turn gap and per-session limits are applied server-side from your dashboard settings, so those you get for free — this one is yours.
Two passes. The SDK asks once when the user hits enter, while your model is still writing, and again once the answer has settled, keeping the better result. The first pass wins latency; the second scores against the full conversation and usually scores higher. If you only ask once, ask after the answer is complete — a placement matched to the question alone matches noticeably worse.
Caching. Identical requests inside 90 seconds can reuse the previous result rather than paying for another auction.
Nothing on a 204. No placeholder, no empty bordered box, no retry loop.
Telling us you exist
Send "v" on every /v1/slot call — any string that identifies your client, e.g. "flutter-1.0".
Without it your integration reports no version, and in our dashboard an app that is installed and matching no demand looks identical to one that was never installed. Those need opposite responses from us, and we would rather help you with the right one.
Rules that are terms, not suggestions
- The disclosure stays visible and legible.
slot.disclosureis"Sponsored". It is not restylable away. - Never present a placement as your app's own recommendation, or as output of your own model.
- No artificial impressions or clicks. We do not pay for traffic we reasonably determine is not genuine.
Testing without a key
pub_test, pub_sandbox and pub_demo work immediately, fill generously so your first run is never blank, bill nobody, and mark every placement with "test": true. Sandbox numbers are meaningless on purpose — they prove the mechanism, not the match quality.
curl -s -X POST https://exchange.zeekend.com/v1/slot \
-H 'content-type: application/json' \
-d '{"publisherKey":"pub_sandbox","placementId":"chat","sessionId":"ses-1","turn":3,
"context":{"type":"conversation","question":"waterproof jacket for the Alps"}}'
Questions: hello@zeekend.com