Skip to main content
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.
Data plane. This endpoint is part of the public data plane — the runtime decisioning loop (/recommend, /respond, /respond/bulk, /capture) — and is callable with any krn_ API key, including default keys minted without scopes. Management endpoints, by contrast, require control-plane access (see API Keys).

What it does

The endpoint accepts an outcomes: [...] array of 1 to 1000 items. Each item is processed sequentially in batches of 50. 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 database transaction that records one interaction-history row plus one outcome.recorded outbox event. A failed item never aborts the batch — the response accumulates an errors[] array of { index, error } entries and continues. The response status is 200 when at least one item succeeded and 422 when every item failed. Per-item summary aggregation runs as fire-and-forget after the transaction commits.

Quick start

Response (mixed success):

How it works

Authentication and rate limiting

Every call goes through the same tenant-resolution layer as /recommend. After tenant resolution, the handler applies a per-tenant rate-limit window — 100 requests per 60 seconds for playground tenants and 1000 requests per 60 seconds for non-playground tenants. The rate limiter runs in fail-closed mode — when the limiter backend is unreachable the request is rejected rather than allowed through.

Validation

The request body is validated server-side and rejected with 400 Bad Request when any of the following are violated:
  • outcomes length is between 1 and 1000.
  • Each item must include customerId, offerId, and outcome as non-empty strings.
  • 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. 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 (the rest of the batch continues).

Idempotency

Each item gets an idempotencyKey. If the caller supplied one it is used verbatim; otherwise the endpoint synthesizes one from customerId + offerId + creativeId + outcomeKey + 5-minute time bucket. The deduplication identifier is customerId:offerId:creativeId:outcomeKey:idempotencyKey. Inside the per-item transaction, the endpoint first checks for an existing interaction-history row with the same idempotency key for the tenant. When a row already exists, the item is reported as succeeded without a duplicate insert.

Outbox-backed event delivery

After each successful insert, the same transaction writes one outcome.recorded outbox event — the event row is durable in the same commit as the interaction row. The dedicated outbox publisher then picks the row up and delivers to the configured event-publisher backend. See Outbox publisher.

Conversion value resolution

When conversionValue is omitted and the outcome’s classification is "positive", the endpoint falls back to the offer’s businessValue. 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. Per-item entries are not created — the audit chain records the batch as one unit.

Reference

Request body

array
required
Array of 1 to 1000 outcome items.

outcomes[] per-item shape

string
required
Customer identifier the outcome is attributed to.
string
required
Offer identifier the outcome targets. Must exist for the tenant or the item is rejected with Offer not found.
string
required
Outcome type key. Must match an outcome-type key registered for the tenant via /api/v1/outcome-types.
string
Optional creative identifier. When supplied, the endpoint auto-resolves channelId and placementId from the creative. Required for impression-class outcomes that need channel attribution.
string
Channel identifier. Defaults to the creative’s channelId when creativeId is supplied.
string
Placement identifier. Defaults to the creative’s placementId when creativeId is supplied.
string
Channel name passed through to interaction.context.channel for analytics filters. Not used for routing.
string
Placement name passed through to interaction.context.placement.
string
ISO timestamp the outcome occurred. Defaults to the current server time when omitted.
string
Operator-supplied idempotency key. When omitted the endpoint synthesizes one from customerId + offerId + creativeId + outcomeKey + 5-minute time bucket.
object
Free-form context merged into the recorded interaction.context JSON. The endpoint always adds bulk: true so consumers can distinguish bulk-imported events.
object
Free-form details merged into the recorded interaction.outcome JSON alongside conversionValue.
number
Monetary value of the outcome. Falls back to the offer’s businessValue for positive outcomes when omitted.
string
"inbound" or "outbound". Defaults to "outbound" for impression-category outcomes and "inbound" for everything else.

Response

number
Total items in the request body — equals outcomes.length.
number
Items that produced a new interaction-history insert OR were idempotency-deduplicated against an existing row.
number
Items that produced an error. Per-item details are in 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

Required headers

Configuration

Environment variables

The endpoint 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

Honest limits

  • Per-item processing is sequential, not parallel. A 1000-item batch produces up to 1000 sequential database transactions — wall time scales linearly with batch size. Sustained throughput is bounded by Postgres write contention, not API replicas.
  • Per-customer/offer summary aggregation runs fire-and-forget after each transaction commits. When the call rejects, the warning is logged but the item still counts as succeeded. Aggregate counters may briefly lag the underlying interaction rows.
  • The endpoint 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.