Creating custom n8n webhook node

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