> ## Documentation Index
> Fetch the complete documentation index at: https://docs.niadra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Events and the batch

> Messages, system events and actions, sent in batches, idempotent, with per-item errors.

Memory is built from events. Everything that happens with a customer, on any channel or system, enters Niadra as an immutable event: what they said, what a system recorded, what an agent did. Nothing is edited afterwards. A correction is a new event, and erasure is a separate operation that issues a receipt.

## Three kinds of event

The `kind` field says what the event records:

| `kind`         | What it is                                              | Example                                                             |
| -------------- | ------------------------------------------------------- | ------------------------------------------------------------------- |
| `message`      | Something said or sent in a conversation. The default.  | Marina writes on WhatsApp at 2:02 pm that the technician never came |
| `system_event` | A change in a system of record, with a `canonical_type` | The ERP emits `invoice.credited` at 2:06 pm                         |
| `action`       | What an agent did in a system, with the `action` block  | The billing agent applies a \$40 credit to invoice 0823             |

Each event carries its `channel` (`whatsapp`, `voice`, `app`, `email`, `erp`, `crm`, `ticket`), who spoke in `speaker` (`customer`, `ai_agent`, `human_agent` or `system`), the moment in `occurred_at`, and at least one handle, subject or object saying whom or what it is about. Free text from inside a system, such as a ticket description or an e-mail body, comes in as a `message` on its own channel.

Memory is ordered by `occurred_at`, never by arrival. A WhatsApp resync that replays old messages lands in the right place. A timestamp outside the clock tolerance is corrected to the arrival time and flagged.

## The batch

Every event comes in through `POST /v1/batch`. A batch takes up to **500 items**, with at most **1 MB per item** and **2.5 MB per batch**, and mixes items of several kinds through the `type` field:

| `type`               | What it is for                                             |
| -------------------- | ---------------------------------------------------------- |
| `event`              | A message, system event or action                          |
| `identify`           | States that two or more handles belong to the same subject |
| `verify`             | Raises the verification level of a conversation or task    |
| `conversation.ended` | Closes the session now instead of waiting for inactivity   |
| `task.ended`         | Closes the session of an internal-agent task               |
| `handoff`            | Records a transfer to a human or another agent             |
| `heartbeat`          | SDK counters used to measure the coverage of each source   |

The raw event is stored before the answer goes out. You rarely build a batch by hand: the SDKs keep events in a local queue and send them in batches without blocking the agent.

<CodeGroup>
  ```python Python theme={null}
  from niadra import Niadra, phone

  niadra = Niadra(channel="whatsapp")

  niadra.track({
      "conversation_id": "wa-8812",
      "idempotency_key": message["id"],  # the provider message id
      "handles": [phone("+14155550123")],
      "speaker": {"role": "customer"},
      "content": {"text": "The technician never showed up. I am calling you."},
  })
  ```

  ```typescript TypeScript theme={null}
  import { Niadra, handles } from "@niadra/sdk";

  const niadra = new Niadra();

  niadra.track({
    channel: "whatsapp",
    conversation_id: "wa-8812",
    idempotency_key: message.id, // the provider message id
    handles: [handles.phone("+14155550123")],
    speaker: "customer",
    text: "The technician never showed up. I am calling you.",
  });
  ```

  ```bash cURL theme={null}
  curl -X POST "https://acme-prod.us-east-1.api.niadra.com/v1/batch" \
    -H "Authorization: Bearer $NIADRA_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
    "items": [
      {
        "type": "event",
        "kind": "message",
        "idempotency_key": "wamid.HBgLMTQxNTU1NTAxMjMVAgASGBQzQUQ",
        "channel": "whatsapp",
        "conversation_id": "wa-8812",
        "handles": [{"type": "phone_e164", "value": "+14155550123"}],
        "speaker": {"role": "customer"},
        "content": {"type": "text", "text": "The technician never showed up. I am calling you."},
        "occurred_at": "2026-09-22T17:02:11Z"
      }
    ]
  }'
  ```
</CodeGroup>

## Idempotency

Every item carries an `idempotency_key`. Use the provider message id whenever there is one: channel providers resend webhooks for days, and the same id never becomes two events. When there is no id, the SDK mints a UUIDv7, which also sorts by time.

Deduplication is by id, never by content similarity. Two identical short messages ("ok", "yes") are two messages. A repeated item is counted in `duplicates`, without an error.

## One bad item never fails the batch

The batch answers **200** when every item went in and **207** when any item was rejected, always with a result per item. What went in counts as `accepted`, what already existed counts as `duplicates`, and every rejected item shows up in `errors` with its position and code:

```json theme={null}
{
  "accepted": 498,
  "duplicates": 1,
  "errors": [
    { "index": 17, "code": "verification_not_allowed", "detail": "V4 is above the ceiling of this source" }
  ]
}
```

Validation happens in two steps: the edge checks only the shape of the envelope, and each item is checked afterwards. The shape rules, which the SDKs also apply before sending:

* a message needs text, a transcript or a media reference;
* a system event needs a `canonical_type`, such as `invoice.credited`;
* an action needs the `action` block, and the block is only valid when `kind` is `action`;
* every event needs at least one handle, subject or object.

An item over 1 MB comes back with the code `too_large`. A body over 2.5 MB, or a batch with more than 500 items, is refused whole with 422 `invalid_input`.

The SDK retries with backoff on network failures, 429 and 503, and never retries a validation 4xx.

## Conversations, tasks and sessions

The `conversation_id` is yours: it can be a WhatsApp thread that lasts months, or a call id. Niadra splits each conversation into **sessions**, windows of activity that close after channel inactivity (about 20 minutes on WhatsApp, 30 in an app) or on `conversation.ended`. For voice, send `conversation.ended` when the call hangs up. A call often has two ids, the platform one and the trunk one: send the second in `conversation_aliases`.

Internal agents use a `task_id` instead of a conversation. A task closes on `task.ended` or after 10 minutes without activity. The SDKs handle this with `conversation()` and `task()`, which emit the end when the block finishes.

A new message is readable in the hot tier in under 1 second, already in the `live` part of the next context read from another channel. Actions and system events reach the recompiled context in under 10 seconds.

## Media and late data

Media never travels inside the event. Reserve an upload with [`POST /v1/media/uploads`](/en/api/media-uploads), giving `content_type`, `size_bytes` (up to 500 MB), the `sha256` of the bytes and, whenever you know it, the `subject` the file belongs to: the file is then stored under that person, and erasing them erases it even if no event ever points to it. The answer carries `upload_url`, `upload_headers` and `expires_at`. `PUT` the bytes to `upload_url` with **exactly** the headers of `upload_headers`: the store refuses any other bytes. Then send the event with `content.media_ref` and `content.media_sha256`. The SDKs do the three steps in one call.

<CodeGroup>
  ```python Python theme={null}
  from niadra import Niadra, phone

  niadra = Niadra(channel="whatsapp")
  marina = phone("+14155550123")

  # Reserves the upload, PUTs the bytes with the exact headers, returns the reference
  media = niadra.upload_media(voice_note_bytes, "audio/ogg", subject=marina)

  if media:
      niadra.track({
          "conversation_id": "wa-8812",
          "handles": [marina],
          "speaker": {"role": "customer"},
          "content": {
              "type": "audio",
              "media_ref": media.media_ref,
              "media_sha256": media.media_sha256,
              "transcript": "The technician never showed up. I am calling you.",
          },
      })
  ```

  ```typescript TypeScript theme={null}
  import { Niadra, handles } from "@niadra/sdk";

  const niadra = new Niadra();
  const marina = handles.phone("+14155550123");

  // Reserves the upload, PUTs the bytes with the exact headers, returns the reference
  const { data: media } = await niadra.uploadMedia({ data: voiceNote, content_type: "audio/ogg", subject: marina });

  if (media) {
    niadra.track({
      channel: "whatsapp",
      conversation_id: "wa-8812",
      handles: [marina],
      speaker: "customer",
      content: {
        type: "audio",
        media_ref: media.media_ref,
        media_sha256: media.media_sha256,
        transcript: "The technician never showed up. I am calling you.",
      },
    });
  }
  ```

  ```bash cURL theme={null}
  BASE=https://acme-prod.us-east-1.api.niadra.com

  # 1. Reserve the upload
  curl -X POST "$BASE/v1/media/uploads" \
    -H "Authorization: Bearer $NIADRA_API_KEY" -H "Content-Type: application/json" \
    -d '{"content_type": "audio/ogg", "size_bytes": 48213, "sha256": "'"$(shasum -a 256 note.ogg | cut -d" " -f1)"'",
         "subject": {"type": "phone_e164", "value": "+14155550123"}}'
  # => {"media_ref": "...", "upload_url": "https://...", "upload_headers": {"Content-Type": "audio/ogg", ...}, "expires_at": "..."}

  # 2. PUT the bytes with every header of upload_headers, exactly as returned
  curl -X PUT "$UPLOAD_URL" -H "Content-Type: audio/ogg" -H "x-amz-checksum-sha256: <value from upload_headers>" --data-binary @note.ogg
  ```
</CodeGroup>

Data that arrives later, such as the full transcript of a call, is a new event, not an update. It marks the conversation, and the derived memory is rebuilt with a new version.

## Source coverage

The SDK sends a periodic `heartbeat` with how many events it sent. Niadra compares that with what it received and marks each source `ok` or `silent`. The context reports this in `coverage`, and a silent source can fire a [trigger](/en/concepts/triggers-and-webhooks). [`GET /v1/sources/coverage`](/en/api/sources-coverage) shows each source day by day, for up to 90 days: what the SDK says it `sent`, what was `received`, what was `rejected` and what arrived with a type your mapping does not cover (`unmapped`).

## Which context the agent used

When an agent answers, the SDK stamps its turn with `context_stamp`: the `etag` of the context that went into the prompt and `injected_at`, the moment it went in. That is how [context use](/en/concepts/context-use) tells a context that arrived late from one that arrived and went unused. Inside a `conversation()` the SDKs stamp it for you when you call `mark_injected()` in Python or `markInjected()` in TypeScript; send it yourself only when you build events by hand.

```json theme={null}
{
  "type": "event",
  "idempotency_key": "call-4471-t2",
  "channel": "voice",
  "conversation_id": "call-4471",
  "handles": [{"type": "phone_e164", "value": "+14155550123"}],
  "speaker": {"role": "ai_agent"},
  "content": {"text": "Hi Marina, I can see the $40 credit on your August bill."},
  "context_stamp": {"etag": "\"cp1-9f2c4e\"", "injected_at": "2026-09-22T17:07:04Z"},
  "occurred_at": "2026-09-22T17:07:06Z"
}
```

## Files: seed identity or backfill history

For volume that does not belong in the conversation path, [`POST /v1/ingest/files`](/en/api/ingest-files) takes a whole file of up to 512 MB, with the `track` scope, and answers **202** with an `import_id`:

* `text/csv` seeds identity. The header names handle types (`phone_e164`, `email`, `system_id`...), with `<type>_scope` for the namespace, `<type>_2` to `<type>_9` for more values of the same type, and the optional `idempotency_key`, `occurred_at` and `subject_kind`. Each row becomes one `identify` with the method `system_import`, under the same protections as any other.
* `application/x-ndjson` backfills history: each line is one item of `POST /v1/batch`, validated the same way.

```bash theme={null}
curl -X POST "https://acme-prod.us-east-1.api.niadra.com/v1/ingest/files" \
  -H "Authorization: Bearer $NIADRA_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @crm-customers.csv
```

Follow the import with [`GET /v1/ingest/files/{import_id}`](/en/api/ingest-file): `status` (`queued`, `running`, `completed`, `failed`), `records`, `accepted`, `duplicates`, `rejected` and the first 100 row errors, which carry the row number and never the values.

## Corrections

A correction is an event too. [`POST /v1/feedback`](/en/api/feedback) takes one `action`, and each action needs its own fields: `retract_fact` needs `fact_id`; `correct_fact` needs `fact_id` and `value`; `resolve_open_item` needs `open_item_id`; `conversation_outcome` needs `conversation_id` and `value`. A request without them is refused with 422, instead of being stored and skipped. The answer has the shape of the batch answer. Both SDKs have `feedback()`.

## Next steps

<CardGroup cols={2}>
  <Card title="Identity and verification" href="/en/concepts/identity">
    how handles become one customer.
  </Card>

  <Card title="Systems, objects and actions" href="/en/concepts/systems">
    ERP events and actions that close open items.
  </Card>

  <Card title="Send a batch" href="/en/api/batch">
    the full reference for `POST /v1/batch`.
  </Card>
</CardGroup>
