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
yamltriggers: - type: cron id: nightly cron: "0 30 6 * * MON-FRI" # 06:30:00 on weekdays tz: America/Los_Angeles catch_up: latest overlap: skip
cronhas 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, plusL,Wand#in the day fields.tzis 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_upis required. It decides what happens to occurrences missed while the node was down or the routine was paused, withincatch_up_window_s(a day by default):skipruns none,latestruns only the newest,allruns each in order.overlapdecides what happens when an occurrence fires while this trigger's previous run is still active:skip(the default) drops it,buffer_onekeeps one pending occurrence,allowstarts it anyway,cancel_runningcancels 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.
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:
texthttps://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 (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.
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:
bashBODY='{"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"
tsimport { 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());
pythonimport jsonimport osimport urllib.requestfrom b3os import sign_webhookbody = 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))
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.
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'
usesnames the pack trigger; a major version such as@1resolves to the newest matching release when you publish and is pinned there.withconfigures the trigger and is evaluated once, at publish, withvarsonly.connectionsbinds 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 atGET /v1/actions/subscriptions. - Each event that passes
ifstarts a run as the publishing key, with the event intrigger.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.