Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt

Use this file to discover all available pages before exploring further.

POST /api/v1/respond/bulk records multiple outcome events in a single round trip and returns a per-item success/failure manifest. The route is designed for batch loaders, file imports, and replay tools that need to ingest historical conversions without the per-call overhead of POST /api/v1/respond.

What it does

The handler at src/app/api/v1/respond/bulk/route.ts accepts an outcomes: [...] array (1 to 1000 items) validated against BulkRespondSchema declared inline at route.ts:37-39. Each item is processed sequentially in chunks of 50 (CHUNK_SIZE at route.ts:43). Per-item logic mirrors the singular /respond route: outcome-type lookup, creative resolution for channelId and placementId, offer existence check, idempotency-key check, then a single prisma.$transaction that writes one interaction_history row plus one outbox_events row of topic outcome.recorded (route.ts:142-192). A failed item never aborts the batch — the route accumulates errors[] with { index, error } entries and continues. The response status is 200 when at least one item succeeded and 422 when every item failed (route.ts:239). Per-item summary aggregation runs as fire-and-forget after the transaction commits (route.ts:196-209).

Quick start

curl -X POST https://playground.kaireonai.com/api/v1/respond/bulk \
  -H "Content-Type: application/json" \
  -H "X-API-Key: krn_your_api_key" \
  -H "X-Tenant-Id: 5a9904b9-..." \
  -d '{
    "outcomes": [
      {
        "customerId": "cust_42",
        "offerId": "off_premium_card",
        "creativeId": "crv_email_a",
        "outcome": "click",
        "timestamp": "2026-04-30T10:15:00Z",
        "context": { "device": "mobile" }
      },
      {
        "customerId": "cust_99",
        "offerId": "off_savings_acct",
        "outcome": "convert",
        "conversionValue": 250.00,
        "idempotencyKey": "import-batch-2026-04-30:line-7"
      }
    ]
  }'
Response (mixed success):
{
  "processed": 2,
  "succeeded": 1,
  "failed": 1,
  "errors": [
    { "index": 1, "error": "Offer not found" }
  ]
}

How it works

Authentication and rate limiting

Every call passes through requireTenant at src/lib/tenant.ts:88 (same surface as /recommend). After tenant resolution, the handler checks isPlaygroundTenant(t.tenantId) at src/lib/playground-guard.ts:15 and applies playgroundRateLimit window (route.ts:58-60). The rate limiter runs in failMode: "closed" — when the limiter cannot reach Redis the request is rejected.

Validation

The body is parsed by parseJsonBody and validated by validateBody(BulkRespondSchema, ...) from src/lib/api-validate.ts (route.ts:62-66). The schema enforces:
  • outcomes length is between 1 and 1000 (route.ts:38).
  • Each item must include customerId, offerId, and outcome (all z.string().min(1)).
  • direction if supplied must be "inbound" or "outbound".
  • conversionValue, when supplied, must be a number.

Outcome-type lookup

Outcome types are loaded once for the whole batch (route.ts:69-72). When an item names an outcome that is not registered for the tenant, the per-item error is Unknown outcome type: "<key>" and the item is skipped (route.ts:91-95).

Idempotency

Each item gets an idempotencyKey. If the caller supplied one it is used verbatim; otherwise the route synthesizes one from customerId + offerId + creativeId + outcomeKey + 5-minute time bucket (route.ts:137-139). The deduplicationId is customerId:offerId:creativeId:outcomeKey:idempotencyKey (route.ts:140). The transaction at route.ts:143-192 first runs tx.interactionHistory.findFirst({ where: { idempotencyKey, tenantId } }). When a row already exists, the transaction returns { alreadyRecorded: true } and the item counts as succeeded without a duplicate insert.

Outbox-backed event delivery

After each successful insert, the same transaction calls createOutboxEvent(tx, { topic: "outcome.recorded", payload: {...} }) (route.ts:178-189) — the event row is durable in the same commit as the interaction row. The dedicated outbox publisher pod picks the row up and delivers to the configured EventPublisher backend. See Outbox publisher.

Conversion value resolution

When conversionValue is omitted and the outcome’s classification is "positive", the route falls back to the offer’s businessValue (route.ts:131-134). For neutral or negative outcomes the conversion value stays 0.

Audit log

A single bulk_create audit entry summarizing { processed, succeeded, failed } is written after the loop completes (route.ts:223-230). Per-item entries are not created — the audit chain records the batch as one unit.

Reference

Request body

outcomes
array
required
Array of 1 to 1000 outcome items. Validated by BulkRespondSchema at route.ts:37-39.

outcomes[] per-item shape

Validated by BulkOutcomeItem at route.ts:20-35.
customerId
string
required
Customer identifier the outcome is attributed to.
offerId
string
required
Offer identifier the outcome targets. Must exist for the tenant or the item is rejected with Offer not found.
outcome
string
required
Outcome type key. Must match an OutcomeType.key registered for the tenant via /api/v1/outcome-types (route.ts:69-72).
creativeId
string
Optional creative identifier. When supplied, the route auto-resolves channelId and placementId from the creative row (route.ts:103-115). Required for impression-class outcomes that need channel attribution.
channelId
string
Channel identifier. Defaults to the creative’s channelId when creativeId is supplied.
placementId
string
Placement identifier. Defaults to the creative’s placementId when creativeId is supplied.
channel
string
Channel name passed through to interaction.context.channel for analytics filters (route.ts:162). Not used for routing.
placement
string
Placement name passed through to interaction.context.placement (route.ts:163).
timestamp
string
ISO timestamp the outcome occurred. Defaults to new Date() when omitted (route.ts:171).
idempotencyKey
string
Operator-supplied idempotency key. When omitted the route synthesizes one from customerId + offerId + creativeId + outcomeKey + 5-minute time bucket (route.ts:137-139).
context
object
Free-form context merged into interaction.context JSON. The route always adds bulk: true (route.ts:160-165).
outcomeDetails
object
Free-form details merged into interaction.outcome JSON alongside conversionValue (route.ts:167-170).
conversionValue
number
Monetary value of the outcome. Falls back to the offer’s businessValue for positive outcomes when omitted (route.ts:131-134).
direction
string
"inbound" or "outbound". Defaults to "outbound" for impression-category outcomes and "inbound" for everything else (route.ts:128).

Response

Returned at route.ts:232-240.
processed
number
Total items in the request body — equals outcomes.length.
succeeded
number
Items that produced an interaction_history insert OR were idempotency-deduplicated against an existing row.
failed
number
Items that produced an error. Per-item details are in errors[].
errors
array
Present only when failed > 0. Each entry: { index: number, error: string }. index is the 0-based position in the request outcomes[] array.

Status codes

CodeWhenSource
200At least one item succeededroute.ts:239
400Invalid JSON body or schema validation failureparseJsonBody / validateBody at route.ts:62-65
401Missing tenant contexttenant.ts:117
403Invalid tenant identifiertenant.ts:128
422Every item in the batch failedroute.ts:239
429Rate limit exceededplaygroundRateLimit at playground-guard.ts:37
500Unexpected error before the per-item loopserverError from src/lib/api-error.ts:90

Required headers

HeaderRequiredRead atPurpose
Content-TypeYesNext.js frameworkapplication/json
X-API-KeyYes (one of the two)tenant.ts:97API key (krn_…)
X-Tenant-IdYes (one of the two)tenant.ts:113Direct tenant id

Configuration

Environment variables

VariableEffect
NODE_ENV=testDisables rate limiting in the test runner (rate-limit-unified.ts:101)
The route reads no other environment variables directly. The downstream outbox publisher reads OUTBOX_LIVENESS_FILE and the outbox-reaper cron reads OUTBOX_REAPER_STALENESS_SECONDS.

Limits

SettingValueSource
Maximum items per request1000BulkRespondSchema.outcomes.max(1000) at route.ts:38
Chunk size for sequential processing50CHUNK_SIZE at route.ts:43
Rate limit (playground tenants)100 / 60splaygroundRateLimit at playground-guard.ts:37
Rate limit (non-playground)1000 / 60splaygroundRateLimit at playground-guard.ts:37

Honest limits

  • Per-item processing is sequential, not parallel. A 1000-item batch produces up to 1000 sequential prisma.$transaction calls — wall time scales linearly with batch size. Sustained throughput is bounded by Postgres write contention, not API replicas.
  • Summary updates (updateSummaries) run fire-and-forget after each transaction commits (route.ts:196-209). When the call rejects, the warning is logged but the item still counts as succeeded. Aggregate counters in interaction_summary may briefly lag the underlying interaction rows.
  • The route does not expose a dryRun flag. To validate a batch shape without writing, the caller has to send a single-item request and discard the result.
  • The errors[] array is included only when failed > 0. Callers checking response.errors.length must handle the missing-field case.
  • An idempotency-deduplicated item is reported as succeeded, not as a separate counter. Callers cannot distinguish “newly recorded” from “already recorded” from the response alone.
  • Respond API — single-outcome endpoint with the same per-item contract.
  • Outcome Types — register the outcome keys this endpoint validates against.
  • Outbox publisher — delivers the outcome.recorded events written by this endpoint.
  • Cron tier — runs /api/v1/cron/outbox-reaper to recover stuck outbox rows.