# Zeekend REST integration

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

```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:

```json
{ "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:

```json
{
  "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

```json
{ "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:

```json
{ "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)

```json
{ "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.disclosure` is
  `"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.

```bash
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
