> ## Documentation Index
> Fetch the complete documentation index at: https://docs.beta.b3os.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Triggers

Start routine runs on a cron schedule, at an interval, from signed webhooks, or from pack triggers such as a new GitHub issue or a paid Stripe invoice.

Besides `POST /v1/routines/{routine}/runs`, a routine starts runs from its `triggers`, up to 20 of them. Each trigger has an `id`, unique in the routine. Triggers switch atomically when you publish a new version, and pause together when you pause the routine.

Every trigger can take `with`, which builds the run's input from the trigger context (absent, the input is `{}` plus the input defaults), and `if`, a condition that must hold for a run to start.

## Cron

```yaml
triggers:
  - type: cron
    id: nightly
    cron: "0 30 6 * * MON-FRI"   # 06:30:00 on weekdays
    tz: America/Los_Angeles
    catch_up: latest
    overlap: skip
```

- `cron` has six fields, seconds first: second, minute, hour, day of month, month, day of week. Numbers, names (`JAN`, `MON`), `*`, ranges, lists, steps (`*/5`) and `?` are accepted, plus `L`, `W` and `#` in the day fields.
- `tz` is an IANA time zone, UTC by default. A local time skipped by a daylight-saving change fires at the first instant after the gap; a local time that occurs twice fires once.
- `catch_up` is required. It decides what happens to occurrences missed while the node was down or the routine was paused, within `catch_up_window_s` (a day by default): `skip` runs none, `latest` runs only the newest, `all` runs each in order.
- `overlap` decides what happens when an occurrence fires while this trigger's previous run is still active: `skip` (the default) drops it, `buffer_one` keeps one pending occurrence, `allow` starts it anyway, `cancel_running` cancels the active run first.

Occurrences are scheduled ahead and deduplicated, so one occurrence never starts two runs. The target for firing lateness is p99 5 ms; see [benchmarks](/benchmarks).

## Interval

```yaml
- type: interval
  id: every_five_minutes
  every_ms: 300000
  anchor: epoch
  catch_up: skip
```

`every_ms` is at least 1000. `anchor: epoch` (the default) fires on multiples of the period since the Unix epoch, aligned to the clock; `publish` fires one period after the version became active, then every period. `catch_up` and `overlap` work as for cron.

## Webhooks

```yaml
- type: webhook
  id: inbound
  verify:
    scheme: b3os
  with:
    pr: ${{ trigger.body.number }}
```

Publishing gives the trigger a hook with an unguessable ID and the URL to hand to the sender:

```text
https://beta-api.b3os.org/v1/routines/hooks/hk_01m5t3r2qwz23751t0x74bhahz
```

The routine's `triggers[].hook` shows the URL. Every delivery must pass verification; there is no unverified webhook. `verify.scheme` is one of:

| Scheme | Verifies |
|---|---|
| `b3os` | `B3OS-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`, with a secret B3OS generates. The secret is returned once, in the publish response. |
| `github`, `stripe`, `slack`, `standard_webhooks` | The provider's own signature scheme |
| `hmac_sha256` | A generic HMAC over `<timestamp>.<body>`: needs `header`, `timestamp_header` and a `dedupe` key on the trigger, and optionally `encoding` and `prefix` |

For every scheme except `b3os`, store the provider's signing secret as a verification-only [vault connection](/vault) (type `api_key`, no pack and no hosts, so it is never injected anywhere) and name it in `verify.secret`. Timestamped schemes refuse deliveries more than `tolerance_s` from the node's clock (300 seconds, the most allowed). A bad signature is [`webhook_signature_invalid`](/errors#webhook_signature_invalid).

A delivery whose `if` is false is acknowledged with `202` and starts nothing. If the node is down, the edge stores deliveries durably and replays them in order when it returns. In expressions, `trigger` holds the request: `hook_id`, `delivery_id`, `received_at`, `method`, `headers` (lowercase), `query` and `body` (parsed when it is JSON).

### Duplicate deliveries

Senders retry, so a webhook trigger drops repeats by a key. `dedupe` sets that key as one expression over `trigger` and `vars`, such as `${{ trigger.headers['x-delivery-id'] }}`. A second delivery with the same key within 24 hours gets the first delivery's response and starts nothing. Without `dedupe`, each scheme uses its own key:

| Scheme | Default key |
|---|---|
| `github` | The `X-GitHub-Delivery` header |
| `stripe` | The event's `id` in the body |
| `slack` | The `event_id` in the body |
| `standard_webhooks` | The `webhook-id` header |
| `b3os` | The `Idempotency-Key` header if the sender sends one, otherwise the `v1` signature |
| `hmac_sha256` | None: `dedupe` is required, and a definition without it does not publish |

```yaml
- type: webhook
  id: orders
  verify:
    scheme: hmac_sha256
    secret: conn_01m5t6cbgdjs1ketgcqwa2sne8
    header: X-Shop-Signature
    timestamp_header: X-Shop-Timestamp
  dedupe: ${{ trigger.headers['x-shop-delivery'] }}
```

### Send a test delivery

A `b3os` hook needs no API key: the hook ID and the signature authenticate the delivery. To try one, sign a body with the secret from the publish response and post it to the hook URL:

**curl**

```bash
    BODY='{"number": 42}'
    T=$(date +%s)
    SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$HOOK_SECRET" | sed 's/^.* //')
    curl -s "$HOOK_URL" \
      -H "Content-Type: application/json" \
      -H "B3OS-Signature: t=$T,v1=$SIG" \
      -d "$BODY"
    ```
  **TypeScript**

```ts
    import { signWebhook } from "@b3os/sdk/webhooks";

    const body = JSON.stringify({ number: 42 });
    const response = await fetch(process.env.HOOK_URL ?? "", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "B3OS-Signature": await signWebhook(body, process.env.HOOK_SECRET ?? ""),
      },
      body,
    });
    console.log(response.status, await response.json());
    ```
  **Python**

```python
    import json
    import os
    import urllib.request
    from b3os import sign_webhook

    body = json.dumps({"number": 42}).encode()
    request = urllib.request.Request(os.environ["HOOK_URL"], data=body, method="POST", headers={
        "Content-Type": "application/json",
        "B3OS-Signature": sign_webhook(body, os.environ["HOOK_SECRET"]),
    })
    with urllib.request.urlopen(request) as response:
        print(response.status, json.load(response))
    ```
  **MCP**

Deliveries come from the sending service, not from an agent, and no MCP tool sends them.

The answer is `202` with a `hook_delivery`: its `run_id`, or `deduplicated: true` for a repeat. A delivery that fails verification gets [`webhook_signature_invalid`](/errors#webhook_signature_invalid).

### Rotate the secret

`POST /v1/routines/{routine}/hooks/{hook_id}/rotate` issues a new `b3os` secret and keeps the previous one valid for `grace_s` (a day by default) so the sender can switch. Hook IDs stay the same across versions for the same trigger ID.

## Pack triggers

A trigger of `type: action` subscribes the routine to a trigger a marketplace pack provides, such as an issue being opened or an invoice being paid:

```yaml
- type: action
  id: invoice_paid
  uses: io.b3os/stripe/event@1
  with:
    types: [invoice.paid]
  connections:
    secret_key: conn_01m5t6cbgdjs1ketgcqwa2sne8
  if: trigger.event_type == 'invoice.paid'
```

- `uses` names the pack trigger; a major version such as `@1` resolves to the newest matching release when you publish and is pinned there.
- `with` configures the trigger and is evaluated once, at publish, with `vars` only. `connections` binds the trigger's connection keys.
- Publishing creates or updates one trigger subscription (`tsub_`) per action trigger; a new version without the trigger, a rollback to a version without it, or deleting the routine removes it. Subscriptions are read-only at [`GET /v1/actions/subscriptions`](/actions/triggers).
- Each event that passes `if` starts a run as the publishing key, with the event in `trigger.data`. Delivery is at least once, and a repeated event ID starts nothing.

`GET /v1/actions/triggers` lists the triggers packs provide, with their configuration and event schemas.

## When triggers stop

Pausing the routine (`PATCH` with `{"paused": true}`) stops every trigger until you resume it. Revoking or expiring the key that published the active version pauses the triggers too, until you publish a new version with a valid key; pack trigger subscriptions show `paused` meanwhile and drop events.