# Events and webhooks

> The append-only event log is the change feed. Read it by polling, tail it over Server-Sent Events, or have signed batches delivered to your receiver.

Every accepted action appends one or more events in the same transaction as the change it describes. The log is append-only, ordered by a monotonic `seq`, and is the only source every downstream surface reads: webhooks, rollups, the live boards, and your integration. There is no change that is not an event.

## Reading the log

`GET /v1/events` returns a page of events, oldest first. Scope: `events:read`.

| Parameter        | Use                                                                                                   |
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| `since`          | Events with `seq` greater than this number. The simplest way to keep your own position.               |
| `cursor`         | The `next_cursor` from the previous page. Opaque; wins over `since` if both are set.                  |
| `correlation_id` | Only the events one action invocation appended.                                                       |
| `object_type`    | One type, or several comma-separated. Matches object events of that type and link events touching it. |

Each event carries its `seq`, its kind (`object_created`, `object_modified`, a link established or removed, and the action’s own domain events such as `reservation.checked_in.decided`), the objects involved, the principal who caused it, and the `correlation_id` of the invocation.

Polling is the right shape for a batch consumer: durable, replayable, and indifferent to your downtime. Store the last `seq` you processed; start from it next time.

## Tailing it live

`GET /v1/events/stream` holds the connection open and frames each new event as it lands, as Server-Sent Events:

```text
id: 12345
data: {"seq": 12345, "kind": "object_modified", …}
```

The `id` of each frame is the event’s `seq`. A browser `EventSource` or any SSE client reconnects with `Last-Event-ID` set to the last one it saw and the server resumes from there; `?since=12345` does the same for clients that do not send the header. The stream opens with a comment frame so a proxy forwards the headers at once, and sends a keepalive comment while nothing is happening. Scope: `events:read`.

Use the stream for anything that should react within a beat: a board, an agent, a screen.

## Webhooks

A webhook subscription asks the kernel to deliver matching events to a URL of yours. Manage subscriptions with `webhooks:write` and read them with `webhooks:read`:

| Route                                          | What it does                                                                             |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `POST /v1/webhook-subscriptions`               | Create a subscription: the receiver URL, a filter, and a signing key you are shown once. |
| `GET /v1/webhook-subscriptions` · `GET …/{id}` | List, or read one, with its status and `last_delivered_seq`.                             |
| `POST …/{id}/rotate-key`                       | Add a secondary signing key. Both keys are valid during the rotation window.             |
| `POST …/{id}/promote-key`                      | Make the secondary key primary and retire the old one.                                   |
| `GET …/{id}/deliveries`                        | The delivery attempts, with their outcomes.                                              |

### What arrives

One `POST` per batch, a CloudEvents 1.0 envelope carrying the events past the subscription’s `last_delivered_seq` that match its filter. The exact payload is in the [API reference](/api/) under webhooks, as `aerandil_event_delivery`.

### Verifying a delivery

Every delivery carries an `Aerandil-Signature` header:

```text
Aerandil-Signature: t=1730000000,v1=5f3a…[,v1=9c1b…]
```

Compute HMAC-SHA256 over the string `"{t}.{raw_body}"` with your signing key and compare it to a `v1` value with a constant-time comparison. Reject a delivery whose `t` is more than five minutes old. During a key rotation there are two `v1` entries, one per key; a match on either is valid. Ignore any other chunk you do not recognise.

### The receiver contract

- Return a `2xx` within ten seconds. Do the work after you have answered.
- Anything else, or no answer, marks the batch failed and schedules a retry at `60 · 2ⁿ` seconds plus up to thirty seconds of jitter, capped at 24 hours, for up to twelve consecutive failures.
- After the twelfth failure the subscription moves to `dead_letter` and deliveries stop until an operator resets it, usually after advancing `last_delivered_seq` past the batch that could not be processed.
- Delivery is at least once. De-duplicate on the event `id`, which is the log’s `seq`. The same event may arrive twice during a retry.

A receiver that cannot keep up should not slow its answer; it should acknowledge and queue. The log is replayable, so nothing is lost by reading it again from a `seq` you trust.

## Which to use

| You are building                                       | Use                                                       |
| ------------------------------------------------------ | --------------------------------------------------------- |
| A nightly sync or a data warehouse load                | Poll `GET /v1/events` from your last `seq`.               |
| A board, an agent, a screen that reacts now            | `GET /v1/events/stream`.                                  |
| A system of yours that should be told, without polling | A webhook subscription, with the receiver contract above. |

## Next

- [Usage guidelines](/guides/usage-guidelines/): idempotency, versions and errors.
- [API reference](/api/): the event and delivery schemas.

---

Source: https://developers.aerandil.com/guides/events-and-webhooks/
