Events
Understanding the normalized event shape and how providers are namespaced
When a webhook arrives from a provider, Restaq normalizes it into a standard shape before it reaches your handler.
Event shape
Every event has this structure:
type NormalizedEvent = {
id: string; // Unique identifier for this webhook delivery
type: string; // Namespaced event type (e.g., "stripe.charge.succeeded")
data: Record<string, unknown>; // Provider-specific payload
receivedAt: string; // ISO 8601 timestamp
};Provider namespacing
Events are namespaced by their provider to avoid conflicts:
- Stripe events:
stripe.charge.succeeded,stripe.charge.refunded,stripe.customer.created, etc. - GitHub events (no action):
github.push,github.ping - GitHub events (with action):
github.pull_request.opened,github.issues.closed,github.workflow_run.completed, etc.
The pattern for GitHub reflects reality: when GitHub sends a webhook for a pull request, it includes an action field in the payload that qualifies the event. Restaq's type system respects this—you won't see github.pull_request by itself, only action-qualified names like github.pull_request.opened.
Registering handlers
Use relay.on(type, handler) to listen for events:
relay.on('stripe.charge.succeeded', async (event, ctx) => {
const charge = event.data.object;
// handle the charge
});
relay.on('github.pull_request.opened', async (event, ctx) => {
const pr = event.data;
// handle the PR
});Multiple handlers can listen to the same event type—they run in the order they were registered.
Organizing handlers
relay.on(...) calls don't belong in relay.ts. That file's job is wiring — storage, plugins, retry policy — and if every event's business logic gets appended there too, it turns into an unmaintainable dumping ground as the app grows.
Keep relay.ts wiring-only, and register handlers from a separate module (or several, one per feature). The trick is preserving type inference: restaq's event map is inferred from the plugins array at the restaq(...) call site, so a handler-registration function needs the concrete relay type, not a generic Relay. Export it with typeof:
// relay.ts
export const restaq = createRestaq({ plugins: [stripe({ ... })] });
// typeof restaq carries the concrete, inferred event map forward.
export type AppRelay = typeof restaq;
registerHandlers(restaq);// relay.handlers.ts
import type { AppRelay } from './relay';
export function registerHandlers(relay: AppRelay): void {
relay.on('stripe.charge.succeeded', async (event, ctx) => {
event.data.object.amount; // still typed - AppRelay preserved the catalog
});
}relay.handlers.ts only imports a type from relay.ts (import type { AppRelay }), which TypeScript erases entirely at compile time — so even though relay.ts imports registerHandlers as a real value, there's no runtime circular dependency between the two files. relay init scaffolds exactly this split — see relay init.
For a larger app, split further: one file per feature (relay/handlers/payments.ts, relay/handlers/repos.ts, ...), each exporting its own register*(relay: AppRelay) function, called in sequence from a single registerHandlers.
Event deduplication
Event id is the provider's own unique delivery ID (Stripe's event ID, GitHub's delivery ID). If the same webhook is redelivered (e.g., because of a timeout and retry), Restaq detects the duplicate by ID and returns the original execution instead of running the handler twice.
See Idempotency for the full dedup story.