# Durable event consumers

> A server-side cursor for agents that start and stop. Operated by Avatar 8 LLC (ops@avatar33.com).

HTML version: https://cooperemail.com/docs/event-consumers · API docs: https://cooperemail.com/docs · Stay in sync: https://cooperemail.com/docs/stay-in-sync

The shell CLI can call these routes with `node cooper.mjs api`. Example: `node cooper.mjs api POST /api/v1/event-consumers --data '{"client_id":"nightly-digest","start":"oldest"}'`.

## Poll, process, commit

POST /api/v1/event-consumers stores a cursor for a client_id on this account. GET /api/v1/event-consumers/{id}/events returns events strictly after that cursor and does not move it. POST /api/v1/event-consumers/{id}/commit moves it forward after you have processed the page. {id} is the evcon_ id or the client_id.

The same client_id on a later run returns the existing consumer with HTTP 200 and does not move the cursor. A new client_id is HTTP 201. start is now (the default), oldest, or an ISO-8601 timestamp, and it is used only when the consumer is created. A restart does not need a saved cursor.

client_id is 1 to 64 characters: letters, digits, and . _ : -. types is an optional subset of the webhook event types. inbox_id is an inbox id or address. Omit types to receive every type. Omit inbox_id to receive every inbox this key can see.

Every event-consumer route requires message_read, the same permission as GET /api/v1/events. A full-access key and an OAuth token can call them. A key without message_read receives HTTP 403 permission_denied. Create, list, poll, commit, reset, and delete all use that read permission, because the consumer returns the same event log. There is no separate requests-per-minute cap. wait is 0 to 25 seconds, the same cap as GET /api/v1/events.

```
curl -sS -X POST "$BASE/api/v1/event-consumers" \
  -H "authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"client_id":"nightly-digest","start":"oldest"}'

curl -sS "$BASE/api/v1/event-consumers/nightly-digest/events?limit=50&wait=20" \
  -H "authorization: Bearer $COOPER_API_KEY"

curl -sS -X POST "$BASE/api/v1/event-consumers/nightly-digest/commit" \
  -H "authorization: Bearer $COOPER_API_KEY" \
  -H "content-type: application/json" \
  -d '{"cursor":"evc1.…"}'
```

## TypeScript

Process the page, then commit commit_cursor. If the process stops before commit, the next poll returns the same events.

```
const base = "https://cooperemail.com";
const key = process.env.COOPER_API_KEY!;
const clientId = "nightly-digest";

async function call(method: string, path: string, body?: unknown) {
  const res = await fetch(base + path, {
    method,
    headers: {
      authorization: `Bearer ${key}`,
      "content-type": "application/json",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const payload = await res.json();
  if (!res.ok) throw new Error(payload.error?.code ?? res.statusText);
  return payload;
}

await call("POST", "/api/v1/event-consumers", { client_id: clientId, start: "oldest" });

for (;;) {
  const page = await call(
    "GET",
    `/api/v1/event-consumers/${clientId}/events?limit=50&wait=20`,
  );
  if (page.events_may_have_expired) {
    console.log("some events may have aged out of the 7-day log");
  }
  for (const event of page.data) {
    console.log(event.type, event.id);
  }
  await call("POST", `/api/v1/event-consumers/${clientId}/commit`, {
    cursor: page.commit_cursor,
  });
  if (page.data.length === 0 && !page.has_more) break;
}
```

## Python

The loop is the same in Python. Commit only after the side effect has finished.

```
import json, os, urllib.request

base = "https://cooperemail.com"
key = os.environ["COOPER_API_KEY"]
client_id = "nightly-digest"

def call(method, path, body=None):
    data = None if body is None else json.dumps(body).encode()
    req = urllib.request.Request(
        base + path,
        data=data,
        method=method,
        headers={"authorization": f"Bearer {key}", "content-type": "application/json"},
    )
    with urllib.request.urlopen(req) as res:
        return json.load(res)

call("POST", "/api/v1/event-consumers", {"client_id": client_id, "start": "oldest"})
while True:
    page = call("GET", f"/api/v1/event-consumers/{client_id}/events?limit=50&wait=20")
    if page.get("events_may_have_expired"):
        print("some events may have aged out of the 7-day log")
    for event in page["data"]:
        print(event["type"], event["id"])
    call("POST", f"/api/v1/event-consumers/{client_id}/commit", {"cursor": page["commit_cursor"]})
    if not page["data"] and not page["has_more"]:
        break
```

## At least once

Delivery is at least once. Cooper returns an event again until you commit a cursor at or after that event. A crash after the side effect and before commit delivers that event again on the next poll. Make the side effect idempotent, or dedupe by the event id when you have a place to remember ids. The event id is the same evt_ id stored on the event log and sent as x-cooper-delivery on a webhook.

Committing an older cursor is a no-op. The response keeps the current cursor. The update compares created_at and id in one statement, so two workers cannot move the cursor backwards. A value that is not an evc1 cursor is HTTP 400 invalid_cursor.

POST /api/v1/event-consumers/{id}/reset moves the cursor on purpose. start is now, oldest, or an ISO-8601 timestamp, including a time in the past. HTTP 400 invalid_start when start is missing or not one of those values.

Filters are fixed on create. A later POST with the same client_id and different types or inbox_id returns HTTP 409 consumer_conflict and leaves the cursor where it is. Delete the consumer and create it again to change filters. There is no PATCH.

## Retention and the 50 consumer cap

The daily cron deletes events older than 7 days. When the committed cursor is older than that window, the poll response sets events_may_have_expired to true. Events still in the window are returned. Events that aged out are not.

An account can store at most 50 consumers. The next create returns HTTP 409 consumer_limit. That cap is a technical limit, not a plan limit. It does not change Free, Starter, or Pro.

An inbox-scoped key can only create, list, read, commit, reset, and delete consumers pinned to its inbox. Omitting inbox_id pins the new consumer to that inbox. A different inbox_id is HTTP 404 inbox_not_found. Another account's consumer is HTTP 404 consumer_not_found. The event objects are the same objects GET /api/v1/events returns to that key. Operator: Avatar 8 LLC (ops@avatar33.com).

## Compared with the other ways to read events

| Method | Who stores the cursor | Needs a public URL | While the agent is stopped | Delivery |
| --- | --- | --- | --- | --- |
| Durable event consumers | Cooper, per client_id | No | Events wait until you commit | At least once, after the committed cursor |
| Webhooks | Cooper retries each delivery | Yes | Delivery is attempted, then retried | At least once, pushed to your URL |
| GET /api/v1/events | You store next_cursor | No | Missed unless you saved next_cursor | You choose the cursor |
| GET /api/v1/events/stream | You send Last-Event-ID | No | The stream is closed | You choose the cursor |
| MCP Events | A subscription on Cooper | A callback URL | Push while the subscription is active | Signed webhook to the MCP client |

GET /api/v1/events, GET /api/v1/events/stream, webhooks, and MCP Events behave as they did before. MCP tools: cooper_poll_events, cooper_commit_events, cooper_list_event_consumers, and cooper_delete_event_consumer.
