Skip to main content

Webhooks

node-auth supports two complementary webhook directions:

DirectionPurposeConfig
OutgoingLibrary fires → external serviceIWebhookStore + WebhookSender
Inbound (dynamic)External service fires → library executes scriptIWebhookStore.findByProvider + a script run through node:vm

Both are managed from the Admin Panel → Webhooks tab.


Outgoing webhooks​

node-auth can forward any AuthEventBus event to external HTTP endpoints as a signed JSON POST. Delivery is performed by WebhookSender with optional HMAC-SHA256 signing and configurable exponential back-off retry.


Delivery flow​


Setup​

1. Implement IWebhookStore​

import { IWebhookStore, WebhookConfig } from '@awesome-lang-auth/node';

export class MyWebhookStore implements IWebhookStore {
async findByEvent(event: string, tenantId?: string): Promise<WebhookConfig[]> {
return db('webhooks')
.where('isActive', true)
.where((q) =>
q.where('tenantId', tenantId ?? null).orWhereNull('tenantId'),
)
.where((q) =>
q.whereJsonContains('events', event).orWhereJsonContains('events', '*'),
);
}
}

2. Pass the store to AuthTools​

const tools = new AuthTools(bus, { webhookStore: myWebhookStore });

Webhooks are now dispatched automatically whenever tools.track() is called.


Webhook payload​

{
"event": "identity.auth.login.success",
"version": "1",
"timestamp": "2024-11-15T10:23:45.678Z",
"data": { "method": "oauth", "provider": "google" },
"metadata": {
"userId": "user-123",
"tenantId": "tenant-abc",
"correlationId": "req-xyz"
}
}

Verifying signatures​

Every outgoing request includes an X-Webhook-Signature: sha256=<hex> header. Verify it on the receiving server using the shared secret:

import { WebhookSender } from '@awesome-lang-auth/node';

const sender = new WebhookSender();

app.post('/webhooks/awesome-node-auth', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const isValid = sender.verify(req.body.toString(), process.env.WEBHOOK_SECRET!, signature);
if (!isValid) return res.status(401).json({ error: 'Invalid signature' });

const event = JSON.parse(req.body.toString());
console.log('Received event:', event.event);
res.sendStatus(200);
});

WebhookConfig fields​

FieldTypeRequiredDescription
idstring✓Unique registration ID
urlstring✓Endpoint URL
eventsstring[]✓Event names (or ['*'] for all)
secretstring—HMAC signing secret
isActiveboolean—Default true
tenantIdstring—Scope to a tenant; omit for global
maxRetriesnumber—Default 3
retryDelayMsnumber—Initial delay in ms; default 1000

Request headers​

HeaderDescription
X-Webhook-EventEvent name
X-Webhook-DeliveryUUID per delivery attempt
X-Webhook-TimestampISO 8601 timestamp
X-Webhook-Signaturesha256=<hmac-hex> (when secret is set)

Dynamic inbound execution​

Beyond the static onWebhook callback, the tools router supports a governance-driven execution model where:

  1. Developers expose service methods as injectable actions with @webhookAction.
  2. Administrators globally enable/disable actions in the Control → Webhook Actions panel.
  3. Each inbound webhook config in the Webhooks tab is assigned a jsScript + an allowedActions subset.
  4. When POST /tools/webhook/:provider fires, the script runs through Node.js node:vm, and its actions object holds only the intersection of globally-enabled and per-webhook-allowed actions.
Inbound scripts run with the server's privileges

node:vm is not a security mechanism, so the script is not sandboxed: it runs with the privileges of the server process and can run arbitrary code on the server. Only fully trusted operators may write a jsScript, whether through the webhook store or the admin console. The allowedActions intersection only decides which functions the script receives in actions. The 5-second timeout bounds only the synchronous part of the script, not its asynchronous work. See awesome-node-auth#10.

Execution flow​

1. Register actions with @webhookAction​

import { webhookAction, ActionRegistry } from '@awesome-lang-auth/node';

class SubscriptionService {
@webhookAction({
id: 'subscription.cancel',
label: 'Cancel subscription',
category: 'Billing',
description: 'Marks a subscription as cancelled.',
})
async cancel(subscriptionId: string): Promise<void> {
await db.subscriptions.update({ id: subscriptionId }, { status: 'cancelled' });
}

@webhookAction({
id: 'subscription.notifyUser',
label: 'Notify user',
category: 'Billing',
description: 'Sends cancellation email to the user.',
dependsOn: ['subscription.cancel'], // only available when cancel is also enabled
})
async notifyUser(userId: string): Promise<void> {
await mailer.send({ to: userId, subject: 'Subscription cancelled', template: 'sub-cancelled' });
}
}

// Bind the instance so inbound scripts can call it
const svc = new SubscriptionService();
ActionRegistry.register({ id: 'subscription.cancel', label: 'Cancel subscription', category: 'Billing', description: '', fn: svc.cancel.bind(svc) });
ActionRegistry.register({ id: 'subscription.notifyUser', label: 'Notify user', category: 'Billing', description: '', dependsOn: ['subscription.cancel'], fn: svc.notifyUser.bind(svc) });

2. Wire stores into the tools router​

app.use('/tools', createToolsRouter(tools, {
webhookStore: myWebhookStore, // must implement findByProvider()
settingsStore: mySettingsStore, // reads enabledWebhookActions
}));

3. Create an inbound webhook config via the Admin UI​

In the Webhooks tab click + Register webhook, switch to Inbound (dynamic) and fill in:

FieldExampleDescription
Provider namestripeMatches :provider in POST /tools/webhook/stripe
Events*(optional) event filter
Allowed actions☑ Cancel subscriptionSubset of globally-enabled actions
JavaScriptsee belowScript body, run through node:vm with the server's privileges

Example script:

// Available globals: body (request payload), actions (filtered functions)
// Set result to emit an internal event, or leave null to silently ack.

if (body.type === 'customer.subscription.deleted') {
const subId = body.data.object.id;
const userId = body.data.object.metadata.userId;

await actions['subscription.cancel'](subId);
await actions['subscription.notifyUser'](userId);

result = {
event: 'identity.tenant.user.removed',
data: { subscriptionId: subId, userId },
};
}

4. Governance rules​

RuleBehaviour
Action not in enabledWebhookActionsNot passed to the script in actions, even if in allowedActions
Action's dependsOn not enabledNot passed to the script in actions
Script throws at runtimeError logged, HTTP 200 returned (no crash)
Synchronous part of the script exceeds 5 sError logged, HTTP 200 returned; asynchronous work is not bounded
result not set / nullWebhook silently acknowledged, no event emitted

WebhookConfig new fields​

FieldTypeDescription
providerstringInbound provider name (matches :provider param)
allowedActionsstring[]Action IDs this webhook's script receives in actions
jsScriptstringJS body run through node:vm with the server's privileges; only trusted operators may write it

IWebhookStore new method​

/** Find the inbound webhook config for a given provider name. */
findByProvider?(provider: string): Promise<WebhookConfig | null>;

AuthSettings new field​

/** Globally enabled action IDs (toggled in the Admin UI → Control tab). */
enabledWebhookActions?: string[];

ToolsRouterOptions new options​

/** Provides findByProvider() for dynamic inbound webhook lookup. */
webhookStore?: IWebhookStore;

/** Reads enabledWebhookActions for inbound webhook scripts. */
settingsStore?: ISettingsStore;