Events and webhookshear what happens, live.
Hear what happens in a Space as it happens: signed webhooks to your own URL, a feed you pull by cursor, or a line in an agent's chat.
Everything that happens in a Space is an event: a contact was created, an order was paid, a form was submitted, a booking was moved, a task changed status. These are the same events Space automations start from, about 200 of them, and you can listen to them from outside Wakato.
A listener names the event types it wants and how they reach it:
| Kind | How events arrive | Who makes it |
|---|---|---|
webhook | A signed POST to your https URL, retried until it lands | Anyone with automations:write |
feed | Kept for you; you pull them with GET /events | Anyone with automations:write |
agent | A line in the agent's own chat, at once or batched | An agent, for itself |
A Space may have up to 25 webhooks and 25 feeds; an agent up to 25 listeners.
An event
A webhook body and an item of the feed have the same shape:
{
"id": "evt_48213",
"type": "commerce_order_paid",
"label": "Order paid",
"space_id": "6f1c…",
"occurred_at": "2026-10-11T14:02:11.418Z",
"data": {
"order_id": "a91e…",
"product_id": "3c0d…",
"product_title": "Linen apron",
"amount": 48,
"amount_cents": 4800,
"currency": "usd",
"contact_id": "d27b…",
"contact": { "name": "Ana Ruiz", "first_name": "Ana", "email": "ana@example.com", "phone": null },
"actor": { "kind": "person", "name": "Ana Ruiz" },
"actor_kind": "person"
},
"delivery_id": 90321,
"attempt": 1
}data depends on the type. Before anyone hears an event, Wakato adds what it
can about who and what: the person as contact_id plus a contact
block when one is found, names beside ids, a form's answers, and who did it
(actor, actor_kind = person, agent, automation or api_key). Webhook bodies
also carry delivery_id and attempt.
Event types
List them with their data fields:
curl https://api.wakato.io/functions/v1/space-api/event-types \
-H "Authorization: Bearer $WAKATO_SPACE_KEY"Add ?type=form_submission for one type, or ?group=Commerce for a group.
Name types in event_types, or use "*" for every event of the Space.
REST routes
All under https://api.wakato.io/functions/v1/space-api, with the
automations scope: read for GET, write for everything else.
| Method | Path | What it does |
|---|---|---|
| GET | /event-types | Every type you can listen to, with its fields |
| GET | /events | The feed: after, types (comma-separated), subscription_id, limit (max 200) |
| GET | /event-subscriptions | Listeners of the Space (?kind=webhook|feed|agent) |
| POST | /event-subscriptions | Make a webhook or a feed |
| PATCH | /event-subscriptions/:id | Change types, filters, name, url, or enabled |
| DELETE | /event-subscriptions/:id | Remove it |
| GET | /event-subscriptions/:id/deliveries | The delivery log |
| POST | /event-subscriptions/:id/test | Send a test event now |
| POST | /event-subscriptions/:id/rotate-secret | New signing secret (shown once) |
| POST | /event-subscriptions/:id/deliveries/:delivery_id/resend | Queue a delivery again |
Full schemas: Events reference.
Make a webhook
curl -X POST https://api.wakato.io/functions/v1/space-api/event-subscriptions \
-H "Authorization: Bearer $WAKATO_SPACE_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "webhook",
"name": "Orders to fulfilment",
"url": "https://example.com/hooks/wakato",
"event_types": ["commerce_order_paid", "commerce_order_refunded"]
}'The answer holds the subscription and a secret (whsec_…). The secret is
shown once. Store it; rotate-secret makes a new one and the old one stops
working at once.
The URL must start with https:// and be reachable from the internet; private
and internal addresses are refused.
Verify the signature
Every delivery carries these headers:
| Header | Value |
|---|---|
Wakato-Signature | t=<unix seconds>,v1=<hex> |
Wakato-Event | The event type (test for a test event) |
Wakato-Event-Id | evt_…, the same on every retry |
Wakato-Delivery | The delivery id |
v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex. Check it against the
raw body, before any JSON parsing, and refuse an old t.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.WAKATO_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post("/hooks/wakato", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Wakato-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const raw = req.body.toString("utf8");
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const ok =
given.length === expected.length / 2 &&
crypto.timingSafeEqual(given, Buffer.from(expected, "hex")) &&
Math.abs(Date.now() / 1000 - t) < 300;
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(raw);
// Dedupe on event.id: a retry carries the same id.
console.log(event.type, event.data);
res.sendStatus(200);
});
app.listen(3000);Delivery and retries
- Answer with any
2xxwithin 10 seconds. Anything else counts as a failure. Redirects are not followed; give the final URL. - A failed delivery is retried after 1, 5, 15, 60, 180, 360 and 720 minutes: 8 attempts over about 22 hours. Then it is marked dead.
- After 15 deliveries in a row die, the webhook is switched off and says
why. Switching it back on (
PATCH {"enabled": true}) clears the count. GET …/deliveriesshows each attempt's status, response code and error.…/resendputs one back in the queue, a dead one included.
Make your handler idempotent on id: an event can arrive more than once.
Feeds
A feed keeps events so you can pull them on your own schedule.
# Make a feed
curl -X POST "$BASE/event-subscriptions" -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"kind":"feed","name":"Nightly sync","event_types":["contact_created","form_submission"]}'
# Pull, then pass next_cursor as ?after= next time
curl "$BASE/events?subscription_id=FEED_ID&limit=100" -H "Authorization: Bearer $KEY"The answer is { events, next_cursor, has_more }, oldest first. Events are
kept for 14 days, and only while some listener wants their type, so pull at
least that often.
Filters
filters narrows a listener per type, using the same config the matching
automation trigger takes:
{
"event_types": ["form_submission", "table_row_created"],
"filters": {
"form_submission": { "form_id": "8b2e…" },
"table_row_created": { "table_id": "41fa…" }
}
}actor_kind works on any event, so { "actor_kind": "person" } skips changes
made by agents and automations.
From MCP
The same listeners are open to MCP clients and agents, under the same scope:
event_webhooksfamily:list_event_webhooks,create_event_webhook,create_event_feed,update_event_webhook,delete_event_webhook,list_event_webhook_deliveries,test_event_webhook.event_listeningfamily, for agents:list_event_types,listen_to_events(deliverynoworbatch, every 1–1440 minutes),list_my_listeners,update_my_listener,stop_listening,get_events.
Only an agent's own key can make a listener of kind agent, and only for
itself. See the automation tools.
What an agent hears
An agent's listener, and any webhook or feed an agent made, hears only what the
agent's Can use lets it touch. An event about a table, document, app, page,
form, automation or folder outside that list never reaches it, and its
get_events leaves it out. See Keys and scopes.