POST /v1/offers/decide
The only V1 offer decision endpoint. Returns a labeled offer or a successful no-fill.
Decide whether the current turn may receive a labeled, tracked offer.
Authentication
Bearer serving key required. Setup tokens are rejected. The app is inferred from the key.
Rate limit
60 requests per minute per client IP. HTTP 429 with `{ ok: false, error: "rate_limited" }`.
Request headers
| Header | Value |
|---|---|
| Authorization | Bearer $KNOBASE_API_KEY |
| Content-Type | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| message | string (max 4000) | No | Current user turn only. Processed transiently and not persisted. Do not send a transcript. |
| session.token | string (max 128) | No | Ephemeral host session token for frequency. Not a user id. |
| session.digest.intent_type | "purchase" | "compare" | "browse" | "low" | "unknown" | No | Typed intent. No free text. |
| session.digest.category_ids | string[] (max 20, each max 64) | No | Allowlisted category identifiers only. |
| session.digest.entity_ids | string[] (max 20, each max 64) | No | Allowlisted entity identifiers only. |
| session.digest.excluded_entity_ids | string[] (max 20, each max 64) | No | Allowlisted exclusions. Unknown ids are dropped. |
| session.digest.budget_bucket | "unknown" | "low" | "mid" | "high" | No | Coarse budget band. No currency or amounts. |
| session.digest.safety | "clear" | "unknown" | "blocked" | No | `unknown` or `blocked` results in safe no-fill. |
| session.digest.generated_at | string (max 40) | No | ISO timestamp. Digests older than 30 minutes are ignored. |
| locale | string (2–16) | No | Serving is live for `ko*` and `en*` locales only. |
| country | string (2–8) | No | ISO-style country hint. Not a precise location. |
| audience.age_band | "under_13" | "13_17" | "18_plus" | "unknown" | No | Never infer age. Serving requires `18_plus`. |
| audience.contextual_offer_consent | boolean | No | Serving requires `true`. Omitted or false is no-fill. |
| native_surface.clickable | boolean | Yes | Self-attestation that an existing separate clickable surface is available. Not production trust. |
| native_surface.disclosure_capable | boolean | Yes | Self-attestation that disclosure and advertiser identity can be shown. False is CAPABILITY_UNMET. |
Constraints
- message is the current user turn only. Do not send a raw full transcript.
- No personal identity, email, phone, advertising ID, precise location, health, financial, biometric, or cross-app identity data.
- The current message is processed transiently and is not persisted.
- Typed digest is optional and has no free text.
- Unknown consent, age, or safety results in safe no-fill.
- Serving requires audience.age_band = 18_plus and audience.contextual_offer_consent = true.
- native_surface.clickable and native_surface.disclosure_capable are required self-attestation booleans. Either false is CAPABILITY_UNMET.
- Unknown top-level keys fail validation. The envelope is strict.
- Treat timeout, network errors, malformed responses, and serve:false as no-render.
These keys are rejected anywhere in the body: email, phone, tel, user_id, userid, userId, name, full_name, fullname, lat, lng, latitude, longitude, address, ip, conversation, conversation_history, messages, raw_message, app_id, placement, display_style, surface_id, capabilities.
Request examples
curl -sS -X POST "https://knobase.com/v1/offers/decide" \
-H "Authorization: Bearer $KNOBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"message":"시니어 반려견에게 줄 부드러운 사료를 찾고 있어요","locale":"ko","country":"KR","audience":{"age_band":"18_plus","contextual_offer_consent":true},"native_surface":{"clickable":true,"disclosure_capable":true}}'Served-offer response
HTTP 200. disclosure, advertiser, and link are required commercial-integrity fields. title, description, image_url, and cta are optional presentation hints and do not instruct you to create a visual component. offer_id is the snapshot id used as offer_snapshot_id on events.
| Field | Type | Required | Description |
|---|---|---|---|
| serve | true | Yes | An eligible offer may be used on an existing surface. |
| request_id | string | Yes | Request identifier. |
| decision_id | string | Yes | Decision identifier (`dec_…`). |
| offer_id | string | Yes | Offer snapshot id (`snp_…`). Send this as `offer_snapshot_id` on events. |
| expires_at | string | null | Yes | ISO expiry when present. |
| disclosure | string | Yes | Required sponsorship label. Must be visible. |
| advertiser | string | Yes | Required advertiser identity. Must be visible. |
| link | string | Yes | Knobase tracked URL. Never replace with a merchant URL. |
| title | string | No | Optional presentation hint. Not a UI instruction. |
| description | string | No | Optional presentation hint. Not a UI instruction. |
| image_url | string | No | Optional presentation hint. Not a UI instruction. |
| cta | string | No | Optional presentation hint. Not a UI instruction. |
| reason_codes | string[] | Yes | Includes `MATCHED` when an offer is served. |
{
"serve": true,
"request_id": "req_01",
"decision_id": "dec_01",
"offer_id": "snp_01EXAMPLEOFFER",
"expires_at": "2026-09-10T12:30:00.000Z",
"disclosure": "광고",
"advertiser": "Example Pet",
"link": "https://knobase.com/r/tok_example",
"title": "시니어 반려견을 위한 부드러운 식사 옵션",
"description": "제품 정보와 원재료를 확인해보세요.",
"cta": "제품 보기",
"reason_codes": [
"MATCHED"
]
}No-fill response
HTTP 200 with serve:false is success. Keep the organic answer. Do not retry in a way that blocks the user.
| Field | Type | Required | Description |
|---|---|---|---|
| serve | false | Yes | Do not render an offer. |
| request_id | string | Yes | Request identifier. |
| decision_id | string | Yes | Decision identifier (`dec_…`). |
| reason_codes | string[] | Yes | Why no offer was returned. |
| retry_after_seconds | number | No | Present on some no-fill outcomes, including timeout. |
{
"serve": false,
"request_id": "req_02",
"decision_id": "dec_02",
"reason_codes": [
"NO_ELIGIBLE_OFFER"
],
"retry_after_seconds": 0
}Reason codes
| Code | Meaning |
|---|---|
| POLICY_BLOCK | Audience, safety, or policy did not allow a serve. |
| LOW_INTENT | Intent was too weak to serve. |
| NO_ELIGIBLE_OFFER | No matching eligible offer. |
| BUDGET_EXHAUSTED | Campaign or account budget is exhausted. |
| TIMEOUT | Handler budget exceeded. Fail open. |
| LOCALE_NOT_LIVE | Locale is not in the live serving set. |
| FREQUENCY_CAPPED | Session frequency cap reached. |
| MATCHED | An eligible offer was returned. |
| INVALID_ENVELOPE | Request failed schema or unknown-field validation. |
| FORBIDDEN_IDENTITY_FIELD | A forbidden identity or placement field was present. |
| CAPABILITY_UNMET | native_surface.clickable or disclosure_capable was false. |
Errors
| HTTP | Body | When |
|---|---|---|
| 401 | { "ok": false, "error": "…" } | Missing, malformed, invalid, or revoked key |
| 400 | { "ok": false, "error": "INVALID_ENVELOPE" | "FORBIDDEN_IDENTITY_FIELD", "reason_codes": […] } | Invalid or forbidden body |
| 429 | { "ok": false, "error": "rate_limited" } | More than 60 requests / minute / IP |
| 200 | serve:false, reason_codes includes TIMEOUT | Handler budget exceeded; fail open |