Receiving Events
Most of Aivi’s API goes one way: your system publishes, the phone shows it. Some features go the other way too. When someone presses a tile on a switchboard widget, the phone reports the press to Aivi, and Aivi hands it to your system as an event.
Every event has the same envelope, and every event reaches you the same two ways: an event stream that your system holds open, or a webhook that Aivi calls. This page covers both, then the part that closes the loop: confirming the result by publishing new content.
The event envelope
Section titled “The event envelope”{ "id": "7c1e5b0a-4d2f-5a8e-9b3c-2f6d8e1a4c57", "request_id": "act_3f9c2a71b04e", "type": "widget.action.v1", "subject": { "kind": "widget", "slug": "living-room" }, "occurred_at": "2026-10-01T19:04:07.512000Z", "expires_at": "2026-10-01T19:04:12.512000Z", "data": { "action": "porch", "kind": "toggle", "value": true }}| Field | Description |
|---|---|
id | The delivery id, a UUID. Unique per resource and press, so presses on two widgets never share one, and the same on every retry of one press: a press delivered twice carries the same id. Deduplicate on it. On a webhook it is also the Idempotency-Key header. |
request_id | The id the phone gave the press. Unique only within one widget, so don’t deduplicate on it; it is there for logs and for matching a press to the phone that sent it. |
type | What happened, including the version of its data, for example widget.action.v1. See the catalog. |
subject | The resource the event is about, as an object: kind names what the resource is, and slug is the slug you gave it. For widget.action.v1 the kind is widget. More kinds will come as other resources start emitting events, so read kind together with slug. |
occurred_at | When the event happened, in ISO 8601. |
expires_at | When the event stops being useful: 5 seconds after occurred_at. Aivi stops delivering at that point, and a receiver should drop an event that reaches it later, rather than act on a stale press. |
data | The payload. Its shape depends on type. |
The envelope is the same on the event stream and in a webhook’s default body.
Event types
Section titled “Event types”widget.action.v1
Section titled “widget.action.v1”Someone pressed a tile on an interactive widget,
such as a switchboard. The
subject is { "kind": "widget", "slug": "<the widget's slug>" }.
| Field | Type | Description |
|---|---|---|
action | string | The id of the pressed action, as you published it in the widget’s content. |
kind | "button" | "toggle" | The action’s kind. |
value | bool | Toggles only: the state the person asked for, true to turn on and false to turn off. It is a request, not a fact: the tile changes once you confirm. |
A button press:
{ "action": "movie", "kind": "button" }A toggle press:
{ "action": "porch", "kind": "toggle", "value": true }Presses come from the widget’s owner and from devices subscribed to a shared widget. Every press reaches the owner’s account: the owner’s API token opens the stream, and the owner configures the webhooks. Presses are rate limited on the phone’s side, so a tile pressed over and over doesn’t flood your system.
Delivery
Section titled “Delivery”Aivi offers two ways to receive events. Use whichever suits your system; you can use both at once.
- The event stream is a long-lived HTTPS connection your system opens to Aivi. Nothing in your network has to be reachable from the internet, which makes it the natural choice for a home server.
- A webhook is a request Aivi makes to a URL of yours when the event happens. Nothing has to stay connected, but the URL has to be public.
Delivery is best effort in both. An event can arrive twice (a retried
webhook, a stream that resumes after a reconnect), so deduplicate on id.
An event that nobody receives before it expires is gone: Aivi keeps events
only until their expires_at.
Event stream
Section titled “Event stream”GET /eventsOpens a server-sent events stream on the public API, authenticated with your API token like every other request. One stream carries the events of every widget on your account.
| Query parameter | Type | Description |
|---|---|---|
types | string, repeatable | Only events of these types, for example types=widget.action.v1. Omit for every type. |
subject | string, repeatable | Only events about these resources, written as <kind>/<slug>, for example subject=widget/living-room. Omit for every subject. |
curl -N "https://api.getaivi.app/events?types=widget.action.v1&subject=widget/living-room" \ -H "Authorization: Token YOUR_API_TOKEN"Each event arrives as an id: line with the stream’s position, then one
data: line holding the envelope as JSON, then a blank line. The stream
opens with a retry: line and its starting position. Positions count your
account’s events, in the form u:<number>; treat them as opaque strings. A : ping comment
arrives every 20 seconds while nothing else happens, so a quiet connection
is easy to tell from a dead one:
retry: 1000id: u:6
: ping
id: u:7data: {"id":"7c1e5b0a-4d2f-5a8e-9b3c-2f6d8e1a4c57","request_id":"act_3f9c2a71b04e","type":"widget.action.v1","subject":{"kind":"widget","slug":"living-room"},"occurred_at":"2026-10-01T19:04:07.512000Z","expires_at":"2026-10-01T19:04:12.512000Z","data":{"action":"porch","kind":"toggle","value":true}}Things to know when you write a client:
- Reconnect. The server closes every stream after 15 minutes. Reconnect
right away when it does (after the
retry:delay, one second), and back off when a connection attempt fails. - Resume with
Last-Event-ID. Send the lastid:you saw in aLast-Event-IDheader when you reconnect, and the stream first replays the events you missed that haven’t expired yet. BrowserEventSourceclients do this on their own. Without the header nothing is replayed, and expired events are never replayed, so keep the gap short. Send theid:exactly as you received it. A value the stream doesn’t recognize, such as a plain number, replays every event that hasn’t expired yet. Either way a replay can repeat an event you already saw: deduplicate on the envelope’sid. - At most 4 streams per account. A fifth returns
429withToo many event streams. Close streams you no longer need. - Filters name the kind. A
subjectfilter must have the<kind>/<slug>form. A bare slug, or an unknown kind, is rejected with422rather than matched loosely, since the same slug can name different kinds of resource. - Temporary outages. When Aivi can’t set a stream up, for example while
its database is unavailable, it answers
200with a stream that holds only aretry:line and ends. Reconnect after the delay, as after any closed stream. A browserEventSourcekeeps reconnecting after that, where an error status would make it give up.
Most languages have an SSE client, but the format is simple enough to read line by line. A minimal Python consumer:
import jsonimport requests
URL = "https://api.getaivi.app/events"HEADERS = {"Authorization": "Token YOUR_API_TOKEN"}seen = set()last_id = None
while True: headers = dict(HEADERS) if last_id is not None: headers["Last-Event-ID"] = last_id # replay what we missed with requests.get( URL, headers=headers, params={"types": "widget.action.v1"}, stream=True, timeout=(10, 60), ) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line.startswith("id: "): last_id = line[len("id: "):] continue if not line.startswith("data: "): continue # blank separators, "retry:", ": ping" heartbeats event = json.loads(line[len("data: "):]) if event["id"] in seen: continue seen.add(event["id"]) print(event["subject"]["slug"], event["data"])A real client also drops expired events, trims seen, waits the retry:
delay before reconnecting, and backs off between failed connection
attempts.
Webhooks
Section titled “Webhooks”A webhook is a request template: Aivi fills it in for each event and sends it to your URL. Webhooks are configured per widget, with one entry per action and an optional default:
PUT /widget/{slug}/webhookscurl https://api.getaivi.app/widget/{slug}/webhooks \ -X PUT \ -H "Content-Type: application/json" \ -H "Authorization: Token YOUR_API_TOKEN" \ --data-binary @- << 'EOF'{ "webhooks": { "movie": { "url": "https://hooks.example.com/scenes/movie", "headers": { "Authorization": "Bearer YOUR_RECEIVER_TOKEN" } }, "*": { "url": "https://hooks.example.com/aivi", "secret": "A_LONG_RANDOM_STRING" } }}EOFThe keys of webhooks are action ids, plus "*" for the default. A press
goes to its action’s own entry if there is one, otherwise to "*",
otherwise nowhere. A map holds at most 9 entries: eight actions and the
default. A PUT replaces the whole map. Keys don’t have to match the
actions the widget shows right now, so you can configure webhooks before
you publish the actions.
| Request | Result |
|---|---|
PUT /widget/{slug}/webhooks | Replaces the map and returns it, redacted. |
GET /widget/{slug}/webhooks | Returns the map, redacted, with last_success_at and last_failure_at for each entry: when a delivery to it last succeeded and last failed, or null. |
DELETE /widget/{slug}/webhooks | Removes every entry. Returns 204. |
The response to a PUT or GET looks like this:
{ "webhooks": { "movie": { "config": { "url": "https://hooks.example.com/scenes/movie", "method": "POST", "headers": { "Authorization": "***" }, "body": null }, "last_success_at": "2026-10-01T19:04:07.901000Z", "last_failure_at": null } }}Redacted means the secret is never returned, header values come back as
"***", and so do the values in the URL’s query string. A validation error
never echoes a secret, a header value, or a query value either.
Webhook fields
Section titled “Webhook fields”| Field | Type | Description | Required? |
|---|---|---|---|
url | string | The URL to call. Must start with https://. May contain placeholders. | Yes |
method | "POST" | "PUT" | "PATCH" | "GET" | Default: "POST". A GET sends no body. | No |
headers | object | Extra request headers, up to 1 KiB in total. Put your receiver’s credentials here. Host, Content-Length, Connection, Transfer-Encoding, Upgrade, TE, Trailer, Proxy-Authorization, and any name starting with X-Aivi- are refused. | No |
body | any JSON | A JSON template of up to 2 KiB whose strings may contain placeholders. Omit it, or send null, to receive the event envelope as the body. | No |
secret | string | Turns on request signing. Use a long random string. | No |
Every request carries Idempotency-Key: <event id>, and every request
except a GET carries Content-Type: application/json.
Placeholders
Section titled “Placeholders”Strings in url and in body may contain placeholders, which Aivi replaces
for each event:
| Placeholder | Value |
|---|---|
{{action}} | The pressed action’s id. |
{{kind}} | button or toggle. |
{{value}} | true or false for a toggle, empty for a button. |
{{slug}} | The subject’s slug: the widget’s slug. |
{{subject}} | The subject as <kind>/<slug>, for example widget/living-room. |
{{event_id}} | The event’s id, the same value as the Idempotency-Key header. |
{{request_id}} | The envelope’s request_id: the id the phone gave the press. |
{{event_json}} | The whole event envelope, as a JSON string. |
A placeholder always produces text. In a body, "on": "{{value}}" sends
the string "true", not the boolean true, and "event": "{{event_json}}"
sends the envelope as an escaped JSON string that your receiver parses. In a
URL, placeholders are inserted as they are, without URL encoding; action ids,
kinds, and values never need it. {{kind}} is the action’s kind from
data, not the subject’s kind.
For example, a body template for a receiver that expects its own shape:
{ "url": "https://hooks.example.com/aivi/{{action}}", "body": { "widget": "{{slug}}", "turn_on": "{{value}}" }}Verifying signatures
Section titled “Verifying signatures”When an entry has a secret, every request also carries two headers:
X-Aivi-Timestamp: 1790881447X-Aivi-Signature: v1=5d41402abc4b2a76b9719d911017c592...The signature is v1= followed by the hex HMAC-SHA256, keyed with your
secret, of this string:
{timestamp}.{METHOD}.{url}.{body_sha256}.{event_id}timestampis theX-Aivi-Timestampvalue, in Unix seconds.METHODis the request method in capitals, for examplePOST.urlis the exact URL Aivi called, with placeholders filled in.body_sha256is the lowercase hex SHA-256 of the body bytes as received. AGEThas no body, so it is the SHA-256 of zero bytes.event_idis the event’sid, which is also theIdempotency-Key.
Because the method, the URL, and the event id are signed along with the
body, a GET webhook that carries everything in its URL is covered as well.
To verify, rebuild the string, compute the HMAC, compare it in constant
time, and reject old timestamps so a captured request can’t be replayed:
import hashlibimport hmacimport time
def verify(secret: str, method: str, url: str, body: bytes, headers) -> bool: timestamp = headers["X-Aivi-Timestamp"] if abs(time.time() - int(timestamp)) > 300: return False message = ".".join([ timestamp, method, url, hashlib.sha256(body).hexdigest(), headers["Idempotency-Key"], ]) digest = hmac.new(secret.encode(), message.encode(), hashlib.sha256) return hmac.compare_digest("v1=" + digest.hexdigest(), headers["X-Aivi-Signature"])A receiver that can’t compute an HMAC, such as a Home Assistant automation,
can check a token of its own instead: put it in headers or body, and
compare it on arrival.
Delivery rules
Section titled “Delivery rules”- At most two attempts, quickly. Each attempt has 2 seconds to get a
2xxresponse. - Only transient failures are retried: a network error (DNS,
connection refused or reset, no response), a timeout, or a
408,425,429, or5xxother than501and505. Any other answer is final and gets one attempt: a4xxsuch as400,401,404, or422, a501or505, or a redirect, which is never followed. - Only while there is time. A retry starts only if a whole 2-second
attempt still fits before the event’s
expires_at. ARetry-Afteron a429or503is honoured only if the wait and a whole attempt still fit; otherwise there is no retry. - Public HTTPS on port 443 only. The host must resolve to a public address, and the URL can’t name another port. A webhook can’t reach a server on your home network directly; use the event stream for that, or a public relay such as Home Assistant Cloud.
- Answer fast, work later. Respond as soon as you have accepted the
event, and do the slow part afterwards. A slow response counts as a
failure and can bring a second attempt with the same
Idempotency-Key, so deduplicate on it.
Confirming the result
Section titled “Confirming the result”An event is a request, not a fact. When your system has carried it out,
say so by publishing the new state with the usual
PATCH /widget/{slug}. For a switchboard,
that means setting the toggle’s on, or the button’s active, to what is
true now. Content replaces the previous content as a whole, so send every
action, not only the one that changed:
curl https://api.getaivi.app/widget/living-room \ -X PATCH \ -H "Content-Type: application/json" \ -H "Authorization: Token YOUR_API_TOKEN" \ --data-binary @- << 'EOF'{ "content": { "template": "switchboard", "actions": [ { "id": "movie", "label": "Movie", "icon": "tv.fill", "active": true }, { "id": "porch", "label": "Porch light", "icon": "lightbulb.fill", "kind": "toggle", "on": true } ] }}EOFThe update does two things: it confirms the press on the phone that sent
it, and it refreshes the widget on every other phone showing it. A toggle
is confirmed by content that shows it in the requested state. A button is
confirmed by any update after the press, even one with nothing changed, so
publish after every button press, whether or not you set active. The
phone that sent the press waits for your update, up to 15 seconds for a
toggle and 5 for a button, and shows it the moment it arrives. Other
phones get the update through iOS, which can take longer. If you
publish whenever the underlying device changes anyway, you don’t need extra
code: the change the press caused triggers the confirming update.
Publish the true state, even when a press failed. A toggle that stays off because the light is unreachable should be published as off: the phone then shows off instead of waiting, and never says No response. Interactive Widgets describes exactly how a tile looks while it waits.
Aivi: Live Activities and widgets over an API · Pricing · App Store