# Events

Publish events and deliver them to subscribers by webhook, pull or live stream, with dead letters and replay.

Base URL: `https://events.inovacc.dev`

## Overview

Events lets one part of your system announce that something happened, and lets every interested part hear about it, without the two knowing each other. A publisher sends an event such as `order.paid` to `https://events.inovacc.dev`; every **subscription** whose pattern matches the event's type receives it, by **webhook** to your HTTPS endpoint, by **pull** from your worker, or by **live stream** over a WebSocket. Events that cannot be delivered are kept as **dead letters**, which you can inspect and replay.

The problem it solves is reliable fan-out. Calling each interested service directly couples them, loses messages when one is down and retries badly. Events accepts the publish once, delivers it at least once to each subscription, retries failed deliveries with backoff, deduplicates repeated publishes, and keeps what it could not deliver.

Use Events to react to what happens in your system. Send an email when an order is paid, refresh a cache when a record changes, push a notification to a page. Keep the state itself in [Data](/en/products/data/) and the bytes in [Files](/en/products/files/): an event should say *what happened*, not carry the object. For the full webhook receiver story, see the [Webhooks guide](/en/guides/webhooks/).

## Concepts

**Workspace.** Every route is under `/v1/workspaces/{workspace}`. The workspace in the path is a claim checked against your credential: an application key may name only its own workspace, and any other answers `403 forbidden`, the same as one that does not exist.

**Events and the envelope.** You publish `type` (dotted lowercase words, such as `invoice.created`, up to 128 bytes), `data` (any JSON value) and an optional `idempotency_key`. Subscribers receive an envelope with `event_id` (`evt_` and 26 characters, assigned by Inovacc), `type`, `source` (your application, set from the credential), `time`, `tenant`, `idempotency_key` and `data`.

**Tiers.** A publish may name a `tier`: `basic` (the default), `reliable` or `advanced`. The tier sets the largest `data` an event may carry: 16 KiB for `basic` and `reliable`, 32 KiB for `advanced`.

**Subscriptions and patterns.** A subscription lists 1 to 20 `types` patterns, each `*` (everything), an exact type, or a prefix ending in `.*` (`order.*` matches `order.paid` and `order.item.added`), and one delivery mode.

**Delivery modes.** `webhook` posts each event to your HTTPS URL, signed with the subscription's secret. `pull` keeps an inbox your code reads and acknowledges. `websocket` keeps the same inbox and also streams it live; pull keeps working beside the stream.

**At least once.** Each event reaches each matching subscription once under normal operation, but a retry or a lost acknowledgement can deliver it again. Receivers deduplicate by `event_id`.

**Dead letters.** A webhook delivery that keeps failing is moved to your workspace's dead letters, with the last error and the number of attempts, and kept 30 days.

## How it works

Every call carries `Authorization: Bearer <credential>`. The credential is the tenant: organization, account and application come from it, and nothing in a body can name another. A **secret key** is for servers and may use every route. A **publishable key** (`apb_...`) may only open a stream, from an origin its application lists. An end user's token may use nothing. Each route needs its own permission, such as `events:event.publish`, `events:subscription.write` or `events:dead.replay`.

**Publish.** `POST /events` with `{"event": {...}}` or `{"events": [...]}` (1 to 100). The answer is `202` with `accepted` (each with its `event_id` and `duplicate`) and `rejected` (each with an index and a code); a bad event never blocks the others. Sending an `idempotency_key` already used in the workspace in the last 168 hours returns the original `event_id` with `duplicate: true` and delivers nothing new.

**Webhook delivery.** Inovacc posts the envelope to your URL with `x-event-id`, `x-event-type`, `x-event-timestamp` and `x-event-signature: v1=<hex>`, an HMAC-SHA256 of the timestamp and the raw body. Any `2xx` within 10 seconds is a delivery; anything else is retried after 10 seconds, the delay doubling up to 10 minutes, until the retry cap of 5 is reached and the event becomes a dead letter. Redirects are never followed, and your URL must be public HTTPS on port 443.

**Pull.** `POST /subscriptions/{id}/pull` with `max` (1 to 100, default 10) and `lease_seconds` (5 to 300, default 30) returns the oldest messages, each with its `delivery_count`. A pulled message is hidden for its lease; acknowledge it with `POST .../ack` and the ids, or it returns when the lease ends.

**Stream.** `GET /subscriptions/{id}/stream` upgrades to a WebSocket with the subprotocol `inovacc.v1`; a browser passes its key as a second subprotocol, `bearer.<key>`. Each frame is one envelope; reply `{"ack": [...]}` to acknowledge. An unacknowledged frame is sent again after its lease.

**Monitor.** `GET /stats` returns `published`, `completed`, `depth`, `oldest_pending_age_seconds`, `retries`, `retry_rate` and `dlq_inflow` for the workspace. Every call is recorded in the [Activity log](/en/products/activity/). What is metered is the delivered event.

## Get started

You need a secret key bound to your workspace with the events permissions ([Authentication](/en/guides/authentication/)).

1. **Create a pull subscription.** `POST /v1/workspaces/{workspace}/subscriptions` with `{"types":["demo.*"],"delivery":{"mode":"pull"}}` (see [the samples](#example)). The answer is `201` with a `subscription_id`.
2. **Publish.** `POST .../events` with `{"event":{"type":"demo.hello","data":{"n":1}}}`. The answer is `202` with one `accepted` entry and its `event_id`.
3. **Pull.** `POST .../subscriptions/{id}/pull` with `{}`. Your event is in `messages`, with `delivery_count` 1.
4. **Acknowledge.** `POST .../subscriptions/{id}/ack` with `{"ids":["<event_id>"]}` answers `{"acked":1}`; a second pull is empty.
5. **Add a webhook.** Create a second subscription with `{"mode":"webhook","url":"https://<your endpoint>"}`, keep the `signing_secret` the answer shows once, and follow the [Webhooks guide](/en/guides/webhooks/) to verify deliveries.

## Use cases

**Order fulfilment.** The checkout publishes `order.paid` with the order id in `data` and the payment id as `idempotency_key`. A webhook subscription for `order.*` reaches the warehouse system; a pull subscription feeds the invoicing job. A checkout retried by the customer publishes once.

**Live updates in a page.** A dashboard opens a stream on a `websocket` subscription for `ticket.*` with a publishable key from its own origin, shows each new ticket as the frame arrives, and acknowledges it.

**Recovering from an outage.** A partner's endpoint was down for an hour. Its deliveries ended in dead letters with `last_error` `webhook_timeout`. Once it is back, your team lists `GET /dead` and replays each event; it goes only to the subscriptions that never received it.

## Limits and pricing

| Limit | Value |
|---|---|
| Events per publish | 100 |
| Request body | 5 MiB |
| `data` per event | 16 KiB (`basic`, `reliable`), 32 KiB (`advanced`) |
| Deduplication window for `idempotency_key` | 168 hours |
| Subscriptions per workspace; patterns per subscription | 50; 20 |
| Messages per pull; lease | 100; 5 to 300 seconds |
| Webhook response time | 10 seconds |
| Retries | from 10 seconds, doubling to 10 minutes; cap 5, then dead letter |
| Dead letters kept | 30 days |
| Stream frame from the client | 49,152 bytes |
| Rate | 600 calls per minute per credential, per location |

**Pricing:** on request. The pricing unit is the **delivered event**.

## Errors

| Status | Code | What it means and what to do |
|---|---|---|
| 400 | `invalid_body` | The body is malformed, or names `source` (it comes from your credential). |
| 400 | `invalid_query` | Only `GET /dead` takes a query (`limit`). |
| 400 | `invalid_subscription`, `invalid_delivery`, `invalid_limit`, `invalid_id` | Fix the subscription, the delivery or the parameter. |
| 400 | `invalid_url`, `url_not_https`, `url_has_credentials`, `url_port_not_allowed`, `url_destination_blocked` | The webhook URL must be public HTTPS on port 443, without credentials. |
| 401 | `invalid_credentials` | Send a valid credential. |
| 403 | `forbidden`, `account_required` | Missing permission or wrong workspace; use an application key. |
| 403 | `publishable_key_not_allowed`, `origin_not_allowed`, `secret_key_in_browser` | A publishable key may only stream, from a listed origin; keep secret keys on servers. |
| 404 | `subscription_not_found`, `dead_event_not_found` | It does not exist in your workspace. |
| 409 | `wrong_delivery_mode`, `too_many_subscriptions`, `secret_not_managed` | The operation does not fit the delivery mode, or you are at 50 subscriptions. |
| 413 | `payload_too_large` | The body is over 5 MiB. |
| 426 | `upgrade_required` | The stream route needs a WebSocket upgrade. |
| 429 | `rate_limited`, `too_many_streams` | Slow down; close sockets you do not need. |
| 503 | `service_unavailable`, `events_disabled`, `signing_unavailable` | Retry later; a publish retried with the same keys reuses its ids. |

A rejected event inside a `202` carries its own code: `invalid_event`, `unknown_field`, `invalid_type`, `invalid_idempotency_key`, `payload_too_large` or `data_required`.

## Best practices

- **Send an `idempotency_key`** derived from what happened (the payment id, the record version), so a retried publish is not a second event.
- **Deduplicate by `event_id`** in every receiver: delivery is at least once.
- **Put ids in `data`, not objects**: fetch the current state from [Data](/en/products/data/) when you handle the event.
- **Answer webhooks fast** with a `2xx` and do the work afterwards; 10 seconds is the limit.
- **Verify every webhook signature** before parsing the body ([Webhooks guide](/en/guides/webhooks/)).
- **Acknowledge pulled messages** after processing, and choose a lease longer than your processing time.
- **Watch `depth` and `dlq_inflow`** in `/stats`, and replay dead letters once the cause is fixed.

## Related

- [Webhooks guide](/en/guides/webhooks/): receive, verify and replay deliveries
- [Data](/en/products/data/), [Activity log](/en/products/activity/)
- [Authentication](/en/guides/authentication/), [Errors](/en/guides/errors/), [Limits](/en/guides/limits/)

## Endpoints

- POST /v1/workspaces/{workspace_id}/events
- GET /v1/workspaces/{workspace_id}/subscriptions
- POST /v1/workspaces/{workspace_id}/subscriptions
- DELETE /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}
- POST /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/rotate-secret
- POST /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/pull
- POST /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/ack
- GET /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/stream
- GET /v1/workspaces/{workspace_id}/dead
- POST /v1/workspaces/{workspace_id}/dead/{event_id}/replay
- GET /v1/workspaces/{workspace_id}/stats

## Example

```sh
curl -X POST "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events" -H "Authorization: Bearer $INOVACC_API_KEY" -H "Content-Type: application/json" -d '{}'
```
