# 09 — Realtime Triggers

Realtime triggers are driven by runtime-owned SYSTEM methods — you never write them. Declarative config
maps onto them:

| SYSTEM method (runtime-owned) | Phase | What you declare |
|---|---|---|
| `handleTriggerUpsertWebhook` | flow start | `subscribe` |
| `handleTriggerResolveEvents` | inbound (shape/handshake) | `extractProviderEventName` + `extractProviderEventData` (+ `handshake`) — or `resolveEvents` |
| `handleTriggerSelectMatched` | inbound (select/enrich) | per-trigger `match` (+ `extendResult`) |
| `handleTriggerRefreshWebhook` | refresh timer | `refresh` |
| `handleTriggerDeleteWebhook` | flow stop | `unsubscribe` |

## Unknown trigger names

An `id` or `eventName` the service does not declare as a trigger is reachable in normal operation — a flow
built against an older version of the service, whose trigger was later renamed or removed, keeps sending the
old name. What happens then is **not** uniform across the SYSTEM methods, and the split is deliberate:

**Control plane throws, data plane declines.**

| SYSTEM method | Unknown name | Why |
|---|---|---|
| `handleTriggerUpsertWebhook` | **throws** `UnknownTriggerError` (404) | control plane |
| `handleTriggerRefreshWebhook` | **throws** `UnknownTriggerError` (404) | control plane |
| `handleTriggerPollingForEvent` | **throws** `UnknownTriggerError` (404) | control plane |
| `handleTriggerSelectMatched` | **declines** — `{ ids: [], enrichedEventData: null }` | data plane |
| `handleTriggerDeleteWebhook` | n/a — carries no trigger name | tears down the whole webhook |

- **Control plane** — upsert, refresh, polling. FlowRunner initiates these itself (enabling a flow, a
  scheduled renewal). Something is waiting on the result, the error reaches a place where it can be acted
  on, and no third party is affected. Upsert in particular is the platform asking *"is this flow
  startable?"* — a version only goes LIVE once upsert has succeeded, so answering `{}` to a trigger that was
  never registered with the provider produces a flow that reports LIVE and can never fire.
- **Data plane** — `selectMatched`. It runs *after* the provider has already been answered, on a detached
  thread, with nothing waiting. A throw reaches nobody, and it would abort the remaining events of the same
  delivery — dropping good events beside the stale name.

An upsert carries **every** realtime trigger of the flow version being enabled, so one unknown name blocks
the whole batch from subscribing. That is intended: a flow that refuses to start beats a live flow with half
its triggers registered.

Note the two callers of the same throw differ in where it surfaces: **upsert** fails a user's flow-enable
request and the error reaches the client, while **refresh** runs from a scheduled job — the flow keeps
running and the error appears in the customer's **workspace log** on each refresh. Neither is silent.

## `ext.setupRealtimeTriggers(props)`

| Property | Type | Description |
|---|---|---|
| `scope` | `'SINGLE_APP' \| 'ALL_APPS'` | Per-app webhook vs one shared callback. |
| `extractProviderEventName` | path \| fn `({requestBody})=>name\|null` | Discriminator → routes to triggers by `providerEventName` (fan-out: matches every trigger with that value). |
| `extractProviderEventData` | path \| fn | Base `providerEventData` handed to matched triggers. Default = whole body. |
| `scopeAt` | path | ALL_APPS routing key path (route where `scopeAt === eventScopeId`). |
| `verify` | object | Declarative signature check — see the field table below. |
| `handshake` | fn | Endpoint verification (see table). |
| `subscribe` | fn | Required (see table). |
| `unsubscribe` | fn | Required (see table). |
| `refresh` | fn | Optional. |
| `resolveEvents` | fn | Escape hatch — replaces `extract*`/`handshake` for irregular/multi-shape payloads. |
| `verifyRequest` | fn | Escape hatch — replaces `verify`. |

**Function args → returns.** All of these also receive the full ctx bag — `config, context, apiRequest,
oauth, logger, helpers` — in the *same* single object as their own arguments. That includes `verifyRequest`,
so a value derived in `initContext` is reachable there; prefer it over re-deriving from `config`
([16-gotchas.md](16-gotchas.md) on normalizing once).

**`headers` arrive verbatim**, in whatever casing the provider sent — they are *not* lowercased or otherwise
normalized on the way to `verifyRequest`. Look them up case-insensitively. (The declarative `verify` block
does its own lowercased retry; that behaviour does not extend to `verifyRequest`.)

| Function | Arguments | Returns |
|---|---|---|
| `subscribe` | `callbackUrl`, `triggers:[{ id, params, providerEventName? }]`, `webhookData` (null first), `connectionId` | `{ webhookData?, eventScopeId?, refreshIntervalInSeconds? }` |
| `unsubscribe` | `callbackUrl`, `webhookData`, `connectionId` | `{ webhookData? }` |
| `refresh` | `callbackUrl`, `triggers:[{ id, params, providerEventName? }]`, `webhookData` | `{ webhookData?, refreshIntervalInSeconds }` |
| `handshake` | `{ requestBody, headers, rawBody }` | `{ responseToWebhook? }` (runtime adds `handshake:true`); nothing → proceed. Empty-body deliveries are auto-acked by default (no `handshake` needed). |
| `resolveEvents` | `rawBody`, `headers`, `query`, `body`, `connectionId` | `{ events:[{ id, providerEventData }], eventScopeId?, handshake?, responseToWebhook? }` |

Both also still accept the **deprecated** `responseToExternalService` / `responseHeaders` — see
[below](#responsetoexternalservice--deprecated).
| `verifyRequest` | `{ rawBody, headers }` + the full ctx bag — `context` included | `boolean` (plain, not a Promise) |

**The `triggers` bag holds one entry per (trigger × `providerEventName`)**, so a trigger declaring several
names appears several times. A trigger that declares **no** `providerEventName` — what you write when
`resolveEvents` emits `id` directly — appears **once, with `providerEventName` absent**: it is a routing
declaration, and having none does not stop the trigger running or stop `subscribe`/`refresh` having to
reconcile it. Guard the field rather than assuming it (`if (t.providerEventName)`), and never key a lookup
on it unconditionally. Nothing is left out of the bag: a name resolving to no trigger **rejects the whole
call** — see [Unknown trigger names](#unknown-trigger-names) below.

### `responseToWebhook` — answering the provider

Some providers do not accept a generic `200`. Xero's *Intent to Receive* validation sends two deliveries and
checks both answers: a correctly signed one must get **2xx**, a deliberately mis-signed one must get **401**.
Return `responseToWebhook` from `handshake` or `resolveEvents` to say exactly what the provider receives.

```js
return {
  events: [],
  responseToWebhook: {
    httpStatus: 401,               // optional, 200 by default
    headers   : { 'X-Ack': 'ok' }, // optional
    body      : { ok: true },      // optional, JSON-serialized
    rawBody   : '',                // optional, sent as-is — wins over `body`
  },
}
```

- **`rawBody` beats `body`.** `body` is always JSON-serialized, so a String passed there arrives quoted. Use
  `rawBody` when the answer is not a JSON document — a challenge token, or an empty body.
- **Content type** defaults to `application/json` for `body` and `text/plain` for `rawBody`, unless `headers`
  sets one.
- **Allowed `httpStatus`:** `200, 201, 202, 204, 400, 401, 403, 404, 409, 410, 422, 429`. Anything else falls
  back to `200` with a warning. **3xx is refused on purpose** — these callback URLs are handed to third
  parties, so honouring a redirect would make any service an open redirector.
- **Dropped headers:** `Set-Cookie`, `Content-Length`, `Transfer-Encoding` and other hop-by-hop names, and
  any header whose name or value contains CR/LF (response splitting).
- **Omit it and nothing changes** — the delivery is answered `200` with an empty body, as before.

#### `responseToExternalService` — deprecated

`responseToExternalService` and `responseHeaders` are **deprecated**. They still work — the runtime forwards
them to the platform untouched, so an existing service keeps behaving exactly as it did and needs no edit —
but they can only say *what the body is*, never what status or headers go with it. **Write
`responseToWebhook` in new code**, and move an old handler over when you next touch it:

| Deprecated | Write instead | Why |
|---|---|---|
| `responseToExternalService: 'token'` | `responseToWebhook: { rawBody: 'token' }` | a String was sent as raw `text/plain` — `body` is JSON, so it would arrive **quoted** |
| `responseToExternalService: { ok: true }` | `responseToWebhook: { body: { ok: true } }` | an object was JSON-serialized, and still is |
| `responseHeaders: { 'X-Echo': 'ok' }` | `responseToWebhook: { headers: { 'X-Echo': 'ok' } }` | |

⚠️ The String case is the one that bites: `body` would deliver `"token"` with the quotes, and a provider
comparing it to the token it sent sees a mismatch. **A String is always `rawBody`.**

⚠️ Do not return both from one response. They are forwarded side by side and the platform picks one — which
one is not yours to choose. Write one or the other.

**Reject by returning, never by throwing.** A throw on this path answers the provider **500**, and inbound
deliveries are **not retried** (every other trigger request type still is), so a rejection expressed as an
error is simply a failed delivery. The declarative `verify` block already follows this rule: a failed
signature check answers `{ events: [], responseToWebhook: { httpStatus: 401, rawBody: '' } }` — it does not
throw.

### ⚠️ `rawBody` is absent, never empty

`rawBody` is the unmodified bytes of the request, decoded as UTF-8 — and it is **absent** when there is
nothing to verify: a `GET`, a request with no body, a request sent without `Content-Length`, or a body over
5 MB. **Treat absent as "cannot verify"**, and answer accordingly:

```js
verifyRequest: ({ rawBody, headers }) => {
  if (typeof rawBody !== 'string') {
    return false   // ✓ nothing to hash — this is not the same as a bad signature
  }
  …
}
```

Hashing `''` instead produces a digest that can never match, which reads as a *signature mismatch* rather
than the missing input it is — and sends you hunting for a wrong secret.

### `verify` fields

HMACs the **raw body alone** with `secret(config)` and compares it to the header in constant time.

| Field | Req | Description |
|---|---|---|
| `header` | ✓ | Signature header. Read as declared, then retried lowercased. Absent header → fail. |
| `algo` | ✓ | Hash algorithm, e.g. `'sha256'`. |
| `encoding` | ✓ | `'hex' \| 'base64'` — the digest encoding the provider sends. |
| `secret` | ✓ | `(config) => key`. |
| `timestampHeader` | – | Header carrying the request timestamp. **Required for `freshness` to run.** |
| `freshness` | – | Max age in seconds. **Only checked when `timestampHeader` is also declared** — on its own it is silently inert. A declared-but-absent timestamp header passes. |

The signed payload is `rawBody` and nothing else. A provider that signs a **composed** string
(`{timestamp}.{body}`, a URL, a concatenation of fields) cannot be expressed here — use `verifyRequest` with
`Flowrunner.crypto`.

`subscribe` **is an upsert** (re-runs with the full desired set + prior `webhookData`; `unsubscribe` only
on flow stop). Two strategies:

- **Recreate** (Calendly, Backendless, joggai) — delete prior `webhookData` first, then create.
- **Reconcile/diff** (Slack, Box) — apply only additions/removals (no teardown).

Runtime provides `Flowrunner.crypto.hmac(algo,key,data,'hex'|'base64')` — **synchronous**, returning the
encoded digest as a string, not a Promise — `timingSafeEqual(a,b)` (false — not a throw — on nullish or
length mismatch, so a caller can `return` it), and `ageSeconds(timestamp)`.
`connectionId` + `webhookData` are runtime-owned/persisted.

## One trigger per event

**Register a separate trigger for each provider event. Never a single trigger with an event-type
parameter.** A "universal" trigger that takes `eventTypes: z.array(z.enum([...]))` and fans out inside its
own handler is wrong here, and the mechanism above is why: `extractProviderEventName` already routes each
inbound delivery to every trigger claiming that `providerEventName`, over **one** shared webhook
registration. `subscribe` receives the full desired trigger set, so it registers one URL for all of them.

Splitting them costs nothing at the transport level and buys everything at the flow level: each trigger gets
its own `label`, its own `description`, its own `result` sample and its own `params` — so a flow author picks
"On Message Edited" from a list instead of picking a generic trigger and then configuring which event they
meant. A `result` on a universal trigger cannot describe anything precise, because the payload differs per
event.

```js
// ✗ one trigger, an event-type param, and a result sample that fits none of them
ext.addRealtimeTrigger({
  id    : 'onUpdate',
  params: z.object({ updateTypes: z.array(z.enum(['message', 'edited_message', 'callback_query'])) }),
  result: { update_id: 1 },
})

// ✓ one per event — each with the shape that event actually delivers
ext.addRealtimeTrigger({ id: 'onMessage',       providerEventName: 'message',        result: { … } })
ext.addRealtimeTrigger({ id: 'onEditedMessage', providerEventName: 'edited_message', result: { … } })
ext.addRealtimeTrigger({ id: 'onCallbackQuery', providerEventName: 'callback_query', result: { … } })
```

Use `params` for **filters** — which chat, which board, which status — never for choosing the event.

## `ext.addRealtimeTrigger(props)`

| Property | Type | Req | Description |
|---|---|---|---|
| `category`/`id`/`label`/`description` | string | ✓ | `id` is persisted and never changes. |
| `providerEventName` | string \| string[] \| `({params})=>string[]` | ✓* | Provider event(s) this trigger claims (routing). *Omitted when `resolveEvents` emits `id` directly. |
| `params` | `z.object` | – | Trigger config — omit if it takes none (see [03-parameters.md](03-parameters.md)). |
| `result` | value \| schema \| loader | ✓ | Emitted event shape (post-`extendResult`). |
| `extendEventData` | fn | – | see table. |
| `match` | object \| fn | – | Instance selection (declarative object or predicate fn). |
| `extendResult` | fn | – | see table. |
| `oauth2Scopes` | string[] | – | Extra scopes. |

| Function | Arguments | Returns |
|---|---|---|
| `extendEventData` | `{ requestBody, providerEventData }` | partial object (merged onto `providerEventData`, **pure, pre-match**) |
| `match` (object) | `{ paramKey: 'path.in.providerEventData' }` | declarative — type-coerced equality per filled param; empty param → all |
| `match` (fn) | `{ params, providerEventData }` | `boolean` — for ranges/contains/derived logic |
| `extendResult` | `{ providerEventData, apiRequest, … }` | augmented `providerEventData` (**post-match, once if matched, may call API**); surfaces as `enrichedEventData` in the runtime response |

**Inbound runtime flow:** `handshake?` → `verify`/`verifyRequest` → read `extractProviderEventName` → for
each trigger whose `providerEventName` matches, build `providerEventData` (`extractProviderEventData` +
`extendEventData`) → `match` per instance → if ≥1 matched, `extendResult` once → fire.

**A trigger declaring no `match` fires every configured instance** — "no filter" means "fires always", and
that is a different case from a name this service does not declare, which selects nothing (see
[Unknown trigger names](#unknown-trigger-names)). Declining an unknown name never runs `extendResult`, so it
costs no provider call.

## `webhookData` lifecycle (persisted; realtime analog of polling `state`)

| Phase | Effect |
|---|---|
| subscribe (flow start) | `null → webhookData` (create webhook / resolve `eventScopeId`) |
| subscribe (set changed) | `webhookData → webhookData'` (recreate: delete prior + create; or diff) |
| inbound event | unchanged \| emits matched instances |
| refresh (opt) | `webhookData → webhookData'` (renew) |
| unsubscribe (flow stop) | `webhookData → {}` (tear down) |

## Frozen contract

These are persisted or read by live flows, so they are fixed once a service is deployed — changing one breaks
running automations:

`id` (trigger/action) · `params` keys · **`webhookData` shape** · `eventScopeId` ·
`refreshIntervalInSeconds` (seconds).

Transient and free to change: shaped events, matched ids, handshake responses.
