Strait Docs
Concepts

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

  1. Create a trigger: A workflow step or SDK call creates an event trigger with a globally unique event_key and optional timeout.
  2. Wait: The step/run transitions to waiting status. No goroutine is held — the wait is a database row.
  3. External event arrives: An external system sends the event via POST /v1/events/{eventKey}/send with the matching event_key.
  4. 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

TypeDescription
eventWaits for an external event to arrive via API. Default type.
sleepDurable sleep — waits until a specific time. Used internally by sleep workflow steps.

Event Sources

Event triggers track where the wait originated:

SourceDescription
workflow_stepCreated by a wait_for_event or sleep workflow step. Resumption triggers workflow progression.
job_runCreated 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 │
                                   └──────────┘
StatusDescription
waitingTrigger is active, waiting for an event or timeout.
receivedEvent was received. Step/run has been resumed.
timed_outTimeout expired before an event arrived. Step fails, workflow follows on_failure policy.
canceledTrigger 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 failed and the workflow follows its on_failure policy (fail_workflow, skip_dependents, or continue)
  • 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

FieldTypeRequiredDescription
step_refstringYesUnique reference name for the step.
typestringYesMust be "wait_for_event".
event_keystringYesGlobally unique key. Supports {{payload.*}} templates.
timeout_secsintNoTimeout in seconds (default: 3600).
depends_onstring[]NoSteps that must complete before this step.
on_failurestringNoFailure policy: fail_workflow, skip_dependents, continue.

Template Rendering

The event_key supports template variables rendered from the workflow trigger payload:

  • {{payload.user_id}} → value of user_id from 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

FieldTypeRequiredDescription
event_keystringYesGlobally unique key for this wait.
timeout_secsintNoTimeout in seconds (default: 3600).
notify_urlstringNoWebhook 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 30

Or 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_at are timed out
  • Stale steps: Triggers marked received but whose step/run is still in waiting status (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

MetricTypeDescription
event_triggers_created_totalCounterTotal triggers created (by source_type, project_id)
event_triggers_received_totalCounterTotal triggers resolved by events
event_triggers_timed_out_totalCounterTotal triggers that timed out

Data Model

FieldTypeDescription
idstringUUIDv7 primary key
event_keystringGlobally unique key (UNIQUE constraint)
project_idstringProject scope
source_typestringworkflow_step or job_run
trigger_typestringevent or sleep
workflow_run_idstringFK to workflow_runs (if source is workflow_step)
workflow_step_run_idstringFK to workflow_step_runs (if source is workflow_step)
job_run_idstringFK to job_runs (if source is job_run)
statusstringwaiting, received, timed_out, canceled
timeout_secsintTimeout duration in seconds
request_payloadJSONBPayload sent when creating the trigger
response_payloadJSONBPayload received with the event
requested_attimestampWhen the trigger was created
received_attimestampWhen the event was received
expires_attimestampWhen the trigger times out
errorstringError message for timed_out/canceled
notify_urlstringWebhook URL for notifications
notify_statusstringWebhook delivery status
event_emit_keystringAuto-emit key for event chaining
sent_bystringIdentity of the event sender
Was this page helpful?

On this page