# Batches and asynchronous results

Source: https://kirora.io/docs/api/batches

Send up to 100 contacts, events or messages in one call, read each item's result, retry safely, and know that accepted is not delivered.

Three operations take up to 100 items in one request: `identifyContactsBatch`
(`POST /v1/contacts/batch`), `trackEventsBatch` (`POST /v1/events/batch`) and
`sendTransactionalMessagesBatch` (`POST /v1/transactional/messages/batch`). A batch
saves round trips. It does not save rate budget, and a successful status does not mean
every item succeeded: you read the result of each item.

## One result per item, in order

The response holds `results`, one entry for each item you sent, in the same order. An
entry holds either what was written or an `error`: the same `code`, `message` and
`details` a single call would have returned. One refused item does not stop the others.

```json
{
  "data": {
    "results": [
      { "created": true, "contact": { "id": "0192f3a1-…", "externalId": "customer-881" } },
      {
        "error": {
          "code": "CONFLICT",
          "message": "The email belongs to a contact with a different externalId; …",
          "details": { "field": "externalId" }
        }
      }
    ]
  }
}
```

The position in `results` is the position in your request, so `results[1]` is the second
item you sent. Keep your items in an array until you have read the results.

| Operation | Status | A written item holds | Item key |
| --- | --- | --- | --- |
| `identifyContactsBatch` | 200 | `created` and the `contact` | None: identify converges, so a repeat sets the same values |
| `trackEventsBatch` | 200 | `idempotencyKey`, `replayed` and the `event` | `idempotencyKey`, optional; without one a retry records the event again |
| `sendTransactionalMessagesBatch` | 202 | `idempotencyKey`, `replayed`, the `messages` (one per channel stored) and `skippedChannels` | `idempotencyKey`, optional; without one a retry sends again |

## What is checked before any item runs

The batch's own shape is checked first: at least 1 and at most 100 items, and no
`idempotencyKey` used twice in the call. A batch that fails it is refused whole with
400 `VALIDATION_ERROR`, and nothing runs.

After that, each item is checked on its own, in all three. An item that fails
validation, such as a contact with a malformed email, is that item's
`VALIDATION_ERROR`, with its issues in `errors`, and the rest still run. A nightly sync
of 1,000 customers in ten batches writes the 999 valid ones and reports the one to fix.
An item refused for what is stored, such as an identity conflict, is likewise that
item's `error` only.

Credential checks apply to the whole request: a key without the scope, or an `agent`
key, refuses the batch with 403. The plan's event allowance is item by item: once it is
used up, the remaining events are each refused with `PLAN_LIMIT_REACHED`.

## Rate limits count each item

A batch counts against the [rate limit](https://kirora.io/docs/api/reference/rate-limits) as one request per item, once
its shape is accepted. An item that is then refused still counts. Contacts and
transactional sends draw on the high-volume budget, events on the events budget.

## Retrying a batch

After a timeout or a 5xx, send the same batch again with the same keys:

- An event or a transactional message already stored returns with `replayed: true`, and
  nothing is recorded or sent twice.
- An event or transactional message sent without `idempotencyKey` is recorded or sent
  again. Give every entry a key derived from what it is, such as
  `order-<order id>-completed` or `receipt-<order id>`.
- An identify sets the same values again, which is safe.

Unlike other `POST`s, a batch takes no `Idempotency-Key` header: the per-item keys are
what protect it. They recognise a send that first went out on its own, and they last as
long as what they created, so retry a failed batch whole with the same item keys. See
[Idempotency](https://kirora.io/docs/api/reference/idempotency).

To retry only the failures, resend the items whose entries carry an `error` you can fix,
with the same keys. Do not resend an item that failed with a 4xx unchanged: it fails the
same way.

## Example: collect the failures

This sends one batch of contacts and returns the indexes and codes of those refused:

```ts
type BatchResult = { error?: { code: string; message: string } };

export async function identifyContacts(contacts: object[]) {
  const response = await fetch('https://api.kirora.io/v1/contacts/batch', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.KIRORA_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ contacts }),
  });
  if (!response.ok) throw new Error(`Batch refused with ${response.status}`);

  const { data } = (await response.json()) as { data: { results: BatchResult[] } };
  const failed = data.results.flatMap((result, index) =>
    result.error ? [{ index, code: result.error.code, contact: contacts[index] }] : [],
  );
  return { written: data.results.length - failed.length, failed };
}
```

Log `failed` with your own record ids, not the whole request: it may hold personal data.

## Accepted is not delivered

A 2xx from a write means Kirora accepted and stored the work. What follows happens
later:

- **A transactional send** returns 202 with each message `queued`. It is then sent,
  delivered, bounced, skipped or failed. Follow it with the `message.*`
  [webhooks](https://kirora.io/docs/api/webhooks), or read it with `getMessage`.
- **A tracked event** is stored when the call returns. The journeys it starts and the
  conversions it counts run afterwards.
- **A campaign send** returns 202 with the campaign; each recipient's message follows as
  its own row in the [message log](https://kirora.io/docs/api/messages).

Do not poll in a tight loop for an outcome. Subscribe to webhooks, or read the message
once, some time later.

## Next steps

- [Idempotency](https://kirora.io/docs/api/reference/idempotency) — the keys that make a batch safe to retry
- [Rate limits](https://kirora.io/docs/api/reference/rate-limits) — the budgets each item draws on
- [Message log and exports](https://kirora.io/docs/api/messages) — what happened to each message
- [Limits](https://kirora.io/docs/api/limits) — every batch size in one table