Restaq
Concepts

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.

On this page