Pause execution until an external event arrives, with timeout support and zero held goroutines.
Event triggers allow runs and workflow steps to pause execution and wait for an external event to arrive via API. They enable durable human-in-the-loop processes, inter-service coordination, and reactive workflows that can wait days, weeks, or months without holding goroutines or consuming resources.
How It Works
- Create a trigger: A workflow step or SDK call creates an event trigger with a globally unique
event_keyand optional timeout. - Wait: The step/run transitions to
waitingstatus. No goroutine is held — the wait is a database row. - External event arrives: An external system sends the event via
POST /v1/events/{eventKey}/sendwith the matchingevent_key. - Resume: The step/run resumes with the event payload as input data. Workflow progression continues automatically.
[step-a] → [wait_for_event: "aml-check:user-123"] ⏸ → [step-c] → [step-d]
↑
External system sends event
POST /v1/events/aml-check:user-123/send
{"payload": {...}}Trigger Types
| Type | Description |
|---|---|
event | Waits for an external event to arrive via API. Default type. |
sleep | Durable sleep — waits until a specific time. Used internally by sleep workflow steps. |
Event Sources
Event triggers track where the wait originated:
| Source | Description |
|---|---|
workflow_step | Created by a wait_for_event or sleep workflow step. Resumption triggers workflow progression. |
job_run | Created by an SDK POST /sdk/v1/runs/{runID}/wait-for-event call. Resumption re-queues the run. |
Event Key
The event_key is a globally unique string controlled by the caller. It has a UNIQUE constraint in the database — only one waiting trigger can exist per key at a time.
Callers are responsible for namespacing event keys to avoid collisions. Use a pattern like {type}:{entity-id} (e.g., aml-check:user-123, payment:order-456).
Key Constraints
- Maximum length: 512 characters
- Must not be empty
- Must not contain control characters (bytes below
0x20, including null bytes and newlines) - Must be unique across all waiting triggers
- Template rendering supported (e.g.,
aml-check:{{payload.user_id}})
Validation is enforced at all API entry points — handleSendEvent, handleGetEventTrigger, handleCancelEventTrigger, handleEventTriggerStream, handleSendEventByPrefix, and handleSDKWaitForEvent — as well as during template rendering in the workflow engine.
Trigger Lifecycle
┌─────────┐ event arrives ┌──────────┐
│ waiting │ ─────────────────────→ │ received │
└────┬────┘ └──────────┘
│
├── timeout expires ────────→ ┌───────────┐
│ │ timed_out │
│ └───────────┘
│
└── cancel API ─────────────→ ┌──────────┐
│ canceled │
└──────────┘| Status | Description |
|---|---|
waiting | Trigger is active, waiting for an event or timeout. |
received | Event was received. Step/run has been resumed. |
timed_out | Timeout expired before an event arrived. Step fails, workflow follows on_failure policy. |
canceled | Trigger was canceled via API. Step fails, workflow follows on_failure policy. |
Timeout Behavior
Every event trigger has a timeout_secs value (default: 3600 seconds / 1 hour). When the timeout expires:
- The trigger status transitions to
timed_out - For workflow steps: the step transitions to
failedand the workflow follows itson_failurepolicy (fail_workflow,skip_dependents, orcontinue) - For job runs: the run transitions to
timed_out
The reaper process polls for expired triggers every 30 seconds.
Set timeout_secs to a large value for long-running waits (e.g., 604800 for 7 days). The wait consumes no resources — it's just a database row.
Workflow Step: wait_for_event
Add a wait_for_event step to your workflow to pause until an external event arrives:
strait workflows create \
--name "KYC Pipeline" \
--slug kyc-pipeline \
--project proj_1 \
--steps-json '[
{"job_id": "job_extract", "step_ref": "extract"},
{
"step_ref": "aml-check",
"type": "wait_for_event",
"event_key": "aml-check:{{payload.user_id}}",
"timeout_secs": 86400,
"depends_on": ["extract"]
},
{"job_id": "job_onboard", "step_ref": "onboard", "depends_on": ["aml-check"]}
]'Step Fields
| Field | Type | Required | Description |
|---|---|---|---|
step_ref | string | Yes | Unique reference name for the step. |
type | string | Yes | Must be "wait_for_event". |
event_key | string | Yes | Globally unique key. Supports {{payload.*}} templates. |
timeout_secs | int | No | Timeout in seconds (default: 3600). |
depends_on | string[] | No | Steps that must complete before this step. |
on_failure | string | No | Failure policy: fail_workflow, skip_dependents, continue. |
Template Rendering
The event_key supports template variables rendered from the workflow trigger payload:
{{payload.user_id}}→ value ofuser_idfrom the trigger payload{{payload.order.id}}→ nested field access with dot notation
Workflow Step: sleep
Durable sleep steps pause a workflow for a specified duration without holding goroutines:
{
"step_ref": "cooldown",
"type": "sleep",
"sleep_duration": "30m",
"depends_on": ["notify"]
}Sleep steps create an event trigger with trigger_type: sleep and an expiry time. When the reaper detects the expiry, it marks the trigger as received and completes the step — triggering workflow progression.
Supported Durations
Go duration strings: 30s, 5m, 1h, 24h, 168h (7 days), etc.
Event Chaining
Steps can auto-emit events on completion using event_emit_key. When the step completes, it automatically resolves a matching waiting trigger:
[
{
"step_ref": "process",
"job_id": "job_process",
"event_emit_key": "process-done:{{payload.batch_id}}"
},
{
"step_ref": "wait-for-process",
"type": "wait_for_event",
"event_key": "process-done:{{payload.batch_id}}"
}
]This enables cross-workflow coordination — one workflow's step completion can resume another workflow's waiting step.
SDK: Wait for Event
Job runs can also wait for events using the SDK endpoint:
curl -X POST https://strait.dev/sdk/v1/runs/{runID}/wait-for-event \
-H "Authorization: Bearer $RUN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event_key": "approval:order-789",
"timeout_secs": 7200,
"notify_url": "https://example.com/webhook"
}'When the event arrives, the run is re-queued with the event payload as checkpoint data and re-dispatched to your endpoint.
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
event_key | string | Yes | Globally unique key for this wait. |
timeout_secs | int | No | Timeout in seconds (default: 3600). |
notify_url | string | No | Webhook URL to notify when the event arrives. |
Sending Events
Transaction Safety
When sending an event that resolves a workflow step trigger, the API wraps both the trigger status update and the step completion in a single database transaction. If either operation fails, both roll back atomically — preventing inconsistency where a trigger is marked received but the step remains in waiting status.
Fan-in progression (starting downstream steps) runs outside the transaction since it involves queues and multiple tables. The reconciliation reaper acts as a safety net for any edge cases.
For job run sources, the existing ReceiveEventAndRequeueRun atomic path handles trigger update and run re-queue in a single operation.
By Exact Key
curl -X POST https://strait.dev/v1/events/{eventKey}/send \
-H "Authorization: Bearer strait_..." \
-H "Content-Type: application/json" \
-d '{
"payload": {"result": "approved", "risk_score": 0.12}
}'By Prefix (Batch)
Resolve all waiting triggers whose event key starts with a prefix:
curl -X POST https://strait.dev/v1/events/prefix/batch-job:run-42:/send \
-H "Authorization: Bearer strait_..." \
-H "Content-Type: application/json" \
-d '{
"payload": {"status": "done"}
}'Idempotent Resend
If an event has already been received for a key with the same payload, the API returns 200 OK with the existing trigger. If the payload differs, it returns 409 Conflict.
Canceling Triggers
Cancel a waiting trigger and drive progression (step fails, run cancels):
curl -X DELETE https://strait.dev/v1/events/aml-check:user-123 \
-H "Authorization: Bearer strait_..."Webhook Notifications
When a trigger is created with a notify_url, the system sends a webhook when the event arrives. Delivery is persistent — it survives process restarts using a database-backed delivery queue.
Retry Strategy
- Max attempts: 5
- Backoff: Exponential — 5s, 25s, 125s, 625s (capped at 30 minutes)
- 4xx errors: Dead-lettered immediately (not retryable)
- 5xx / network errors: Retried up to max attempts
Webhook DLQ Management
Failed webhook deliveries can be inspected and retried via the API:
# List failed deliveries
curl "https://strait.dev/v1/webhook-deliveries?project_id=proj_1&status=failed" \
-H "Authorization: Bearer strait_..."
# Retry a failed delivery
curl -X POST https://strait.dev/v1/webhook-deliveries/{deliveryID}/retry \
-H "Authorization: Bearer strait_..."The retry endpoint resets the delivery to pending status with zero attempts and immediate next retry time. Only deliveries in failed status can be retried.
Real-Time Streaming
Stream trigger status changes via Server-Sent Events (SSE):
curl -N https://strait.dev/v1/events/aml-check:user-123/stream \
-H "Authorization: Bearer strait_..."The stream receives:
- Initial state on connect
- Status updates on event receipt, timeout, or cancellation
- Keepalive pings every 15 seconds
- Automatic close on terminal state
The SSE stream uses a direct Redis pubsub channel (event_trigger:{id}) for sub-millisecond delivery. CDC also publishes to a project-level channel as a reliable catch-all.
Browser Authentication
The browser EventSource API cannot set custom headers. For browser-based SSE clients, pass the auth token as a query parameter:
const source = new EventSource(
"https://strait.dev/v1/events/aml-check:user-123/stream?token=strait_your_api_key"
);
source.addEventListener("status", (event) => {
const trigger = JSON.parse(event.data);
console.log("Trigger status:", trigger.status);
});The ?token= parameter is converted to an Authorization: Bearer header by the sseTokenAuth middleware before the standard auth middleware runs. Header-based auth continues to work unchanged — the query param is only used when no Authorization or X-Internal-Secret header is present.
Monitoring
Stats Endpoint
curl https://strait.dev/v1/events/stats \
-H "Authorization: Bearer strait_..."Returns counts per status:
{
"waiting": 12,
"received": 458,
"timed_out": 3,
"canceled": 1
}List Triggers
curl "https://strait.dev/v1/events?status=waiting&workflow_run_id=wfr_123" \
-H "Authorization: Bearer strait_..."Supports filtering by status, workflow_run_id, and source_type.
Per-Project Quotas
When a project has a MaxActiveEventTriggers quota configured, the SDK wait-for-event endpoint enforces the limit:
# Response when quota is exceeded:
HTTP/1.1 429 Too Many Requests
{"error": "project has reached maximum active event triggers (100)"}The quota only counts triggers in waiting status. Resolved, timed-out, and canceled triggers do not count toward the limit.
Data Retention & Purge
Terminal event triggers (received, timed_out, canceled) are automatically cleaned up by the reaper based on the EVENT_TRIGGER_RETENTION setting (default: 30 days).
For manual control, use the purge API or CLI:
# Preview what would be deleted (dry run)
strait triggers purge --older-than 30 --dry-run
# Delete triggers older than 30 days
strait triggers purge --older-than 30Or via the API:
curl -X POST https://strait.dev/v1/events/purge \
-H "Authorization: Bearer strait_..." \
-H "Content-Type: application/json" \
-d '{"older_than_days": 30, "dry_run": false}'SDK Client Libraries
Official SDK packages now live under packages/ in this repository:
- TypeScript:
packages/typescript-sdk(@strait/ts) - Python scaffold:
packages/python-sdk
TypeScript
import { createClient } from "@strait/ts";
const runClient = createClient({
baseUrl: "https://strait.dev",
auth: { type: "runToken", token: process.env.RUN_TOKEN! },
});
const eventClient = createClient({
baseUrl: "https://strait.dev",
auth: { type: "bearer", token: process.env.STRAIT_API_KEY! },
});
// Pause a run and wait for an external event
const trigger = await runClient.operationsPromise.postSdkV1RunsByRunIDWaitForEvent({
pathParams: { runID: "run-123" },
body: { event_key: "approval:order-789", timeout_secs: 7200 },
});
// Send an event to resolve the trigger
const resolved = await eventClient.operationsPromise.postV1EventsByEventKeySend({
pathParams: { eventKey: "approval:order-789" },
body: { payload: { approved: true, reviewer: "alice" } },
});Python
# Python SDK is scaffold-only in this phase.
# See packages/python-sdk for current status and roadmap.TypeScript currently provides full API coverage. Python/Go packages are scaffold placeholders pending parity implementation.
Reconciliation
A background reconciliation reaper runs every 30 seconds to catch edge cases:
- Expired triggers: Triggers past their
expires_atare timed out - Stale steps: Triggers marked
receivedbut whose step/run is still inwaitingstatus (30-second grace period) are re-resolved - Expired approvals: Approval steps past their timeout are failed
Distributed Reaper
In multi-instance deployments, the reaper uses PostgreSQL advisory locks (pg_try_advisory_lock) to ensure exactly one instance runs the reaper cycle at a time. Other instances skip the cycle gracefully and try again on the next interval.
This requires no external coordination service — the database itself provides leader election. If the active instance crashes, another instance acquires the lock on the next tick.
Prometheus Metrics
| Metric | Type | Description |
|---|---|---|
event_triggers_created_total | Counter | Total triggers created (by source_type, project_id) |
event_triggers_received_total | Counter | Total triggers resolved by events |
event_triggers_timed_out_total | Counter | Total triggers that timed out |
Data Model
| Field | Type | Description |
|---|---|---|
id | string | UUIDv7 primary key |
event_key | string | Globally unique key (UNIQUE constraint) |
project_id | string | Project scope |
source_type | string | workflow_step or job_run |
trigger_type | string | event or sleep |
workflow_run_id | string | FK to workflow_runs (if source is workflow_step) |
workflow_step_run_id | string | FK to workflow_step_runs (if source is workflow_step) |
job_run_id | string | FK to job_runs (if source is job_run) |
status | string | waiting, received, timed_out, canceled |
timeout_secs | int | Timeout duration in seconds |
request_payload | JSONB | Payload sent when creating the trigger |
response_payload | JSONB | Payload received with the event |
requested_at | timestamp | When the trigger was created |
received_at | timestamp | When the event was received |
expires_at | timestamp | When the trigger times out |
error | string | Error message for timed_out/canceled |
notify_url | string | Webhook URL for notifications |
notify_status | string | Webhook delivery status |
event_emit_key | string | Auto-emit key for event chaining |
sent_by | string | Identity of the event sender |