Typed Events
Define and send fully typed events to running workflows
Typed Events
Ablauf's event system is fully typed end-to-end with Zod schemas. Events are defined as part of the workflow definition, and TypeScript knows exactly which events a workflow accepts and what shape their payloads have. No more guessing, no more runtime surprises.
Defining Events
Events are defined alongside your workflow using a simple event name → Zod schema mapping:
import { defineWorkflow } from '@der-ablauf/workflows';
const PaymentWorkflow = defineWorkflow((t) => ({
type: 'payment',
input: t.object({ amount: t.number(), currency: t.string() }),
events: {
'payment-confirmed': t.object({
transactionId: t.string(),
confirmedAt: t.number(),
}),
'payment-failed': t.object({
reason: t.string(),
}),
},
run: async (step, payload) => {
await step.do('initiate-payment', async () => {
// Start payment process...
});
// TypeScript knows this returns { transactionId, confirmedAt }
const confirmation = await step.waitForEvent('payment-confirmed', {
timeout: '30m',
});
return { transactionId: confirmation.transactionId };
},
}));Sending Events
When you send an event, TypeScript enforces the correct event name and payload shape:
// Fully typed — autocomplete on event names and payload properties
const payment = ablauf.get(PaymentWorkflow, { id: workflowInstanceId });
await payment.sendEvent({
event: 'payment-confirmed',
payload: {
transactionId: 'txn_123',
confirmedAt: Date.now(),
},
});Try sending a typo'd event name or missing a required field — TypeScript will stop you before you even save the file.
Event Buffering
You can send an event to a workflow instance before it reaches the corresponding waitForEvent() call, as long as the instance has been created. The event is buffered and automatically delivered when the workflow reaches the matching step.
// Create the workflow — it starts running (hasn't reached waitForEvent yet)
const handle = await ablauf.create(OrderWorkflow, {
id: 'order-123',
payload: { orderId: 'abc' },
});
// Send event immediately — it gets buffered
await handle.sendEvent({
event: 'payment-received',
payload: { amount: 99.99 },
});
// When the workflow reaches step.waitForEvent('payment-received'),
// it picks up the buffered event and continues without suspending.If multiple events of the same type are sent before consumption, only the most recent one is kept (last-write-wins).
Event buffering eliminates race conditions. You no longer need to wait for the workflow to reach waitForEvent() before sending events.
Validation
Payloads are validated at runtime against the Zod schema:
- Invalid payloads throw
EventValidationErrorwith detailed Zod issues - Unknown event names also throw
EventValidationError - All validation happens before the event reaches your workflow code
This means your workflow's step.waitForEvent() always receives valid, properly shaped data. No defensive parsing needed.
Common Patterns
Webhook Handler
The classic use case: a webhook fires, your workflow wakes up.
import { Hono } from 'hono';
const app = new Hono();
app.post('/webhooks/payment', async (c) => {
const body = await c.req.json();
const payment = ablauf.get(PaymentWorkflow, { id: body.workflowId });
await payment.sendEvent({
event: 'payment-confirmed',
payload: {
transactionId: body.transaction_id,
confirmedAt: Date.now(),
},
});
return c.json({ ok: true });
});Events are the glue between the outside world and your workflows. A webhook fires, a user clicks a button, a cron job runs — send an event, and your workflow picks up right where it left off.
User Actions
Let users resume workflows with a button click:
app.post('/workflows/:id/approve', async (c) => {
const id = c.req.param('id');
const workflow = ablauf.get(ApprovalWorkflow, { id });
await workflow.sendEvent({
event: 'approved',
payload: {
approvedBy: c.get('userId'),
approvedAt: Date.now(),
},
});
return c.json({ ok: true });
});Multiple Event Types
Your workflow can wait for different events at different steps:
const OrderWorkflow = defineWorkflow((t) => ({
type: 'order',
input: t.object({ orderId: t.string() }),
events: {
'payment-received': t.object({ amount: t.number() }),
'item-shipped': t.object({ trackingNumber: t.string() }),
'item-delivered': t.object({ deliveredAt: t.number() }),
},
run: async (step, payload) => {
const payment = await step.waitForEvent('payment-received', { timeout: '1h' });
const shipment = await step.waitForEvent('item-shipped', { timeout: '3d' });
const delivery = await step.waitForEvent('item-delivered', { timeout: '7d' });
return { trackingNumber: shipment.trackingNumber };
},
}));Event Timeouts
step.waitForEvent() accepts an optional timeout. If the event doesn't arrive in time, the workflow is marked as errored with EventTimeoutError. Timeouts use duration strings like "30s", "5m", "1h", or "7d".
For the full details on timeout behavior and internals, see step.waitForEvent().