> ## 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.

# Respond Bulk

> Record up to 1000 customer outcomes in one request with per-item idempotency, partial-success reporting, and outbox-backed event delivery

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

<Note>
  **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](/api-reference/api-keys)).
</Note>

## 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

```bash theme={null}
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):

```json theme={null}
{
  "processed": 2,
  "succeeded": 1,
  "failed": 1,
  "errors": [
    { "index": 1, "error": "Offer not found" }
  ]
}
```

## 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](/self-host/configure/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

<ParamField body="outcomes" type="array" required>
  Array of 1 to 1000 outcome items.
</ParamField>

#### `outcomes[]` per-item shape

<ParamField body="customerId" type="string" required>
  Customer identifier the outcome is attributed to.
</ParamField>

<ParamField body="offerId" type="string" required>
  Offer identifier the outcome targets. Must exist for the tenant or the item is rejected with `Offer not found`.
</ParamField>

<ParamField body="outcome" type="string" required>
  Outcome type key. Must match an outcome-type key registered for the tenant via `/api/v1/outcome-types`.
</ParamField>

<ParamField body="creativeId" type="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.
</ParamField>

<ParamField body="channelId" type="string">
  Channel identifier. Defaults to the creative's `channelId` when `creativeId` is supplied.
</ParamField>

<ParamField body="placementId" type="string">
  Placement identifier. Defaults to the creative's `placementId` when `creativeId` is supplied.
</ParamField>

<ParamField body="channel" type="string">
  Channel name passed through to `interaction.context.channel` for analytics filters. Not used for routing.
</ParamField>

<ParamField body="placement" type="string">
  Placement name passed through to `interaction.context.placement`.
</ParamField>

<ParamField body="timestamp" type="string">
  ISO timestamp the outcome occurred. Defaults to the current server time when omitted.
</ParamField>

<ParamField body="idempotencyKey" type="string">
  Operator-supplied idempotency key. When omitted the endpoint synthesizes one from `customerId + offerId + creativeId + outcomeKey + 5-minute time bucket`.
</ParamField>

<ParamField body="context" type="object">
  Free-form context merged into the recorded `interaction.context` JSON. The endpoint always adds `bulk: true` so consumers can distinguish bulk-imported events.
</ParamField>

<ParamField body="outcomeDetails" type="object">
  Free-form details merged into the recorded `interaction.outcome` JSON alongside `conversionValue`.
</ParamField>

<ParamField body="conversionValue" type="number">
  Monetary value of the outcome. Falls back to the offer's `businessValue` for positive outcomes when omitted.
</ParamField>

<ParamField body="direction" type="string">
  `"inbound"` or `"outbound"`. Defaults to `"outbound"` for impression-category outcomes and `"inbound"` for everything else.
</ParamField>

### Response

<ResponseField name="processed" type="number">
  Total items in the request body — equals `outcomes.length`.
</ResponseField>

<ResponseField name="succeeded" type="number">
  Items that produced a new interaction-history insert OR were idempotency-deduplicated against an existing row.
</ResponseField>

<ResponseField name="failed" type="number">
  Items that produced an error. Per-item details are in `errors[]`.
</ResponseField>

<ResponseField name="errors" type="array">
  Present only when `failed > 0`. Each entry: `{ index: number, error: string }`. `index` is the 0-based position in the request `outcomes[]` array.
</ResponseField>

### Status codes

| Code | When                                           |
| ---- | ---------------------------------------------- |
| 200  | At least one item succeeded                    |
| 400  | Invalid JSON body or schema validation failure |
| 401  | Missing tenant context                         |
| 403  | Invalid tenant identifier                      |
| 422  | Every item in the batch failed                 |
| 429  | Rate limit exceeded                            |
| 500  | Unexpected error before the per-item loop      |

### Required headers

| Header         | Required             | Purpose            |
| -------------- | -------------------- | ------------------ |
| `Content-Type` | Yes                  | `application/json` |
| `X-API-Key`    | Yes (one of the two) | API key (`krn_…`)  |
| `X-Tenant-Id`  | Yes (one of the two) | Direct tenant id   |

## Configuration

### Environment variables

| Variable        | Effect                                    |
| --------------- | ----------------------------------------- |
| `NODE_ENV=test` | Disables rate limiting in the test runner |

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

| Setting                              | Value      |
| ------------------------------------ | ---------- |
| Maximum items per request            | 1000       |
| Chunk size for sequential processing | 50         |
| Rate limit (playground tenants)      | 100 / 60s  |
| Rate limit (non-playground)          | 1000 / 60s |

## 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.

## Related

* [Respond API](/api-reference/respond) — single-outcome endpoint with the same per-item contract.
* [Outcome Types](/api-reference/outcome-types) — register the outcome keys this endpoint validates against.
* [Outbox publisher](/self-host/configure/outbox-publisher) — delivers the `outcome.recorded` events written by this endpoint.
* [Cron tier](/self-host/configure/cron-tier) — runs `/api/v1/cron/outbox-reaper` to recover stuck outbox rows.
