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

# Event consumer groups: managed offsets over the platform event stream

> How the /events/consume|commit|seek endpoints give Kafka/Kinesis-style managed offsets over your tenant's platform event stream — monotonic commits, at-least-once replay, seek semantics, lag visibility, and the failure shapes to plan for.

# Event consumer groups: managed offsets

For high-volume event pipelines, tracking your own cursor against the raw
event feed is the wrong tool. [GET /events](/api-reference/endpoints/events)
is stateless cursor paging — your client carries the cursor, and if it loses
that position it re-reads or skips. [GET /events/stream](/api-reference/endpoints/events)
is a live SSE tail with `Last-Event-ID` resumes. Neither gives you a
server-side committed position, replay control, or lag visibility.

**Consumer groups** add that managed layer over the same ordered per-tenant
event stream. The model is deliberately Kafka-shaped: a poll reads from a
durable server-side offset, and your consumer commits that offset back only
after it has processed the batch. That gives you at-least-once delivery,
replay/backfill, and a lag report, without running a broker. The field
reference is the [Events API](/api-reference/endpoints/events).

## 1. The three semantic concepts

The whole model is built from three small pieces:

* **Consumer-group name** — an identifier you pick (1–128 chars of letters,
  digits, `._:-`, starting alphanumeric; e.g. `dlr-warehouse`, `siem-sync`).
  All offsets are tracked per group, so each independent consumer of your
  stream uses its own group and advances its own cursor.
* **Offset** — a position in the stream. The server stores one committed
  offset per group and everything reads relative to it. Offsets compare
  numerically, not lexically, so ordering is always correct.
* **Verbs** — `GET /events/consume` (poll a batch), `POST /events/consume/commit`
  (advance the group's offset), `POST /events/consume/seek` (reset it, the
  only operation allowed to move backwards), and `GET /events/consumer-groups`
  (list groups with lag).

## 2. At-least-once delivery

A poll (`GET /events/consume?group=...&limit=...`) reads the batch **after**
the group's committed offset but does **not** advance it. You commit
(`POST /events/consume/commit`) only after your side has durably processed —
written to the warehouse, handed to the queue, stored the checkpoint — the
batch. Until you commit, the batch's events remain available to the group.

That gives **at-least-once** delivery: a crash or restart before commit
re-delivers the same batch on the next poll. The platform does **not**
deduplicate for you — consumers dedup on each event's `id` (a stable stream
id the poll attaches to each event’s envelope). Idempotency in your consumer
is the only dedup layer.

The commit itself is **idempotent and monotonic**: submitting the same
offset, or an offset already behind the committed position, never rewinds
the cursor. The response’s `applied` flag tells you whether the write took.

Each poll returns the shape you need to drive this loop: `committed_offset`
(the position the batch was read after), `next_offset` (the offset to commit
after processing the batch — equals `committed_offset` when the batch was
empty), `count`, and `has_more` (true when the batch filled your `limit`).

## 3. The cursor model

Offsets are **opaque**: `<ms>-<seq>` stream ids, or the literal `0` for the
origin of the retained window. Treat them as tokens you pass back verbatim —
do not construct or parse them on your side.

The committed offset survives disconnects. It lives server-side per group, so
a consumer that crashes and restarts — or that you redeploy — picks up
exactly where it last committed. A group that has never committed starts at
`0`, the beginning of the retained window. A `seek` to `end` on an empty
stream resolves to `0` too (== "from now").

## 4. Isolation

Consumer groups are **per tenant**. Your offsets live under your own tenant
event channel, and there is no cross-tenant iterator — a group never sees
another tenant's events, and another tenant's groups never see yours. That
makes the feature safe to share across all tenants without coordination.

Within your own tenant, independence is by group name: two consumers that
should each see the full stream use two distinct group names; one logical
consumer scaled across worker replicas uses **one** group name all replicas
share, so the committed cursor is a single durable position.

## 5. Failure shapes to plan for

The failure modes are deliberately loud and bounded:

* **Consumer-group overflow** — a tenant may hold at most 100 consumer
  groups. The limit exists so a client that mints a fresh group name per poll
  cannot grow the offset store without bound. The commit/seek write for a
  **new** group past the limit returns `409` with code `CONSUMER_GROUP_LIMIT`;
  writes from an existing group are unaffected. Reuse a small set of stable
  group names.
* **Seek ahead of head** — numeric offset comparison means a seek to an
  explicit offset that is past the stream head simply parks the group past
  everything currently retained; only future events then poll through.
  Seeking to a malformed offset is rejected at validation with a `422`
  before any write.
* **Stale offsets when a tenant re-attaches** — offsets are durable for 30
  days, refreshed on every commit/seek. After a fully dormant window a group
  can lose its committed offset; its next poll then falls back to the
  beginning of the retained window (never to skipping events).
* **Duplicated delivery** — at-least-once means redelivery after any
  crash-before-commit. Dedup on the event `id`.

`GET /events/consumer-groups` surfaces each group's committed offset and
approximate **lag** (retained events waiting after the committed offset) so
you can alarm on a consumer falling behind. Lag is reported with a
`lag_capped` flag once a group's backlog exceeds the report cap, so the list
call stays cheap even for far-behind groups.

## 6. A worked example

Poll a batch for a group, then commit the returned `next_offset` once you
have processed it:

```bash theme={null}
# 1. Poll up to 50 events for the group (reads after its committed offset)
curl -s "https://api.orbit.devotel.io/api/v1/events/consume?group=dlr-warehouse&limit=50" \
  -H "Authorization: Bearer $API_KEY"

# -> data: [ ... events ... ]
#    meta: { committed_offset: "1719331200000-0",
#            next_offset:     "1719331241502-3",
#            count: 50, has_more: true }

# 2. Commit once you have durably processed every event in the batch
curl -s -X POST "https://api.orbit.devotel.io/api/v1/events/consume/commit" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"group":"dlr-warehouse","offset":"1719331241502-3"}'

# -> data: { group: "dlr-warehouse", committed_offset: "1719331241502-3",
#            applied: true }
```

To backfill over the retained window, seek then consume:

```bash theme={null}
# Reset the group to the start of the retained window
curl -s -X POST "https://api.orbit.devotel.io/api/v1/events/consume/seek" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"group":"dlr-warehouse","to":"beginning"}'

# Or skip everything currently retained and consume only new events
curl -s -X POST "https://api.orbit.devotel.io/api/v1/events/consume/seek" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"group":"dlr-warehouse","to":"end"}'
```

Send exactly one of `to` or `offset`; sending both or neither is a `422`.

## 7. Contrast with the other event surfaces

Orbit's event plane has three egress shapes, and the trade-off is worth
naming before you pick one:

| Surface                                        | Direction                   | Cursor ownership                     |
| ---------------------------------------------- | --------------------------- | ------------------------------------ |
| **Consumer groups** (this page)                | In-platform poll            | Server-side, per group               |
| **CDP streaming destinations** — Kafka/Kinesis | Fan-out to a bus you own    | Your Kafka offset / Kinesis position |
| **Event sources** (inbound model)              | Your Kafka topic into Orbit | The source's own consume engine      |

* [CDP streaming destinations](/concepts/cdp-streaming-destinations) are
  **fan-out**: every event is produced onto your Kafka topic or Kinesis
  stream, and your downstream consumers manage the bus-side offset. Pick
  this when your event system of record is already a broker.
* [Event sources inbound](/concepts/event-sources-inbound-model) is the
  **reverse** direction — Orbit consumes your Kafka topic in. That model
  shares the filter and envelope vocabulary with the sink side, but it is an
  inbound subscription, not a managed offset over Orbit's own stream.
* Consumer groups fit when you want Kafka-style pull semantics **without**
  standing up a bus: your pipeline polls and commits Orbit-side, and the
  only state you carry is the group name.

The event **taxonomy** itself — which event types exist and what each
envelope carries — is defined in the [firehose and schema registry](/concepts/event-ledger-projection-model)
surfaces; consumer groups are a cursor layer over that taxonomy, not a new
event model.

## See also

<CardGroup cols={2}>
  <Card title="Events API" href="/api-reference/endpoints/events">
    The consume / commit / seek / consumer-groups endpoint field reference.
  </Card>

  <Card title="CDP streaming destinations" href="/concepts/cdp-streaming-destinations">
    Kafka and Kinesis fan-out when your consumers own a bus.
  </Card>

  <Card title="Event sources inbound model" href="/concepts/event-sources-inbound-model">
    The reverse direction — your Kafka topic consumed into Orbit.
  </Card>

  <Card title="Webhook fan-out and event sinks" href="/concepts/webhook-fan-out-and-event-sinks">
    The push-based sibling over the same event taxonomy.
  </Card>
</CardGroup>
