Events API

Trigger workflows by sending an event, and read the durable event log.

An event is how you start a workflow from outside Duraton. POST /events ingests one event; Duraton matches it against every workflow trigger in the project and returns what it started. Every ingested event is also kept in a durable event log you can read and tail.

The live event log in the console

Send an event

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });
const res = await duraton.events.send({
  name: "ticket.created",
  app: "support-app",
  data: { ticketId: "T-421" },
});

Request body

FieldTypeMeaning
namestring, requiredThe event name workflows trigger on. Non-blank, up to 256 characters.
appstringScope the event to one app's triggers. Omit to broadcast project-wide.
dataany JSONThe event payload, delivered as ctx.event.data. Any valid JSON - object, array, or scalar.
dedupeIdstringDrop a repeat of the same id (per app) within 24 hours, before any fan-out.
runnerstringPin runs started by this event to a specific runner.
targetAppstringRoute a cross-app trigger to a specific app.
sessionstringThread the runs this event starts into one conversation, read back by GET /sessions. Omitted, each run is its own session.
tagsobject of string->stringCustomer-defined key/value metadata attached to every run this event triggers, for filtering runs by your own dimensions (see Run tags).

app, runner, targetApp, dedupeId, and session are each bounded at 256 characters; a longer value, a blank name, or unparseable data returns 400. tags is bounded too: at most 20 tags per run, each key up to 64 characters matching [A-Za-z0-9_.-] and each value up to 256 characters - an over-limit tag returns 400.

tags on an event is sendable from the TypeScript, Python, and Go SDKs - each SendEventInput carries a tags field - as well as over REST. The tags attach to every run the event triggers; filter those runs back with tag.<key> (see Run tags).

Unsupported characters

An event must not carry a NUL character (\u0000). Duraton's durable event log has no representation for it, so an event carrying one could never be stored. Duraton rejects it at ingest with a 400 - before any workflow is matched or any run is started - rather than accepting the event and failing later.

The check covers every text field (name, app, runner, dedupeId, targetApp, session) and the whole data payload, at any depth: a NUL inside a nested string, an array element, or even a JSON object key is rejected.

curl -X POST $DURATON_URL/events \
  -d '{"name":"ticket.created","app":"support-app","data":{"note":"bad\u0000byte"}}'
# 400

Only the escaped form \u0000 reaches this check - a raw NUL byte in the request body is already invalid JSON and is rejected as a malformed body. Either way the event is refused with a 400, and nothing is recorded.

Response

202 Accepted. The body reports what the event did:

{
  "runId": "01HXYZ...",
  "woke": 0,
  "triggered": [
    { "workflow": "fulfillment", "runId": "01H..." },
    { "workflow": "audit", "runId": "01H..." }
  ]
}
FieldMeaning
runIdThe run started, when exactly one workflow matched.
wokeHow many waiting runs this event resumed (via step.waitForEvent).
triggeredOne entry per matched workflow: its workflow name, the app the run landed in (set even for cross-app fan-out), and the runId started.
skipped / dropped / debounced / batched / dedupedFlow-control outcomes - set when the event was held back rather than run immediately (see flow control).
suspendedtrue when the project is suspended: in-flight waiters were still woken, but no new run was started.

An event that matches nothing still returns 202 with an empty triggered - it is recorded, not lost.

Each triggered entry's runId resolves to a run, and that run records the event back: its eventId is this event's id (see runs). List every run one event fanned out with GET /runs?eventId=<id>. Cron, child (step.runWorkflow), and on-failure runs carry no eventId - they have no triggering event.

Two kinds of deduplication

Two independent mechanisms can each report deduped: true, and they mean different things. Knowing which one fired matters, because one throws the event away and the other only suppresses a single run.

Event dedupeIdWorkflow idempotency
Set whereOn the event (dedupeId on POST /events)On the workflow definition (idempotency: { key, periodMs })
Keyed byThe literal dedupeId string you send, scoped per appA field path into the event data (e.g. key: "ticketId"), scoped per workflow
WindowFixed 24 hoursThe configured periodMs
What is droppedThe whole event, before any fan-outOne run of that one workflow
Waiters woken?No - a duplicate is dropped before waitForEvent waiters are checkedYes - waiters are woken before the per-workflow gate runs
Recorded in the event log?No - a duplicate is never persistedYes - the event is recorded, only the run is suppressed
Where deduped appearsAt the top level ({ "deduped": true }, no runId, no triggered)On that workflow's triggered[] entry (mirrored to the top level when it is the first match)

In short: dedupeId is event-level and total - the exact same event is collapsed to a no-op, nothing is recorded, and nothing is woken. It is the safety net for an at-least-once caller retrying POST /events. idempotency is run-level and partial - the event still lands, still wakes waiters, and still runs every other matching workflow; only a second run of the idempotent workflow for the same derived key is suppressed within the window.

Because both surface deduped: true, tell them apart by the rest of the response: an event-level drop returns no triggered array and no runId, whereas an idempotency drop still carries the event's triggered fan-out with deduped set on the affected entry.

Reading the log

The log records every event with what it did, newest first.

curl "$DURATON_URL/events?app=support-app&name=ticket.created&limit=20"
Method + pathReturns
GET /eventsA page of event records. Filter with ?app= and ?name=; ?limit= sets the page size (100 default, 1000 max).
GET /events/{id}One event record.
GET /events/streamA live tail of incoming events (Server-Sent Events).

Each record carries the event (name, app, data), its source (api for an external POST, emit for a workflow's step.emit), when it arrived, and the triggered fan-out:

{
  "id": "9f2b...",
  "name": "ticket.created",
  "app": "support-app",
  "source": "api",
  "data": { "ticketId": "T-421" },
  "receivedAt": "2026-06-15T09:00:00Z",
  "woke": 0,
  "triggered": [{ "workflow": "fulfillment", "runId": "01H..." }]
}

The stream is a best-effort live view - a slow or reconnecting client can miss events. GET /events is the complete record.

On this page