Events
Litestore reacts to events in the store, such as a paid order or an abandoned cart, through one shared system where you add your own handlers.
When something happens in the store, such as an order being paid, a cart being abandoned or a return being requested, Litestore reacts to it through one shared system. Read this page when you need your own code to run in response to something in the store.
domainEvents.emit publishes the event to Inngest. Inngest runs one function per event type, and that function runs every reaction to the event.
Using one system for all of this is a deliberate choice. The sections below show where to add your own reaction and which of the available kinds it should be.
Domain events
platform/events/types.ts defines 53 event names, each with its own payload type.
| Group | Examples |
|---|---|
| Orders | order.created, order.paid, order.refunded, order.disputed |
| Shipments | shipment.shipped, shipment.delivered, shipment.pickup_ready, shipment.pickup_uncollected |
| Reviews | review.submitted, review.approved |
| Payments | payment.capture_recorded |
| Others | Inventory, carts, customers, products, collections, returns, wishlists, campaigns and collabs |
The full list is in the DOMAIN_EVENT_TYPES tuple at the bottom of that file. It is declared as const satisfies readonly DomainEventType[], so the array and the union type can never fall out of step.
Emitting events
platform/events/emitter.ts is the only place that sends events. It sends each event to Inngest as domain/{type} and adds a _meta envelope with these fields:
eventType
emittedAt
eventIdEach delivery gets its own eventId. Later, the code that prevents duplicate work uses this id as its key.
Litestore deliberately has no in-process listener registry. A second way of delivering events, which redelivered events differently, is what caused a whole class of bugs where activity was counted twice.
Event handlers
In jobs/domain-events.ts, each handler is tied to one event by name, and that handler does all the work for the event. For a new order, one handler sends the confirmation email, writes the activity row and alerts the operator:
export const onOrderCreated = createDomainEventHandler("order.created", async payload => {
// one owner for the confirmation email, the activity row, the operator alert
})createDomainEventHandler wraps every handler and runs the same steps in order.
invalidate-caches
The wrapper clears the caches the event affects.
dispatch-webhooks
It sends the event to subscribed webhook endpoints.
process-event
It runs your handler.
Chained workflows
It starts any workflows chained to that event.
Whoever writes a handler does not need to clear caches or send webhooks, because the wrapper does both for every event.
Handler registration
Every event in DOMAIN_EVENT_TYPES needs a createDomainEventHandler call, and the handler constant has to be in the domainEventFunctions array. If either is missing, Inngest never registers the function, and the webhook that the developer API offers for that event quietly delivers nothing. Nothing in verify checks this, so add both when you add an event.
Durable and scheduled work
jobs/ contains two other kinds of work, and Litestore serves both from /api/inngest.
Polling crons
The jobs/cron.* modules check for work on a schedule.
| Module | When it runs |
|---|---|
cron.abandoned-carts.ts, cron.payment-reconcile.ts | Every 30 minutes |
cron.webhook-retries.ts | Every 10 minutes |
cron.stock-cleanup.ts | Every 15 minutes |
cron.publish-scheduled-collections.ts | Every 5 minutes |
cron.shipment-watch.ts | Daily at 8am |
cron.index-data.ts | Daily at 9am |
cron.currency-rates.ts | Daily at 6pm |
cron.weekly-digest.tsx | Mondays at 9am |
cron.data-cleanup.ts | Sundays at 4am |
The daily and weekly schedules run in Europe/Warsaw time.
Litestore finds abandoned carts by checking on a schedule, not with a timer. The cron looks for carts that have been idle longer than ABANDONED_CART_DELAY_HOURS (24, in platform/config/email-automations.ts) and emits cart.abandoned for each one. A schedule is needed because nothing sends an event when a cart goes quiet, so there is nothing to start a timer from.
Durable waits
A durable wait is a step.sleep inside a workflow. WORKFLOWS in modules/email/automations.ts links an event to an async function. The function receives the event payload and durable steps it can call. A delay is ordinary code, and it continues correctly after a deploy.
| Workflow | What starts it |
|---|---|
| The abandoned-cart recovery emails | cart.abandoned |
| A review request and a reminder | shipment.delivered |
| An onboarding nudge | customer.created |
Every delay is a typed constant in platform/config/email-automations.ts. Workflows lists them.
A retry can run the same handler twice
Each emitted event has one eventId. Before a handler does something that must not happen twice, it claims the key evt:{eventId}:{slot} through claimIdempotencyKey. If Inngest retries the step, the activity row or counter is not applied a second time. Email sends pass an idempotency key to Resend for the same reason.
Best-effort delivery
Inside a request, emit registers the send with Next's after. Vercel then keeps the function running until the send finishes, without delaying the response. Outside a request, emit awaits the send directly. This still does not guarantee delivery:
handler emits
↓
send registered with `after`
↓
crash mid-send, or waitUntil budget overrun
↓
event dropped, nothing retries itTo guarantee at-least-once delivery, Litestore would need a transactional outbox table, and it does not have one.
Without an INNGEST_EVENT_KEY, Litestore runs the same handler code inline instead, so events are not quietly lost. In production without a key it goes straight to the inline path; in development it tries a key-less Inngest dev server first. Once a key is configured it never falls back, because a send that timed out may still have reached Inngest, and running it inline as well could deliver it twice.
Outbound webhooks
Outside systems subscribe to events when you register a webhook in Settings → Developer. modules/integrations/webhooks/delivery.ts sends the webhooks, and it runs from the dispatch-webhooks step of every domain-event handler.
Litestore signs each payload with HMAC (v1). It retries a failed payload up to five times, with backoff that grows from 1 second to 5 minutes, and logs it with its status and number of attempts. cron.webhook-retries.ts picks up any deliveries still waiting every ten minutes.
If the last 15 completed deliveries to an endpoint all failed, Litestore disables it and writes a webhook_disabled activity entry. A single success within those 15 keeps it active.
Adding reactions
The right choice does not depend on how long the work takes. Ask yourself this instead: what starts the work: an event arriving, or the clock?