I’m building a custom trigger node that connects to a remote server using a webhook mechanism.
What I want to achieve:
- When the user selects the trigger node and clicks “Execute Node”,
→ My node should send a subscription request to the server API. - After subscribing, the server sends a request back to my webhook endpoint to verify that it’s active.
→ I need to respond with a 2XX HTTP status code to confirm that the endpoint is alive.
My concern:
I’m not sure if this is the right approach. Is it okay to:
- Send the subscription request during node execution?
- Handle the verification callback from the server by responding with a 2XX status?
Or is there a better, more reliable way to do this in a custom trigger node?
Any guidance on how to properly implement this kind of handshake/verification logic would be greatly appreciated.
import type {
IHookFunctions,
IWebhookFunctions,
IDataObject,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
JsonObject,
} from 'n8n-workflow';
import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow';
// Replace with your actual constants
import { ACCOUNT_LEVEL_EVENTS, WEBHOOK_BASE_URL } from '../../constants';
export class GenericWebhookTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Generic Webhook Trigger',
name: 'genericWebhookTrigger',
icon: 'file:genericWebhook.svg',
group: ['trigger'],
version: 1,
subtitle:
'={{$parameter["events"].length ? $parameter["events"].join(", ") : "No events selected"}}',
description: 'Starts the workflow when webhook events occur from a generic system',
defaults: {
name: 'Generic Webhook Trigger',
},
inputs: [],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'genericOAuth2Api',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Events',
name: 'events',
type: 'multiOptions',
options: [
{
name: 'Account - All Events',
value: 'account/*',
description: 'Receive notifications for all account-level events',
},
],
default: [],
required: true,
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Ignore Account Level Events Extra Filter',
name: 'ignoreAccountLevelFilter',
type: 'boolean',
default: true,
description:
'Whether to ignore extra filters for account-level events (recommended)',
},
],
},
],
};
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId === undefined) return false;
try {
await this.helpers.httpRequestWithAuthentication.call(this, 'genericOAuth2Api', {
method: 'GET',
url: `${WEBHOOK_BASE_URL}/subscriptions/${webhookData.webhookId}`,
json: true,
});
} catch {
delete webhookData.webhookId;
return false;
}
return true;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const events = this.getNodeParameter('events') as string[];
const options = this.getNodeParameter('options') as IDataObject;
if (webhookUrl.includes('//localhost')) {
throw new NodeOperationError(
this.getNode(),
'The Webhook can not work on "localhost". Please use a public URL.',
);
}
let filteredEvents = events;
if (options.ignoreAccountLevelFilter) {
filteredEvents = events.filter((event) => {
const [topic, eventType] = event.split('/');
return !ACCOUNT_LEVEL_EVENTS.some(
(accountEvent) =>
accountEvent.topic === topic &&
(accountEvent.event === eventType || event.endsWith('/*')),
);
});
}
const body: IDataObject = {
endpointUrl: webhookUrl,
events: filteredEvents,
};
let responseData;
try {
responseData = await this.helpers.httpRequestWithAuthentication.call(
this,
'genericOAuth2Api',
{
method: 'POST',
body,
url: `${WEBHOOK_BASE_URL}/subscriptions`,
json: true,
},
);
} catch (error) {
throw new NodeOperationError(
this.getNode(),
`Failed to create webhook: ${error.message}`,
{ level: 'warning' },
);
}
if (!responseData?.id) {
throw new NodeApiError(this.getNode(), responseData as JsonObject, {
message: 'Webhook creation response did not contain the expected data.',
});
}
webhookData.webhookId = responseData.id as string;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
try {
await this.helpers.httpRequestWithAuthentication.call(this, 'genericOAuth2Api', {
method: 'DELETE',
url: `${WEBHOOK_BASE_URL}/subscriptions/${webhookData.webhookId}`,
json: true,
});
} catch {
return false;
}
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
console.log('🔔 Webhook request received');
const req = this.getRequestObject();
const bodyData = this.getBodyData();
const httpMethod = req.method?.toUpperCase() || 'POST';
if (httpMethod === 'GET') {
if (req.query?.url) {
console.log('✅ Webhook verification request for URL:', req.query.url);
return { webhookResponse: 'OK' };
}
console.log('✅ Health check GET request received');
return {
webhookResponse: JSON.stringify({
status: 'healthy',
message: 'Webhook endpoint is active',
timestamp: new Date().toISOString(),
endpoint: 'generic-webhook-trigger',
}),
};
}
if (httpMethod === 'POST') {
if (!bodyData || (typeof bodyData === 'object' && Object.keys(bodyData).length === 0)) {
console.log('⚠️ Empty POST request received');
return { webhookResponse: 'OK' };
}
console.log('🎯 Processing webhook event');
const returnData: IDataObject[] = [];
returnData.push({
body: bodyData,
headers: this.getHeaderData(),
query: this.getQueryData(),
});
return {
workflowData: [this.helpers.returnJsonArray(returnData)],
};
}
console.log(`⚠️ Unsupported HTTP method: ${httpMethod}`);
return {
webhookResponse: JSON.stringify({
error: 'Method not allowed',
message: `HTTP method ${httpMethod} is not supported`,
supportedMethods: ['GET', 'POST'],
}),
};
}
}
Information on your n8n setup
- n8n version: 1.97.1
- Database (default: SQLite):
- n8n EXECUTIONS_PROCESS setting (default: own, main):
- Running n8n via (Docker, npm, n8n cloud, desktop app): desktop app
- Operating system: windows