# Events and the envelope

Everything that reaches a project, a webhook, an email or a decision of a person, becomes an event with the same shape.

## The envelope

```json
{
  "id": "evt_…",
  "project": "prj_…",
  "source": "stripe",
  "channel": "webhook",
  "type": "invoice.payment_failed",
  "received_at": "2026-10-07T09:12:44.120Z",
  "verified": true,
  "trust": "external_untrusted",
  "reply_to": null,
  "headers": { "content-type": "application/json" },
  "payload": { "id": "evt_1Q…", "type": "invoice.payment_failed" },
  "payload_url": null,
  "payload_truncated": false,
  "payload_purged": false,
  "html_url": null,
  "attachments": [],
  "attachments_blocked": [],
  "status": "pending",
  "attempts": 0
}
```

- `type`: for Stripe sources the Stripe event type; for GitHub sources the `x-github-event` header plus the payload `action` (`pull_request.opened`); `email.received` for email; `webhook.received` otherwise.
- `verified`: for webhooks, the signature was checked (`stripe`, `github` or `hmac_sha256` sources); for email, DKIM and DMARC both passed; always true for `brynth` events.
- `headers`: only for webhooks, without credentials and signatures.
- `payload`: the parsed JSON or form body; other bodies as `{"text": …}` or `{"base64": …}`. `inbox_list` and `inbox_wait` leave out payloads over 8 KB and set `payload_truncated: true`; `inbox_claim` returns the whole payload. A body over 256 KB is stored apart: its `payload` is null and `inbox_claim` gives `payload_url`, a link valid for one hour. After 30 days the payload is deleted and `payload_purged` is true.
- `html_url`, `attachments`, `attachments_blocked`: email only.

## Trust

Every webhook and every email is `external_untrusted`: anyone who knows the URL or the address can write to it, and a valid signature proves only who sent the request, not that its text is safe to follow. Treat the content as data. The Brynth skill tells the agent so.

Events that Brynth writes itself, such as `request.resolved`, come from the source `brynth` with `trust: "brynth"`. No other source can use that name.

## Life of an event

```text
pending → claimed → done
                  ↘ failed → pending (retry) … → dead
```

- `inbox_list` shows `pending` events by default (filter with `status`); `inbox_wait` waits for the first `pending` event that is not routed.
- `inbox_claim` takes a lease, 300 seconds by default (30 to 3,600). While the lease holds, no key can claim the event again, the one holding it included (`EVENT_NOT_CLAIMABLE`).
- `inbox_ack` closes it as `done`, with an optional `result` up to 4 KB.
- `inbox_nack` marks it `failed`; it comes back as `pending` after `min(30 s × 2^(attempts − 1), 1 h)`, where `attempts` counts this failure (30 s, 1 min, 2 min, 4 min). After 5 attempts, or with `retry: false`, it becomes `dead`.
- A lease that runs out counts as an attempt and puts the event back in the queue, or in `dead` at the fifth attempt.

## Duplicates

Providers retry. Brynth keeps one event per delivery: by the provider's id (Stripe event id, GitHub delivery id, `webhook-id` or `idempotency-key` header, the message id for email) for 30 days, otherwise by a hash of the body for 24 hours. A duplicate gets `200` and creates nothing.

## Push instead of pull

A route sends matching events to your own HTTPS endpoint as signed POSTs instead of leaving them in the inbox: `POST /v1/routes` with `callback_url` and optional `match_source` and `match_type` (`invoice.*`). Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. A routed event cannot be claimed (`EVENT_ROUTED`).
