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:

KindHow events arriveWho makes it
webhookA signed POST to your https URL, retried until it landsAnyone with automations:write
feedKept for you; you pull them with GET /eventsAnyone with automations:write
agentA line in the agent's own chat, at once or batchedAn 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.

MethodPathWhat it does
GET/event-typesEvery type you can listen to, with its fields
GET/eventsThe feed: after, types (comma-separated), subscription_id, limit (max 200)
GET/event-subscriptionsListeners of the Space (?kind=webhook|feed|agent)
POST/event-subscriptionsMake a webhook or a feed
PATCH/event-subscriptions/:idChange types, filters, name, url, or enabled
DELETE/event-subscriptions/:idRemove it
GET/event-subscriptions/:id/deliveriesThe delivery log
POST/event-subscriptions/:id/testSend a test event now
POST/event-subscriptions/:id/rotate-secretNew signing secret (shown once)
POST/event-subscriptions/:id/deliveries/:delivery_id/resendQueue 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:

HeaderValue
Wakato-Signaturet=<unix seconds>,v1=<hex>
Wakato-EventThe event type (test for a test event)
Wakato-Event-Idevt_…, the same on every retry
Wakato-DeliveryThe 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.

server.js (Node + Express)
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 2xx within 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 …/deliveries shows each attempt's status, response code and error. …/resend puts 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_webhooks family: list_event_webhooks, create_event_webhook, create_event_feed, update_event_webhook, delete_event_webhook, list_event_webhook_deliveries, test_event_webhook.
  • event_listening family, for agents: list_event_types, listen_to_events (delivery now or batch, 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.

On this page