Skip to content

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.

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
Terminal window
bun add @lastshotlabs/slingshot-webhooks
import {
createMemoryWebhookAdapter,
createWebhooksPackage,
} from '@lastshotlabs/slingshot-webhooks';
const webhooks = createWebhooksPackage({
mountPath: '/webhooks',
adapter: createMemoryWebhookAdapter(),
events: ['*'],
managementRole: 'admin',
});

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 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

Delivery is scope-aware by design:

Endpoint ownerCan 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.

Webhook jobs and delivery records preserve the publish-time envelope identity:

  • event — the event key
  • eventId — stable per-publish identifier
  • occurredAt — 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.

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-typecheck
onDeadLetter: (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.

  • Keep management routes fail-closed with an explicit admin guard (managementRole).
  • Use subscriptions rather 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 / concrete subscriptions, it is disabled instead of widened.