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

Hi @Emre_KAS,

Your implementation looks good so far! If you take an example, like the Github Trigger Node, you’re already following the right patterns.

The only thing I would suggest is to add Error handling in create() - e.g. GitHub trigger handles 422 errors for existing webhooks. Ensure you clean up webhookData on errors as well, like GitHub does at here.

You should also do make sure your checkExists() method validates whether the webhook is still
configured for the correct events/URL.

And lastly, I would use n8n’s native this.logger instead of console.logs.

I hope this helps :slight_smile: