Webhooks
@lastshotlabs/slingshot-webhooks owns endpoint management, scoped delivery, and inbound
provider intake. Event owners declare what can leave the process via defineEvent(...);
this package projects those events to subscriber endpoints with retries, dead-letter
handling, and scope enforcement.
When to use it
Section titled “When to use it”Reach for the webhook package when you need:
- Signed outbound delivery to customer-owned endpoints
- Endpoint management (subscribe, list, update, revoke) with retries and dead-letter handling
- Inbound provider intake mounted under the same package surface
Quick start
Section titled “Quick start”bun add @lastshotlabs/slingshot-webhooksimport { createMemoryWebhookAdapter, createWebhooksPackage,} from '@lastshotlabs/slingshot-webhooks';
const webhooks = createWebhooksPackage({ mountPath: '/webhooks', adapter: createMemoryWebhookAdapter(), events: ['*'], managementRole: 'admin',});Registry-governed subscriptions
Section titled “Registry-governed subscriptions”Webhook delivery in Slingshot is registry-governed. The webhook package no longer owns a
separate allowlist of string event names — event owners declare what can leave the
process via defineEvent(...), and the webhook package can only deliver events that
opted in.
events.register( defineEvent('billing:invoice.paid', { ownerPlugin: 'billing', exposure: ['tenant-webhook'], resolveScope: (payload, ctx) => ({ tenantId: payload.tenantId, actorId: ctx.actorId ?? null, }), }),);The definition decides:
- Which exposure classes are allowed:
tenant-webhook,user-webhook,app-webhook - How scope is projected for routing
- Whether external delivery is possible at all
If scope cannot be resolved for an externally exposed event, publish fails closed — the webhook package never silently broadens delivery.
Endpoint subscriptions
Section titled “Endpoint subscriptions”Endpoint management uses subscriptions — patterns that the framework normalizes to concrete event keys at write time:
{ "ownerType": "tenant", "ownerId": "tenant-a", "tenantId": "tenant-a", "url": "https://example.com/webhooks", "secret": "whsec_...", "subscriptions": [{ "pattern": "billing:invoice.*" }]}Subscription patterns are normalized when the endpoint is created or updated. The stored
record keeps the concrete approved event keys plus the optional sourcePattern they
came from. The result:
- Writes fail if a pattern does not resolve to any approved events
- Future event registrations do not silently expand existing endpoints
- Reads and queue jobs operate on frozen concrete subscriptions — no late-binding surprises
Scope and authorization
Section titled “Scope and authorization”Delivery is scope-aware by design:
| Endpoint owner | Can receive |
|---|---|
ownerType: 'tenant' | Tenant-scoped webhook events for that tenant only |
ownerType: 'user' | User-scoped webhook events for that user only |
ownerType: 'app' | App-scoped webhook events for that app only |
Cross-tenant widening is rejected by the subscriber authorizer before delivery is queued.
Delivery records
Section titled “Delivery records”Webhook jobs and delivery records preserve the publish-time envelope identity:
event— the event keyeventId— stable per-publish identifieroccurredAt— original publish timestamp- Subscriber identity (
ownerType/ownerId) - Source scope projected by
resolveScope - The projected payload
Queues stay transport-focused: they carry the already-projected payload and metadata — they do not re-run authorization or rebuild scope from raw payload fragments.
Dead-letter callbacks
Section titled “Dead-letter callbacks”Both the in-memory and BullMQ webhook queues accept an onDeadLetter callback
that fires when a delivery exhausts its retry budget or hits a non-retryable
error:
// @skip-typecheckonDeadLetter: (job, err, services) => { // job: WebhookJob — the final snapshot // err: Error — the last failure // services: HookServices | undefined — framework accessor (when wired) services?.bus.emit('app:webhook.dlq', { deliveryId: job.deliveryId });};The third argument is the framework HookServices accessor. When the queue is
constructed inside your own plugin’s setupMiddleware, build the bag once with
buildHookServices() and pass getHookServices: () => services alongside
onDeadLetter. See HookServices for
the late-binding pattern. Callback errors are caught and logged — they never
abort the queue.
Production notes
Section titled “Production notes”- Keep management routes fail-closed with an explicit admin guard (
managementRole). - Use
subscriptionsrather than legacy event allowlists when configuring endpoints. - Treat endpoint URLs and delivery secrets as untrusted input at the boundary.
- Legacy stored endpoint rows are normalized at startup; if a row cannot be mapped safely
to
ownerType/ownerId/ concretesubscriptions, it is disabled instead of widened.
Go deeper
Section titled “Go deeper”- Package docs — full API reference
- Events and the Event Bus — declaring deliverable events
- Server-Sent Events — the other client-facing event transport
- Internals: Event Bus — transport contract and adapter details