Events
Learn how the 56 domain events publish through Inngest, how handlers stay idempotent, what the crons do, and why delivery is best-effort rather than guaranteed.
Litestore reacts to things happening — an order paid, a cart abandoned, a return requested — through one substrate.
domainEvents.emit publishes to Inngest, one Inngest function per event type runs every consumer, and a repository check fails the build if an event type has no handler.
One substrate is the deliberate part. This page covers where to add a reaction of your own, and which of the four available shapes it should take.
Domain events
events/types.ts defines 56 event names and one payload type each. They cover:
- Orders —
order.created,order.paid,order.refunded,order.disputed - Shipments —
shipment.shipped,shipment.delivered,shipment.pickup_ready,shipment.pickup_uncollected - Inventory, carts, customers, products and collections
- Reviews —
review.submitted,review.approved - Returns, wishlists, campaigns and collabs
payment.capture_recordedand the social-posting events
The full list is the DOMAIN_EVENT_TYPES tuple at the bottom of that file, declared as const satisfies readonly DomainEventType[] so the array and the union cannot drift.
One producer
events/emitter.ts is the only producer entry point.
It sends the event to Inngest as domain/{type} and stamps a _meta envelope carrying:
eventType
emittedAt
eventIdThe eventId is per delivery, and it is what the idempotency machinery keys against later.
There is deliberately no in-process listener registry. A second delivery substrate with different redelivery semantics is what produced the double-counted activity bug class.
One handler per event
Handlers bind to events by name in functions/domain-events.ts, and each owns its side effects end to end — the order-confirmation email, the activity row and the operator alert for a new order all live in one handler:
export const onOrderCreated = createDomainEventHandler("order.created", async payload => {
// one owner for the confirmation email, the activity row, the operator alert
})createDomainEventHandler wraps every handler in the same sequence:
invalidate-cachesdispatch-webhooksprocess-event- Any workflows chained to that event
Cache invalidation and webhook fan-out are therefore not something a handler author remembers to do. They happen because the wrapper does them, on every event.
The completeness check
scripts/check-event-completeness.ts runs as part of bun run verify and fails the build in two cases:
- A member of
DOMAIN_EVENT_TYPESwith nocreateDomainEventHandlercall - A handler const missing from the
domainEventFunctionsarray
In either case Inngest would never register the function, and the webhook the developer API advertises would silently deliver nothing.
Durable and scheduled work
Two different mechanisms live in functions/, both served from /api/inngest.
Polling crons
The functions/cron.* modules poll on a schedule:
cron.abandoned-carts.tsandcron.payment-reconcile.ts— every 30 minutescron.webhook-retries.ts— every 10 minutescron.stock-cleanup.tsandcron.publish-social-posts.ts— every 15 minutescron.shipment-watch.tsandcron.alert-digest.ts— daily at 8amcron.data-cleanup.ts— weekly
Abandoned-cart detection is a poll, not a timer. It scans for carts idle past the operator's abandoned_cart_delay_hours and emits cart.abandoned.
Nothing schedules that check when a cart goes quiet, because nothing emits an event when a cart goes quiet.
Durable waits
Durable waits are the other mechanism, and they are step.sleep inside a workflow.
WORKFLOWS in lib/rules/workflows.ts chains an event to an async function that receives the payload and a durable step surface, so a delay is plain code that survives a deploy.
Three ship:
- The abandoned-cart recovery drip on
cart.abandoned - A review request and reminder on
shipment.delivered - An onboarding nudge on
customer.created
Every delay reads from a setting — nothing is hardcoded.
Retries can run the same handler twice
Each emit carries one eventId. Handlers claim their non-idempotent effects
against evt:{eventId}:{slot} through claimIdempotencyKey, so an Inngest
retry that re-runs the step cannot double-apply an activity row or a counter.
Email sends pass an idempotency key through to Resend for the same reason.
Delivery is best-effort
emit registers the send with Next's after, so Vercel keeps the runtime alive without blocking the response, and awaits it directly outside a request scope.
That is not the same as a guarantee:
handler emits
↓
send registered with `after`
↓
crash mid-send, or waitUntil budget overrun
↓
event dropped, nothing retries itAt-least-once delivery would need a transactional outbox table, which is not implemented.
In development with no INNGEST_EVENT_KEY, a failed send falls back to running the same handler bodies inline, so events do not silently go nowhere. That fallback never applies in production, where a timed-out send may still have been accepted.
Task kinds
Operator tasks are the third kind of reaction — computed, not stored.
A task kind pairs a detection query with the exact filter its link opens, and two properties follow from that:
- Consistent counts — the count on the dashboard and the list behind it compose the same predicate, so they can never disagree.
- Self-clearing — tasks resolve themselves when the underlying condition resolves. There is nothing to mark done.
41 kinds ship, defined as the AdminTaskData union in server/admin/tasks/queries.ts. Adding one means three things:
- The detection query
- The intent filter for its destination list
- Its entry in the
Record<AdminTaskKind, …>maps inlib/tasks/definitions.ts
The compiler reports each one you have not completed.
Several event handlers feed tasks indirectly. A webhook endpoint auto-disabled after failures surfaces as webhooksDisabled; a failed import as importFailed.
Outbound webhooks
External subscribers register webhooks in Settings → Developer.
Delivery runs in services/webhooks/delivery.ts, from the dispatch-webhooks step of every domain-event handler. Payloads are:
- HMAC-signed (
v1) - Retried up to five times with exponential backoff from 1 second to 5 minutes
- Logged with status and attempts
cron.webhook-retries.ts picks up the stragglers every ten minutes.
An endpoint whose last 15 completed deliveries all failed is auto-disabled — a single success anywhere in that window keeps it alive — and that raises an operator task.
Where to add a reaction
For a side effect when something happens, write a handler on the domain event in functions/domain-events.ts. Cache invalidation and webhook dispatch come with the wrapper.
For a delayed or multi-touch follow-up, add a WORKFLOWS entry in lib/rules/workflows.ts and use step.sleep. The delay is durable; a deploy does not lose it.
For work on a schedule, add an Inngest cron in functions/. Use this when there is no event to react to — when the thing you care about is an absence, not an occurrence.
For a to-do the operator should see, add a task kind: a detection query plus the intent filter its link opens.
To notify an external system, register a webhook subscription. That needs no code at all.
The choice is not really about how long the work takes. It is:
What starts it — an event arriving, the clock, or an operator opening a screen?
Next Steps
SEO
Learn how the rule-based SEO audit scores a page, which models keep slug-redirect history, and what the AIO report checks for machine readers.
Inngest
Learn how Litestore builds its Inngest client, what the /api/inngest route registers, why no Inngest environment variable is declared in env.ts, and what the dev inline fallback does when a send fails.