Skip to content
Get the appGet app

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.

{
"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 }
}
FieldDescription
idThe 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_idThe 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.
typeWhat happened, including the version of its data, for example widget.action.v1. See the catalog.
subjectThe 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_atWhen the event happened, in ISO 8601.
expires_atWhen 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.
dataThe payload. Its shape depends on type.

The envelope is the same on the event stream and in a webhook’s default body.

Someone pressed a tile on an interactive widget, such as a switchboard. The subject is { "kind": "widget", "slug": "<the widget's slug>" }.

FieldTypeDescription
actionstringThe id of the pressed action, as you published it in the widget’s content.
kind"button" | "toggle"The action’s kind.
valueboolToggles 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.

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.

GET /events

Opens 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 parameterTypeDescription
typesstring, repeatableOnly events of these types, for example types=widget.action.v1. Omit for every type.
subjectstring, repeatableOnly 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: 1000
id: u:6
: ping
id: u:7
data: {"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 last id: you saw in a Last-Event-ID header when you reconnect, and the stream first replays the events you missed that haven’t expired yet. Browser EventSource clients do this on their own. Without the header nothing is replayed, and expired events are never replayed, so keep the gap short. Send the id: 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’s id.
  • At most 4 streams per account. A fifth returns 429 with Too many event streams. Close streams you no longer need.
  • Filters name the kind. A subject filter must have the <kind>/<slug> form. A bare slug, or an unknown kind, is rejected with 422 rather 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 200 with a stream that holds only a retry: line and ends. Reconnect after the delay, as after any closed stream. A browser EventSource keeps 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 json
import 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.

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}/webhooks
curl 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"
}
}
}
EOF

The 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.

RequestResult
PUT /widget/{slug}/webhooksReplaces the map and returns it, redacted.
GET /widget/{slug}/webhooksReturns 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}/webhooksRemoves 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.

FieldTypeDescriptionRequired?
urlstringThe URL to call. Must start with https://. May contain placeholders.Yes
method"POST" | "PUT" | "PATCH" | "GET"Default: "POST". A GET sends no body.No
headersobjectExtra 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
bodyany JSONA 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
secretstringTurns 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.

Strings in url and in body may contain placeholders, which Aivi replaces for each event:

PlaceholderValue
{{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}}" }
}

When an entry has a secret, every request also carries two headers:

X-Aivi-Timestamp: 1790881447
X-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}
  • timestamp is the X-Aivi-Timestamp value, in Unix seconds.
  • METHOD is the request method in capitals, for example POST.
  • url is the exact URL Aivi called, with placeholders filled in.
  • body_sha256 is the lowercase hex SHA-256 of the body bytes as received. A GET has no body, so it is the SHA-256 of zero bytes.
  • event_id is the event’s id, which is also the Idempotency-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 hashlib
import hmac
import 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.

  • At most two attempts, quickly. Each attempt has 2 seconds to get a 2xx response.
  • Only transient failures are retried: a network error (DNS, connection refused or reset, no response), a timeout, or a 408, 425, 429, or 5xx other than 501 and 505. Any other answer is final and gets one attempt: a 4xx such as 400, 401, 404, or 422, a 501 or 505, 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. A Retry-After on a 429 or 503 is 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.

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 }
]
}
}
EOF

The 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.