Scheduled Events
Schedule events to fire in the future. Reminders, expirations, delayed notifications -- anything that shouldn't happen immediately.
How it works
When an event is written, your spec can schedule a future event:
User adds item to cart -> Schedule "send reminder" in 24 hours
(unless cart is checked out first)
If the cart is checked out within 24 hours, the reminder is automatically canceled. If not, the reminder event fires.
Spec syntax
Add a schedule block to any event's implications:
{
"aggregate_types": {
"cart": {
"events": {
"had_item_added": {
"schema": {"type": "object", "properties": {"product_id": {"type": "string"}}},
"handler": [{"append": {"target": "items", "value": "$.data"}}],
"implications": [
{
"schedule": {
"delay": "24h",
"emit": {
"aggregate_type": "notification",
"id": "$.metadata.actor.id",
"event_type": "cart_abandonment_reminder",
"data": {
"cart_id": "$.key"
}
},
"cancel_on": [
{
"aggregate_type": "cart",
"id": "$.key",
"event_type": "was_checked_out"
}
]
}
}
]
}
}
}
}
}
Fields
| Field | Required | Description |
|---|---|---|
delay |
Yes | How long to wait before firing. See delay formats. |
emit |
Yes | The event to emit when the delay expires. |
cancel_on |
No | Events that cancel this scheduled event before it fires. |
Delay formats
| Format | Duration | Example |
|---|---|---|
"Ns" |
N seconds | "30s" = 30 seconds |
"Nm" |
N minutes | "30m" = 30 minutes |
"Nh" |
N hours | "24h" = 24 hours |
"Nd" |
N days | "7d" = 7 days |
You can also use delay_ms as an integer (milliseconds) instead of the string format.
Minimum delay: 5 minutes. Scheduled events are designed for coarse-grained business logic, not real-time operations. Delays under 5 minutes are rejected at spec validation time.
The emit block
The emit block defines what event fires when the delay expires:
{
"emit": {
"aggregate_type": "notification",
"id": "$.metadata.actor.id",
"event_type": "reminder_sent",
"data": {
"original_cart": "$.key",
"user_name": "$.data.user_name"
}
}
}
aggregate_type- Target aggregate type (string literal)id- Target aggregate ID (JSONPath or string literal)event_type- Event type to emit (string literal)data- Event payload (values can be JSONPath expressions)
JSONPath expressions are resolved at scheduling time against the triggering event.
Cancel conditions
Cancel conditions specify which future events should cancel this scheduled event:
{
"cancel_on": [
{
"aggregate_type": "order",
"id": "$.key",
"event_type": "was_completed"
},
{
"aggregate_type": "order",
"id": "$.key",
"event_type": "was_cancelled"
}
]
}
When any matching event is written, the scheduled event is canceled. All three fields must match for cancellation to occur.
Race condition warning
There is an inherent race between firing and cancellation. If a cancel event arrives at nearly the same moment the scheduled event is due to fire, the cancellation may not prevent the scheduled event from firing.
For most use cases (reminders, follow-ups, soft deadlines), this is fine -- a redundant reminder is harmless. But if your domain requires strict either-or semantics (payment processed OR refund issued, never both), scheduled events alone are not sufficient. Use full saga patterns for those cases.
Fired event metadata
When a scheduled event fires, it includes metadata for audit trails:
{
"key": "notification:user123",
"type": "cart_abandonment_reminder",
"data": {"cart_id": "cart:abc"},
"metadata": {
"timestamp": 1705312800,
"actor": {
"type": "system_agent",
"id": "event_scheduler"
},
"implied_by": {
"key": "cart:abc",
"event_type": "had_item_added",
"depth": 1
},
"scheduled": {
"scheduled_id": "a1b2c3d4-e5f6-...",
"fire_at": "2024-01-15T12:00:00Z"
}
}
}
actor.typeis"system_agent"-- a reserved actor type for system operations that bypasses normalagent_typesvalidationactor.idis"event_scheduler"-- identifies this as a scheduled eventimplied_byreferences the original triggering eventscheduledcontains scheduling metadata
Use cases
Trial expiration
{
"was_activated": {
"handler": [{"set": {"target": "", "value": "$.data"}}],
"implications": [
{
"schedule": {
"delay": "14d",
"emit": {
"aggregate_type": "subscription",
"id": "$.data.user_id",
"event_type": "trial_should_expire",
"data": {"user_id": "$.data.user_id"}
},
"cancel_on": [
{
"aggregate_type": "subscription",
"id": "$.data.user_id",
"event_type": "was_converted"
}
]
}
}
]
}
}
User converts to paid? Expiration cancels automatically.
Subscription renewal
{
"was_activated": {
"handler": [{"set": {"target": "", "value": "$.data"}}],
"implications": [
{
"schedule": {
"delay": "30d",
"emit": {
"aggregate_type": "billing",
"id": "$.metadata.actor.id",
"event_type": "renewal_due",
"data": {
"subscription_id": "$.key",
"plan": "$.data.plan"
}
},
"cancel_on": [
{
"aggregate_type": "subscription",
"id": "$.key",
"event_type": "was_cancelled"
}
]
}
}
]
}
}
Recurring events
j17 doesn't natively support cron-like recurring events. Build recurrence by rescheduling -- each event schedules the next:
{
"weekly_report_sent": {
"handler": [{"set": {"target": "last_sent", "value": "$.metadata.timestamp"}}],
"implications": [
{
"schedule": {
"delay": "7d",
"emit": {
"aggregate_type": "reports",
"id": "global",
"event_type": "should_send_weekly",
"data": {}
}
}
}
]
}
}
Admin API
List pending events
GET /_admin/scheduled?status=pending&limit=50
Authorization: Bearer <operator-jwt>
Query params:
- status - Filter by status: pending, fired, canceled, dead_letter
- limit - Max results (default 100)
- offset - Pagination offset
Cancel a scheduled event
DELETE /_admin/scheduled/:scheduled_id
Authorization: Bearer <operator-jwt>
List dead letters
GET /_admin/scheduled/dead_letters
Authorization: Bearer <operator-jwt>
Events that failed after max retries (10 attempts).
Retry dead letter
POST /_admin/scheduled/:scheduled_id/retry
Authorization: Bearer <operator-jwt>
Reset a dead-lettered event to pending for another attempt.
Error handling
If a scheduled event fails to fire (e.g., spec not deployed, validation error), it is retried on the next poll cycle. After 10 failures, it moves to dead_letter status.
Dead-lettered events remain in the database for inspection, include last_error with the failure reason, and can be manually retried via the admin API.
| Error | Cause | Resolution |
|---|---|---|
no_spec_deployed |
No spec in target environment | Deploy a spec |
| Schema validation | Emitted data doesn't match schema | Fix emit data or schema |
Telemetry events
| Event | When |
|---|---|
[:j17, :scheduled, :created] |
Scheduled event stored |
[:j17, :scheduled, :fired] |
Event successfully fired |
[:j17, :scheduled, :canceled] |
Canceled by matching event |
[:j17, :scheduled, :dead_lettered] |
Max retries exceeded |
Precision
Scheduled events fire within about a minute of the target time. This is intentional -- scheduled events are for business logic like "send a reminder tomorrow" or "expire this offer in 7 days" where the exact second doesn't matter.
Environment behavior
Scheduled events fire in the same environment they were created in. An event scheduled in staging fires in staging, and an event scheduled in prod fires in prod. This allows testing scheduled event logic in staging without affecting production.
Limits
| Limit | Value |
|---|---|
| Min delay | 5 minutes |
| Max delay | 1 year |
| Max scheduled per aggregate | 100 |
| Max total per instance | 100,000 |
Best practices
Use cancellation liberally. Always cancel if the triggering condition changes. It's cheaper to cancel than to handle no-op events.
Short delays in implications. Use scheduled events for delays over 5 minutes. For sub-minute coordination, use implications or sagas.
Monitor pending count. Too many scheduled events indicates a leak -- something that should cancel isn't.
All times are UTC. Convert in your application before scheduling.
See also
- Implications guide - Trigger scheduled events from implications
- Sagas guide - Complex workflows where race conditions must be explicitly resolved