POST /api/v1/respond
Records the outcome of a recommendation delivered via the Recommend API. Outcomes feed into behavioral metrics, adaptive model training, experiment analysis, and attribution.
Minimal Request (Recommended)
The smallest way to record an outcome — 4 fields. The system resolves the offer, creative, and channel from the recommendation record.
{
"customerId": "CUST001",
"recommendationId": "rec_7f3a2b1c-9d4e-5f6a-8b7c-0d1e2f3a4b5c",
"rank": 1,
"outcome": "click"
}
Full Request (all optional fields)
{
"customerId": "CUST001",
"recommendationId": "rec_7f3a2b1c-9d4e-5f6a-8b7c-0d1e2f3a4b5c",
"rank": 1,
"outcome": "click",
"idempotencyKey": "click-cust001-offer001-1710590400",
"creativeId": "creative_001",
"offerId": "offer_001",
"channelId": "channel_email",
"direction": "outbound",
"context": {
"source": "email_campaign_q1",
"utm_medium": "email"
},
"outcomeDetails": {
"product_sku": "SKU-12345"
},
"timestamp": "2026-03-16T14:35:00.000Z",
"conversionValue": 149.99
}
Field Reference
| Field | Type | Required | Description |
|---|
customerId | string | Yes | The customer who interacted with the recommendation |
recommendationId | string | Yes* | The recommendationId from the Recommend API response. Used with rank to resolve the offer automatically |
rank | integer | Yes* | Which offer from the recommendation (1-based). Combined with recommendationId to look up the offer, creative, and channel |
outcome | string | Yes | The outcome type key (e.g., "click", "accept", "dismiss", "convert", "renewed", "unsubscribed"). Must match a registered Outcome Type |
creativeId | string | No | Explicit Creative ID. Only needed if NOT using recommendationId + rank |
idempotencyKey | string | Yes | Required. Unique key to prevent double-counting. Returns 400 if missing. Can also be sent as Idempotency-Key header |
interactionId | string | No | Legacy field — use recommendationId instead |
direction | string | No | Direction of the interaction: "inbound" or "outbound". Default is "inbound" for most outcome types. For impression-category outcomes (e.g., impression, delivery), the default is "outbound" since those represent platform-initiated contacts |
offerId | string | No | Offer ID. If omitted, resolved automatically from the creative’s parent offer |
channelId | string | No | Channel ID. Always resolved from the creative when creativeId is provided, regardless of whether channelId is passed in the request body. This ensures interaction summaries and contact policy frequency caps use the correct channel. |
placementId | string | No | Placement ID. If omitted, resolved from the creative |
rank | integer | No | The position at which the offer was displayed (1-indexed) |
context | object | No | Arbitrary context metadata (campaign source, UTM params, etc.) |
outcomeDetails | object | No | Structured details about the outcome (e.g., product SKU, plan selected) |
timestamp | string | No | ISO 8601 timestamp of the interaction. Defaults to the current server time |
conversionValue | number | No | Monetary value of the conversion (used for attribution and revenue reporting) |
attributes | object | No | Additional attributes (device, browser, etc.). Used for online model learning |
channel | string | No | Channel name string (stored in context) |
placement | string | No | Placement name string (stored in context) |
responseTime | number | No | Time in milliseconds the customer took to respond |
deviceType | string | No | Device type string (stored in context) |
Fields marked Yes* require at least one resolution path. You must provide either creativeId OR recommendationId + rank to identify the offer. The field outcome is required (the legacy alias interactionType is also accepted).
Prior to this fix, channelId was only resolved when offerId was missing, causing per-channel frequency caps to be silently broken.
Attribution — how recommendationId + rank resolves the offer
When recommendationId and rank are supplied (without a direct creativeId), the route looks up the original recommendation through a three-tier ladder. Tiers run in order; the first match wins.
| Tier | How it matches | When it applies |
|---|
| 1 — column match | recommendationId column = supplied value AND rank = supplied value | Recommendation rows written after the 2026-06-07 fix. The dedicated column index makes this the fastest and most precise path; a concurrent call for the same customer at the same rank cannot cross-attribute. |
| 2 — legacy JSON match | response->>'interactionId' = supplied recommendationId AND rank = supplied value | Recommendation/impression rows written before the fix, when the interaction ID was stored only inside the response JSONB. Rank is still filtered — the stored rank matches exactly what /recommend returned, so keeping the clause cannot drop a legitimate match. |
| 3 — rank-only fallback | Most recent recommendation or impression row for customerId + rank | Reached only when tiers 1 and 2 both miss (e.g. a row exists but carries no recommendationId in either the column or the JSONB). Callers that never send recommendationId, and callers that supply creativeId directly, skip all three tiers. |
If none of the three tiers resolve a row, the route returns 400 No recommendation found for customer=… rank=….
Limitation: multi-placement /recommend responses return a recommendationId in the response body but write no interaction_history rows (pre-existing behaviour). Precise tier-1 attribution therefore applies to single-flow recommendations and auto-impressions only. Multi-placement callers should supply creativeId directly.
Idempotency
Every call to the Respond API requires an idempotency key to prevent duplicate outcome recording. You can provide it in two ways:
- Request body:
"idempotencyKey": "unique-key-here"
- HTTP header:
Idempotency-Key: unique-key-here
If both are provided, the body value takes precedence.
When a duplicate key is detected, the API returns the original record with "status": "already_recorded" and a 200 status code (not 201).
Response: New Record (201)
The response includes enriched names for easy display without additional lookups.
{
"interactionId": "550e8400-e29b-41d4-a716-446655440000",
"recommendationId": "c6184851-54f8-49df-88e3-0b11ed3b9fcb",
"customerId": "CUST-00500",
"outcome": "click",
"classification": "positive",
"rank": 4,
"offerName": "Multi-Policy Bundle",
"creativeName": "Multi-Policy Bundle — Email",
"channelName": "Email Campaign",
"categoryName": "Policy Renewal",
"status": "recorded",
"timestamp": "2026-03-16T14:35:00.000Z"
}
Response: Duplicate (200)
When the same idempotencyKey is sent again:
{
"interactionId": "550e8400-e29b-41d4-a716-446655440000",
"recommendationId": "c6184851-54f8-49df-88e3-0b11ed3b9fcb",
"customerId": "CUST-00500",
"outcome": "click",
"status": "already_recorded",
"timestamp": "2026-03-16T14:35:00.000Z"
}
Outcome Types
Outcomes must be registered in the platform before they can be recorded. Each outcome type has a classification that drives downstream behavior:
| Classification | Effect | Examples |
|---|
positive | Increments positive counters, triggers attribution, updates adaptive models with reward | click, accept, convert, purchase |
negative | Increments negative counters, updates models with penalty | dismiss, not_interested, unsubscribe |
neutral | Tracked but does not affect scoring | impression, view, requested_info |
Register outcome types in Studio > Outcome Types or via POST /api/v1/outcome-types.
Examples
Record an Impression
curl -X POST https://playground.kaireonai.com/api/v1/respond \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: my-tenant" \
-H "X-Api-Key: sk_live_abc123" \
-d '{
"customerId": "CUST001",
"creativeId": "creative_001",
"outcome": "impression",
"idempotencyKey": "imp-cust001-creative001-20260316-1430",
"interactionId": "550e8400-e29b-41d4-a716-446655440000",
"rank": 1
}'
Record a Click
curl -X POST https://playground.kaireonai.com/api/v1/respond \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: my-tenant" \
-H "X-Api-Key: sk_live_abc123" \
-d '{
"customerId": "CUST001",
"creativeId": "creative_001",
"outcome": "click",
"idempotencyKey": "click-cust001-creative001-20260316-1435",
"interactionId": "550e8400-e29b-41d4-a716-446655440000",
"context": {
"source": "email_hero_banner"
}
}'
Record a Conversion with Value
curl -X POST https://playground.kaireonai.com/api/v1/respond \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: my-tenant" \
-H "X-Api-Key: sk_live_abc123" \
-d '{
"customerId": "CUST001",
"creativeId": "creative_001",
"outcome": "convert",
"idempotencyKey": "conv-cust001-offer001-20260316",
"interactionId": "550e8400-e29b-41d4-a716-446655440000",
"offerId": "offer_001",
"conversionValue": 299.99,
"outcomeDetails": {
"plan": "platinum",
"term_months": 12
}
}'
Error Responses
| Status | Cause |
|---|
400 | Missing required fields (customerId, creativeId/treatmentId, outcome/interactionType, idempotencyKey), unknown outcome type, or invalid JSON |
401 | Missing or invalid X-Tenant-Id / API key |
404 | Creative not found or belongs to another tenant |
415 | Content-Type header is not application/json |
429 | Rate limit exceeded (1,000 requests per 60s per tenant) |
500 | Internal server error |
Example error (unknown outcome type):
{
"error": {
"code": "BAD_REQUEST",
"message": "Unknown outcome type: \"purchased\". Register it in Outcome Types first.",
"status": 400,
"recommendationId": "d4e5f678-90ab-cdef-1234-567890abcdef",
"timestamp": "2026-03-16T14:35:00.000Z"
}
}
See also: Outcome Types | Behavioral Metrics | API Tutorial