Skip to main content

Event-Driven Triggers

Define Business Flows and Run Business Flows cover conversation-driven flows: a customer message matches intent, and the Runtime starts a run.

Event-driven triggers are an additional entry path into the same Runtime. A connector, schedule, webhook, or your backend emits a namespaced event; the bus validates and queues it; the dispatcher matches enabled flows whose metadata.trigger matches; then FlowRunner runs the flow — with the same steps, tools, approvals, delays, and challenges as chat.

Outcome

  • Flows declared with trigger: { type: 'event' | 'schedule' | 'webhook', ... }
  • Connectors (or Admin API) emit namespaced events into the bus
  • Matching flows start as Channel::Event runs visible in Flow Runs
  • Production safeguards: versioning, lineage, ordering, expiration, replay, loop depth, when predicates

Prerequisites

  • SDK Connection with tools + flows: Register SDK Business Tools
  • @qefro-ai/backend1.3.0 (or equivalent Python / Rust SDK with trigger support)
  • Owner/Admin JWT for emit / list / replay APIs
  • Optional: Qefro connector-kit EventEmitter for connector services

Architecture

LayerResponsibility
Producer (connector, cron, HTTP)Build envelope, call emit/ingest — no FlowRunner
Event busValidate name/namespace/depth/sequence, persist, idempotency, queue
DispatcherClaim due events, match flows, apply when, start runs
FlowRunnerSame engine as conversation flows (ask, tool, approval, delay, …)

Trigger types

Set metadata.trigger on app.flow(...). Default (omit trigger) is conversation — Phase 2 behaviour.

typeWhen it startsKey fields
conversationCustomer message matches intent(default)
eventBus event name equals eventevent (required, namespaced), when?
scheduleCron ticker emits schedule.<flow_id>cron (required)
webhookNamed webhook ingestname?, when?

TypeScript

app
.flow({
id: 'abandoned_cart_recovery',
name: 'Abandoned cart recovery',
version: 1,
trigger: {
type: 'event',
event: 'shopify.cart.abandoned',
// optional payload predicate
// when: 'cart.total >= 50',
},
inputs: ['cartId'],
})
.delay({ id: 'wait_1h', duration_seconds: 3600 })
.tool({ id: 'remind', tool_ref: 'send_cart_reminder' })
.complete({ id: 'done', message: 'Cart reminder sent.' });

Python

(
app.flow({
"id": "abandoned_cart_recovery",
"name": "Abandoned cart recovery",
"trigger": {
"type": "event",
"event": "shopify.cart.abandoned",
"when": "cart.total >= 50", # optional
},
})
.delay("wait_1h", duration_seconds=3600)
.tool("remind", tool_ref="send_cart_reminder")
.complete("done", message="Cart reminder sent.")
)

Rust

use qefro_backend_sdk::{BusinessFlowMetadata, FlowTrigger};

app.flow(BusinessFlowMetadata {
id: "abandoned_cart_recovery".into(),
name: Some("Abandoned cart recovery".into()),
trigger: Some(FlowTrigger::Event {
event: "shopify.cart.abandoned".into(),
when: None,
}),
..Default::default()
})?
.delay("wait_1h", 3600)
.tool("remind", "send_cart_reminder")
.complete("done", Some("Cart reminder sent.".into()))?;

Schedule trigger

app.flow({
id: 'nightly_reconciliation',
name: 'Nightly reconciliation',
trigger: { type: 'schedule', cron: '0 2 * * *' },
})
.tool({ id: 'run', tool_ref: 'reconcile_orders' })
.complete({ id: 'done' });

On Sync Tools, the Runtime upserts a tenant schedule. The ticker emits schedule.<flow_id> (already namespaced). Dispatch resolves the flow via the event name and payload.flow_id.

Optional when predicate

For event and webhook triggers, when is evaluated against the event payload (same expression style as flow condition steps):

path OP literal | path exists | path empty | bare path (truthy)
OP := == | != | >= | <= | > | <

Examples: total >= 1000, status == "paid", customer.email exists.

  • Missing / empty when → always matches.
  • Expression false → that flow is skipped (other matching flows may still run).
  • Unparseable expressions fail closed for that flow.

Event names and namespaces

Every event name must be fully qualified: namespace.rest (at least one .).

RuleDetail
Required dotshopify.order.created ✅ · order_created
SegmentsNon-empty; a-zA-Z0-9_- only
Max length256 characters
NamespaceFirst segment (shopify, system, schedule, …)

Well-known namespaces (conventions, not exclusive):

NamespaceTypical producers
shopify, woocommerce, odoo, erpnext, hubspot, stripeCommerce / CRM connectors
scheduleCron ticker (schedule.<flow_id>)
webhookGeneric HTTP webhook alias
system, conversationPlatform lifecycle (reserved style)

Wildcards on the bus: subscribers may match ns.* patterns; flows match on the exact trigger event name.

Envelope

FieldRequiredPurpose
nameyesNamespaced event name
sourcenoProducer label (default api / connector name)
payloadnoJSON object; seed variables for the flow
versionnoEnvelope schema version (default "1.0")
correlation_idnoGroups related events across a process
causation_idnoImmediate parent event id (lineage)
idempotency_keynoTenant-scoped unique; duplicate emit returns existing row
sequence_key + sequence_nnoFIFO ordering stream (both required together)
expires_at or ttl_secondsnoDrop if still pending after expiry
depthnoCausation hop count (default 0; children = parent + 1)
delay_secondsnoHold before dispatcher may claim

Seed variables when a flow starts

The Runtime opens a system conversation (Channel::Event) and seeds the run with:

  • event — envelope summary (id, name, namespace, source, lineage, payload, timestamp, …)
  • Top-level keys from payload merged in (so cartId in the payload is available as {{cartId}})

Approvals, delays, challenges, and tool invokes behave exactly like conversation-driven runs — monitor them in Flow Runs.

Emit events

Admin / org API

POST/api/v1/org/events

Emit and enqueue an orchestration event (Owner/Admin JWT).

POST/api/v1/org/events/ingest/:tenant_id

Connector-style ingest; JWT tenant must match path tenant.

curl -sS -X POST "https://api.qefro.com/api/v1/org/events" \
-H "Authorization: Bearer $QEFRO_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "shopify.cart.abandoned",
"source": "connector:shopify",
"correlation_id": "cart:abc123",
"idempotency_key": "shopify:cart:abc123:abandoned",
"payload": { "cartId": "abc123", "email": "[email protected]", "total": 89.5 },
"ttl_seconds": 86400
}'

Connector-kit EventEmitter

Connectors use the kit so they never touch FlowRunner:

import { EventEmitter } from '@qefro/connector-kit'; // package path may vary by monorepo layout

const emitter = new EventEmitter({
baseUrl: process.env.QEFRO_API_BASE_URL!,
tenantId: process.env.QEFRO_TENANT_ID!,
token: process.env.QEFRO_API_TOKEN,
source: 'connector:shopify',
});

await emitter.emit({
name: 'shopify.cart.abandoned',
payload: { cartId: 'abc123', email: '[email protected]' },
correlationId: 'cart:abc123',
idempotencyKey: 'shopify:cart:abc123:abandoned',
});

Path used: POST /api/v1/org/events/ingest/:tenant_id.

Lifecycle and status

StatusMeaning
received / validated / queuedIngest path before claim
runningDispatcher processing / flow starting
completedHandled (including “no matching flow” ack)
retryTransient failure; backoff until next_run_at
failed / dead_letterExhausted retries or permanent failure
expiredStill pending after expires_at

Production guarantees (hardening)

Ordering

  • With sequence_key + sequence_n: the claim path keeps FIFO per stream (later n waits until earlier events settle).
  • Without a sequence key: events are independent, at-least-once.

Lineage and loop protection

  • correlation_id ties a business process; causation_id points at the parent event.
  • Child emits should set depth = parent.depth + 1 and causation_id = parent.id.
  • Default max depth = 8. Deeper emits are rejected so event→flow→emit loops cannot runaway.

Expiration

  • Set expires_at or ttl_seconds for time-sensitive work (flash sales, OTP windows).
  • The recovery loop expires due pending rows before claiming new work.

Idempotency

  • Prefer stable idempotency_key values from the source system (order id + event type).
  • Duplicate keys return the existing record instead of double-starting flows.

Replay (operator)

Replay creates a new queued event linked via replay_of (it does not re-queue the same row).

MethodPathPurpose
POST/api/v1/org/events/:id/replayReplay one event
POST/api/v1/org/events/replayReplay root events in a time range (start, end, optional name, limit)
POST/api/v1/org/events/:id/retryRe-queue a failed / dead-letter event
GET/api/v1/org/eventsList (status, name, limit)
GET/api/v1/org/events/:idGet one
curl -sS -X POST "https://api.qefro.com/api/v1/org/events/$EVENT_ID/replay" \
-H "Authorization: Bearer $QEFRO_JWT"

Range body:

{
"start": "2026-08-01T00:00:00Z",
"end": "2026-08-01T23:59:59Z",
"name": "shopify.cart.abandoned",
"limit": 100
}

Worked example — abandoned cart

Runnable reference: event-abandoned-cart in the JS Backend SDK.

  1. Register send_cart_reminder tool + flow with trigger: { type: 'event', event: 'shopify.cart.abandoned' }.
  2. Sync Tools and Enable the flow (Valid + Accepted).
  3. Shopify connector (or curl) emits shopify.cart.abandoned with { cartId, email }.
  4. Dispatcher matches the flow → FlowRunner runs delaytoolcomplete.
  5. Watch the run in Flow Runs (same UI as chat-started flows).

Workflow checklist

Ship an event-triggered flow

  1. Register toolsapp.tool for every tool_ref the flow needs.
  2. Declare triggermetadata.trigger event | schedule | webhook with a namespaced event name.
  3. Sync + enableSync Tools; fix Invalid; Accept version; toggle Enabled.
  4. Emit with idempotencyConnector EventEmitter or POST /api/v1/org/events with stable idempotency_key.
  5. Verify runFlow Runs shows Channel Event execution; tools hit your signed webhook.
  6. OperateUse retry/replay, TTL, sequence keys, and when predicates in production.

FAQ

Is this a second workflow engine?
No. Events only choose when a Business Flow starts. Execution is always FlowRunner — the same engine as conversation-driven flows, with the same steps and tool.invoke callbacks.
Can connectors call FlowRunner or start executions directly?
No. Connectors must only emit events (connector-kit EventEmitter or the ingest API). Starting flows from a connector bypasses validation, retries, lineage, and admin visibility.
Why was my event accepted but no flow ran?
Common causes: no enabled flow with matching trigger.event; when predicate false; flow Invalid or not Accepted; event expired before claim; sequence_key blocked behind an earlier unsettled n.
How do conversation and event triggers coexist?
Per flow. A flow is either conversation-selected (intent) or event/schedule/webhook-selected. Use separate flow ids when you need both entry styles for related processes.
What SDK versions support trigger?
Backend SDKs at 1.3.0+ advertise metadata.trigger (event / schedule / webhook) and optional when. Older SDKs still work for conversation flows only.