From 7ce2c4aaade76f0b95cf45bb567a5a1e662d499d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:17:00 -0700 Subject: [PATCH 01/22] feat(webhook): add WebhookTrigger class for Workflow Builder triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new WebhookTrigger class that mirrors IncomingWebhook but handles Workflow Builder webhook triggers which return JSON responses with arbitrary payloads (vs plain text "ok" from incoming webhooks). - Constructor takes URL + defaults (timeout, agent) — same pattern - send() accepts arbitrary key-value payload, returns { ok, body } - Reuses existing error infrastructure and User-Agent instrumentation - Enables consumers like slack-github-action to use the SDK instead of raw fetch for WFB triggers Co-Authored-By: Claude --- packages/webhook/package.json | 4 +- packages/webhook/src/WebhookTrigger.test.ts | 128 ++++++++++++++++++++ packages/webhook/src/WebhookTrigger.ts | 106 ++++++++++++++++ packages/webhook/src/index.ts | 7 ++ 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 packages/webhook/src/WebhookTrigger.test.ts create mode 100644 packages/webhook/src/WebhookTrigger.ts diff --git a/packages/webhook/package.json b/packages/webhook/package.json index b9f8f6fb8..8349aa6ed 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -37,8 +37,8 @@ "build:clean": "shx rm -rf ./dist", "docs": "npx typedoc --plugin typedoc-plugin-markdown", "prepack": "npm run build", - "test": "npm run build && node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts", - "test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts" + "test": "npm run build && node --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts src/WebhookTrigger.test.ts", + "test:coverage": "npm run build && node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=junit --test-reporter-destination=test-results.xml --import tsx --test src/IncomingWebhook.test.ts src/WebhookTrigger.test.ts" }, "dependencies": { "@slack/types": "^2.20.1", diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts new file mode 100644 index 000000000..aed8706d1 --- /dev/null +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import nock from 'nock'; + +import type { CodedError } from './errors'; +import { ErrorCode } from './errors'; +import { WebhookTrigger } from './WebhookTrigger'; + +const url = 'https://hooks.slack.com/triggers/FAKETRIGGER'; + +describe('WebhookTrigger', () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe('constructor()', () => { + it('should build a default webhook trigger given a URL', () => { + const trigger = new WebhookTrigger(url); + assert.ok(trigger instanceof WebhookTrigger); + }); + + it('should create a default webhook trigger with a default timeout', () => { + const trigger = new WebhookTrigger(url); + // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion + assert.strictEqual((trigger as any).defaults.timeout, 0); + }); + + it('should create an axios instance that has the timeout passed by the user', () => { + const givenTimeout = 100; + const trigger = new WebhookTrigger(url, { timeout: givenTimeout }); + // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion + assert.strictEqual((trigger as any).axios.defaults.timeout, givenTimeout); + }); + }); + + describe('send()', () => { + let trigger: WebhookTrigger; + beforeEach(() => { + trigger = new WebhookTrigger(url); + }); + + describe('when making a successful call', () => { + let scope: nock.Scope; + beforeEach(() => { + scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(200, { ok: true }); + }); + + it('should return results in a Promise', async () => { + const result = await trigger.send({ key: 'value' }); + assert.strictEqual(result.ok, true); + assert.deepStrictEqual(result.body, { ok: true }); + scope.done(); + }); + }); + + describe('when the response contains additional data', () => { + let scope: nock.Scope; + beforeEach(() => { + scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(200, { ok: true, workflow_run_id: 'WFR123' }); + }); + + it('should include the full response body', async () => { + const result = await trigger.send({ input: 'data' }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.body.workflow_run_id, 'WFR123'); + scope.done(); + }); + }); + + describe('when the call fails', () => { + let statusCode: number; + let scope: nock.Scope; + beforeEach(() => { + statusCode = 500; + scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(statusCode); + }); + + it('should return a Promise which rejects on error', async () => { + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.ok(error); + assert.ok(error instanceof Error); + assert.match((error as Error).message, new RegExp(String(statusCode))); + scope.done(); + } + }); + + it('should fail with RequestError when the API request fails', async () => { + const trigger = new WebhookTrigger('https://localhost:8999/api/'); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual((error as CodedError).code, ErrorCode.RequestError); + } + }); + }); + + describe('User-Agent header', () => { + it('should send the User-Agent header with every request', async () => { + const scope = nock('https://hooks.slack.com', { + reqheaders: { + 'User-Agent': (value) => { + return /@slack:webhook/.test(value); + }, + }, + }) + .post(/triggers/) + .reply(200, { ok: true }); + try { + const trigger = new WebhookTrigger(url); + await trigger.send({ key: 'value' }); + } finally { + scope.done(); + } + }); + }); + }); +}); diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts new file mode 100644 index 000000000..ad3d98563 --- /dev/null +++ b/packages/webhook/src/WebhookTrigger.ts @@ -0,0 +1,106 @@ +import type { Agent } from 'node:http'; + +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; + +import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; +import { getUserAgent } from './instrument'; + +/** + * A client for Slack's Workflow Builder webhook triggers + * @see {@link https://docs.slack.dev/workflows/triggers/webhook} + */ +export class WebhookTrigger { + /** + * The webhook trigger URL + */ + private url: string; + + /** + * Default arguments for sending to this webhook trigger + */ + private defaults: WebhookTriggerDefaultArguments; + + /** + * Axios HTTP client instance used by this client + */ + private axios: AxiosInstance; + + public constructor( + url: string, + defaults: WebhookTriggerDefaultArguments = { + timeout: 0, + }, + ) { + if (url === undefined) { + throw new Error('Webhook trigger URL is required'); + } + + this.url = url; + this.defaults = defaults; + + this.axios = axios.create({ + baseURL: url, + httpAgent: defaults.agent, + httpsAgent: defaults.agent, + maxRedirects: 0, + proxy: false, + timeout: defaults.timeout, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + }, + }); + + this.defaults.agent = undefined; + } + + /** + * Send a payload to the webhook trigger + * @param payload - arbitrary key-value data to send to the trigger + */ + public async send(payload: WebhookTriggerSendArguments): Promise { + try { + const response = await this.axios.post(this.url, payload); + return this.buildResult(response); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + } catch (error: any) { + if (error.response !== undefined) { + throw httpErrorWithOriginal(error); + } + if (error.request !== undefined) { + throw requestErrorWithOriginal(error); + } + throw error; + } + } + + /** + * Processes an HTTP response into a WebhookTriggerResult. + */ + private buildResult(response: AxiosResponse): WebhookTriggerResult { + const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + return { + ok: body.ok ?? true, + body, + }; + } +} + +/* + * Exported types + */ + +export interface WebhookTriggerDefaultArguments { + agent?: Agent; + timeout?: number; +} + +export interface WebhookTriggerSendArguments { + [key: string]: unknown; +} + +export interface WebhookTriggerResult { + ok: boolean; + // biome-ignore lint/suspicious/noExplicitAny: webhook trigger responses are untyped + body: Record; +} diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 74420ffba..7517027ba 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -14,3 +14,10 @@ export { IncomingWebhookResult, IncomingWebhookSendArguments, } from './IncomingWebhook'; + +export { + WebhookTrigger, + WebhookTriggerDefaultArguments, + WebhookTriggerResult, + WebhookTriggerSendArguments, +} from './WebhookTrigger'; From 89c7af2b1fb3de659da3cec1ff72365bf97ce00c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:20:02 -0700 Subject: [PATCH 02/22] docs(webhook): update WebhookTrigger @see link to JSODC article Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index ad3d98563..8c75444e7 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -7,7 +7,7 @@ import { getUserAgent } from './instrument'; /** * A client for Slack's Workflow Builder webhook triggers - * @see {@link https://docs.slack.dev/workflows/triggers/webhook} + * @see {@link https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack} */ export class WebhookTrigger { /** From 0aab45b61d68b73e156bd4687cfc814b3ce6b850 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:40:12 -0700 Subject: [PATCH 03/22] fix(webhook): constrain WebhookTriggerSendArguments values to string Workflow Builder webhook trigger inputs are always string values. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 8c75444e7..30929fe45 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -96,7 +96,7 @@ export interface WebhookTriggerDefaultArguments { } export interface WebhookTriggerSendArguments { - [key: string]: unknown; + [key: string]: string; } export interface WebhookTriggerResult { From 29ea38ef6925dd69d4963686df8dafb4385ca816 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 10:05:42 -0700 Subject: [PATCH 04/22] feat(webhook): default WebhookTrigger.send payload to empty object Allow send() to be called with no arguments, POSTing an empty body. send({}) continues to work unchanged. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 14 ++++++++++++++ packages/webhook/src/WebhookTrigger.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index aed8706d1..f24161df5 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -55,6 +55,20 @@ describe('WebhookTrigger', () => { }); }); + describe('when called without a payload', () => { + it('should send an empty body and resolve', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/, (body) => { + assert.deepStrictEqual(body, {}); + return true; + }) + .reply(200, { ok: true }); + const result = await trigger.send(); + assert.strictEqual(result.ok, true); + scope.done(); + }); + }); + describe('when the response contains additional data', () => { let scope: nock.Scope; beforeEach(() => { diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 30929fe45..5d2cdf905 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -58,7 +58,7 @@ export class WebhookTrigger { * Send a payload to the webhook trigger * @param payload - arbitrary key-value data to send to the trigger */ - public async send(payload: WebhookTriggerSendArguments): Promise { + public async send(payload: WebhookTriggerSendArguments = {}): Promise { try { const response = await this.axios.post(this.url, payload); return this.buildResult(response); From d6677bfcd40a91134a791e5396dd1862d431dfc2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 10:25:49 -0700 Subject: [PATCH 05/22] fix(webhook): reject empty URL in WebhookTrigger constructor Use a falsy check so an empty-string URL is rejected up front rather than failing later at request time. Adds a guard test. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 6 ++++++ packages/webhook/src/WebhookTrigger.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index f24161df5..563c3d9ec 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -31,6 +31,12 @@ describe('WebhookTrigger', () => { // biome-ignore lint/suspicious/noExplicitAny: accessing private property for test assertion assert.strictEqual((trigger as any).axios.defaults.timeout, givenTimeout); }); + + it('should throw when the URL is missing or empty', () => { + // biome-ignore lint/suspicious/noExplicitAny: exercising the runtime guard with invalid input + assert.throws(() => new WebhookTrigger(undefined as any), /URL is required/); + assert.throws(() => new WebhookTrigger(''), /URL is required/); + }); }); describe('send()', () => { diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 5d2cdf905..7306ca14a 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -31,7 +31,7 @@ export class WebhookTrigger { timeout: 0, }, ) { - if (url === undefined) { + if (!url) { throw new Error('Webhook trigger URL is required'); } From 236d95411c2c406ec0b1a5833bdb88ea2d7aa537 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:06:20 -0700 Subject: [PATCH 06/22] test(webhook): cover 401 application failure, drop extra-field success test Add a test that a 401 with { ok: false, error: 'invalid_auth' } rejects with an HTTPError exposing the response status and body. Remove the success test asserting workflow_run_id so success cases only assert result.ok === true. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 35 +++++++++++---------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index 563c3d9ec..ba6d6db64 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -75,22 +75,6 @@ describe('WebhookTrigger', () => { }); }); - describe('when the response contains additional data', () => { - let scope: nock.Scope; - beforeEach(() => { - scope = nock('https://hooks.slack.com') - .post(/triggers/) - .reply(200, { ok: true, workflow_run_id: 'WFR123' }); - }); - - it('should include the full response body', async () => { - const result = await trigger.send({ input: 'data' }); - assert.strictEqual(result.ok, true); - assert.strictEqual(result.body.workflow_run_id, 'WFR123'); - scope.done(); - }); - }); - describe('when the call fails', () => { let statusCode: number; let scope: nock.Scope; @@ -125,6 +109,25 @@ describe('WebhookTrigger', () => { }); }); + describe('when the response is an application-level failure', () => { + it('should reject with an HTTPError carrying the response body on a 401', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(401, { ok: false, error: 'invalid_auth' }); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + // biome-ignore lint/suspicious/noExplicitAny: reading the wrapped axios response body + const original = (error as any).original; + assert.strictEqual(original.response.status, 401); + assert.deepStrictEqual(original.response.data, { ok: false, error: 'invalid_auth' }); + } + scope.done(); + }); + }); + describe('User-Agent header', () => { it('should send the User-Agent header with every request', async () => { const scope = nock('https://hooks.slack.com', { From 7d56341b8dd2798407aae5ebf7ad585a26740573 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:09:49 -0700 Subject: [PATCH 07/22] feat(webhook): add WebhookTrigger-specific error types WebhookTrigger throws the same coded errors as IncomingWebhook but had no correspondingly named types. Add WebhookTriggerSendError / WebhookTriggerHTTPError / WebhookTriggerRequestError aliases and export them so consumers have properly-named errors to catch. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 11 ++++++----- packages/webhook/src/errors.ts | 6 ++++++ packages/webhook/src/index.ts | 3 +++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index ba6d6db64..09cd0d0f8 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import nock from 'nock'; -import type { CodedError } from './errors'; +import type { CodedError, WebhookTriggerHTTPError } from './errors'; import { ErrorCode } from './errors'; import { WebhookTrigger } from './WebhookTrigger'; @@ -118,11 +118,12 @@ describe('WebhookTrigger', () => { await trigger.send({ key: 'value' }); assert.fail('expected rejection'); } catch (error) { - assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + const httpError = error as WebhookTriggerHTTPError; + assert.strictEqual(httpError.code, ErrorCode.HTTPError); // biome-ignore lint/suspicious/noExplicitAny: reading the wrapped axios response body - const original = (error as any).original; - assert.strictEqual(original.response.status, 401); - assert.deepStrictEqual(original.response.data, { ok: false, error: 'invalid_auth' }); + const response = (httpError.original as any).response; + assert.strictEqual(response.status, 401); + assert.deepStrictEqual(response.data, { ok: false, error: 'invalid_auth' }); } scope.done(); }); diff --git a/packages/webhook/src/errors.ts b/packages/webhook/src/errors.ts index 1252b190a..ba7d0b12d 100644 --- a/packages/webhook/src/errors.ts +++ b/packages/webhook/src/errors.ts @@ -27,6 +27,12 @@ export interface IncomingWebhookHTTPError extends CodedError { original: Error; } +// WebhookTrigger throws the same coded errors as IncomingWebhook; these aliases +// give consumers of WebhookTrigger correctly-named types to catch. +export type WebhookTriggerRequestError = IncomingWebhookRequestError; +export type WebhookTriggerHTTPError = IncomingWebhookHTTPError; +export type WebhookTriggerSendError = WebhookTriggerRequestError | WebhookTriggerHTTPError; + /** * Factory for producing a {@link CodedError} from a generic error */ diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 239e4ee29..7f5b4d098 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -6,6 +6,9 @@ export { IncomingWebhookHTTPError, IncomingWebhookRequestError, IncomingWebhookSendError, + WebhookTriggerHTTPError, + WebhookTriggerRequestError, + WebhookTriggerSendError, } from './errors'; export { From cd50095533a062f246798bf14fa9bdf4d5395317 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:13:28 -0700 Subject: [PATCH 08/22] refactor(webhook): colocate each WebhookTrigger error alias with its source Co-Authored-By: Claude --- packages/webhook/src/errors.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/webhook/src/errors.ts b/packages/webhook/src/errors.ts index ba7d0b12d..c9caf8335 100644 --- a/packages/webhook/src/errors.ts +++ b/packages/webhook/src/errors.ts @@ -16,22 +16,19 @@ export enum ErrorCode { } export type IncomingWebhookSendError = IncomingWebhookRequestError | IncomingWebhookHTTPError; +export type WebhookTriggerSendError = WebhookTriggerRequestError | WebhookTriggerHTTPError; export interface IncomingWebhookRequestError extends CodedError { code: ErrorCode.RequestError; original: Error; } +export type WebhookTriggerRequestError = IncomingWebhookRequestError; export interface IncomingWebhookHTTPError extends CodedError { code: ErrorCode.HTTPError; original: Error; } - -// WebhookTrigger throws the same coded errors as IncomingWebhook; these aliases -// give consumers of WebhookTrigger correctly-named types to catch. -export type WebhookTriggerRequestError = IncomingWebhookRequestError; export type WebhookTriggerHTTPError = IncomingWebhookHTTPError; -export type WebhookTriggerSendError = WebhookTriggerRequestError | WebhookTriggerHTTPError; /** * Factory for producing a {@link CodedError} from a generic error From 2724cd7fb26bba9e6d79e5944d23d841db7b5b13 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:20:18 -0700 Subject: [PATCH 09/22] test(webhook): group WebhookTrigger send() tests by success and failure Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 32 ++++++--------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index 09cd0d0f8..b8a8a086c 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -45,24 +45,18 @@ describe('WebhookTrigger', () => { trigger = new WebhookTrigger(url); }); - describe('when making a successful call', () => { - let scope: nock.Scope; - beforeEach(() => { - scope = nock('https://hooks.slack.com') + describe('on success', () => { + it('should return results in a Promise', async () => { + const scope = nock('https://hooks.slack.com') .post(/triggers/) .reply(200, { ok: true }); - }); - - it('should return results in a Promise', async () => { const result = await trigger.send({ key: 'value' }); assert.strictEqual(result.ok, true); assert.deepStrictEqual(result.body, { ok: true }); scope.done(); }); - }); - describe('when called without a payload', () => { - it('should send an empty body and resolve', async () => { + it('should send an empty body and resolve when called without a payload', async () => { const scope = nock('https://hooks.slack.com') .post(/triggers/, (body) => { assert.deepStrictEqual(body, {}); @@ -75,26 +69,20 @@ describe('WebhookTrigger', () => { }); }); - describe('when the call fails', () => { - let statusCode: number; - let scope: nock.Scope; - beforeEach(() => { - statusCode = 500; - scope = nock('https://hooks.slack.com') + describe('on failure', () => { + it('should reject on an HTTP error status', async () => { + const statusCode = 500; + const scope = nock('https://hooks.slack.com') .post(/triggers/) .reply(statusCode); - }); - - it('should return a Promise which rejects on error', async () => { try { await trigger.send({ key: 'value' }); assert.fail('expected rejection'); } catch (error) { - assert.ok(error); assert.ok(error instanceof Error); assert.match((error as Error).message, new RegExp(String(statusCode))); - scope.done(); } + scope.done(); }); it('should fail with RequestError when the API request fails', async () => { @@ -107,9 +95,7 @@ describe('WebhookTrigger', () => { assert.strictEqual((error as CodedError).code, ErrorCode.RequestError); } }); - }); - describe('when the response is an application-level failure', () => { it('should reject with an HTTPError carrying the response body on a 401', async () => { const scope = nock('https://hooks.slack.com') .post(/triggers/) From b8992d8cca8d847074e8a05d939380536bbd5bec Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:24:22 -0700 Subject: [PATCH 10/22] test(webhook): assert send() transmits the payload body Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index b8a8a086c..6f992078c 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -48,7 +48,10 @@ describe('WebhookTrigger', () => { describe('on success', () => { it('should return results in a Promise', async () => { const scope = nock('https://hooks.slack.com') - .post(/triggers/) + .post(/triggers/, (body) => { + assert.deepStrictEqual(body, { key: 'value' }); + return true; + }) .reply(200, { ok: true }); const result = await trigger.send({ key: 'value' }); assert.strictEqual(result.ok, true); From eab95b30a3cdf53a2dc471523b144dc52aef16bf Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:27:34 -0700 Subject: [PATCH 11/22] docs(webhook): add WebhookTrigger changeset and README usage example Co-Authored-By: Claude --- .changeset/webhook-trigger-class.md | 5 +++++ packages/webhook/README.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .changeset/webhook-trigger-class.md diff --git a/.changeset/webhook-trigger-class.md b/.changeset/webhook-trigger-class.md new file mode 100644 index 000000000..1d30dd00e --- /dev/null +++ b/.changeset/webhook-trigger-class.md @@ -0,0 +1,5 @@ +--- +"@slack/webhook": minor +--- + +feat: add `WebhookTrigger` class for Workflow Builder webhook triggers diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 5461a2819..7c2c436cf 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -82,6 +82,35 @@ const webhook = new IncomingWebhook(url); --- +### Trigger a Workflow Builder workflow + +The package also exports a `WebhookTrigger` class for [Workflow Builder webhook +triggers](https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack). +Unlike incoming webhooks, triggers accept arbitrary JSON payloads and return a JSON response. Initialize it with the +trigger URL, then call `.send(payload)`. The payload is optional; calling `.send()` with no argument posts an empty body. +The returned `Promise` resolves to `{ ok, body }`. + +```javascript +const { WebhookTrigger } = require('@slack/webhook'); + +// Read the trigger URL from the environment variables +const url = process.env.SLACK_WEBHOOK_TRIGGER_URL; + +const trigger = new WebhookTrigger(url); + +(async () => { + // Keys should match the variables your workflow expects + const result = await trigger.send({ + customer_name: 'Ada Lovelace', + order_id: '1024', + }); + + console.log(result.ok, result.body); +})(); +``` + +--- + ### Proxy requests with a custom agent The webhook allows you to customize the HTTP From ceba283c02254a6658b9a3da7b09a4921d2d4178 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:31:58 -0700 Subject: [PATCH 12/22] docs: readme --- packages/webhook/README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 7c2c436cf..74cb268e7 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -84,16 +84,10 @@ const webhook = new IncomingWebhook(url); ### Trigger a Workflow Builder workflow -The package also exports a `WebhookTrigger` class for [Workflow Builder webhook -triggers](https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack). -Unlike incoming webhooks, triggers accept arbitrary JSON payloads and return a JSON response. Initialize it with the -trigger URL, then call `.send(payload)`. The payload is optional; calling `.send()` with no argument posts an empty body. -The returned `Promise` resolves to `{ ok, body }`. +The package also exports a `WebhookTrigger` class for [Workflow Builder webhook triggers](https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack) which accepts an optional, flattened, stringified JSON payload sent to start a workflow. ```javascript const { WebhookTrigger } = require('@slack/webhook'); - -// Read the trigger URL from the environment variables const url = process.env.SLACK_WEBHOOK_TRIGGER_URL; const trigger = new WebhookTrigger(url); @@ -104,8 +98,6 @@ const trigger = new WebhookTrigger(url); customer_name: 'Ada Lovelace', order_id: '1024', }); - - console.log(result.ok, result.body); })(); ``` From eace3baa25db0f2407e15b1f9f182a7444c815bc Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:36:54 -0700 Subject: [PATCH 13/22] docs: readme --- packages/webhook/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 74cb268e7..38e7a3a4a 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -2,10 +2,10 @@ [![codecov](https://codecov.io/gh/slackapi/node-slack-sdk/graph/badge.svg?token=OcQREPvC7r&flag=webhook)](https://codecov.io/gh/slackapi/node-slack-sdk) -The `@slack/webhook` package contains a helper for making requests to Slack's [Incoming -Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). Use it in your app to send a notification to a channel. +The `@slack/webhook` package contains a helper for making requests to Slack's [Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks) or [Workflow Builder](https://slack.com/features/workflow-automation). Use it in your app to send a notification to a channel or start a workflow. ## Requirements + This package supports Node v18 and higher. It's highly recommended to use [the latest LTS version of node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. From b02a78e97f8db8d3c05c2a5b734a63a51482701e Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:38:41 -0700 Subject: [PATCH 14/22] docs: readme --- packages/webhook/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 38e7a3a4a..b3060ed1d 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -94,7 +94,7 @@ const trigger = new WebhookTrigger(url); (async () => { // Keys should match the variables your workflow expects - const result = await trigger.send({ + await trigger.send({ customer_name: 'Ada Lovelace', order_id: '1024', }); From e3626f95c69ab16d8551b93da9a32a247c2a4131 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:42:35 -0700 Subject: [PATCH 15/22] fix(webhook): derive WebhookTrigger result ok strictly from the body Coerce result.ok from body.ok instead of defaulting a missing field to true, so only an explicit ok:true reports success. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 7306ca14a..5cc08faf1 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -80,7 +80,7 @@ export class WebhookTrigger { private buildResult(response: AxiosResponse): WebhookTriggerResult { const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; return { - ok: body.ok ?? true, + ok: body.ok === true, body, }; } From dc3dc2d5295791beff239576ab8cfc44e90b736b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 13:54:12 -0700 Subject: [PATCH 16/22] refactor(webhook): return the trigger response body directly from send() Drop the { ok, body } wrapper so send() resolves to the parsed response body itself, exposing ok/error without nesting or a duplicated ok field. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 3 +-- packages/webhook/src/WebhookTrigger.ts | 13 +++++-------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index 6f992078c..85eba7208 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -54,8 +54,7 @@ describe('WebhookTrigger', () => { }) .reply(200, { ok: true }); const result = await trigger.send({ key: 'value' }); - assert.strictEqual(result.ok, true); - assert.deepStrictEqual(result.body, { ok: true }); + assert.deepStrictEqual(result, { ok: true }); scope.done(); }); diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 5cc08faf1..154de492b 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -78,11 +78,7 @@ export class WebhookTrigger { * Processes an HTTP response into a WebhookTriggerResult. */ private buildResult(response: AxiosResponse): WebhookTriggerResult { - const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; - return { - ok: body.ok === true, - body, - }; + return typeof response.data === 'string' ? JSON.parse(response.data) : response.data; } } @@ -100,7 +96,8 @@ export interface WebhookTriggerSendArguments { } export interface WebhookTriggerResult { - ok: boolean; - // biome-ignore lint/suspicious/noExplicitAny: webhook trigger responses are untyped - body: Record; + ok?: boolean; + error?: string; + // biome-ignore lint/suspicious/noExplicitAny: webhook trigger responses are otherwise untyped + [key: string]: any; } From 3bed3dcf060ca2eae57e25f9352999095afa6c5c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 15:58:03 -0700 Subject: [PATCH 17/22] test(webhook): verify addAppMetadata expands WebhookTrigger User-Agent Assert that metadata added via addAppMetadata is sent in the User-Agent header on a WebhookTrigger request, alongside the base webhook agent. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index 85eba7208..db49116fb 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -4,6 +4,7 @@ import nock from 'nock'; import type { CodedError, WebhookTriggerHTTPError } from './errors'; import { ErrorCode } from './errors'; +import { addAppMetadata } from './instrument'; import { WebhookTrigger } from './WebhookTrigger'; const url = 'https://hooks.slack.com/triggers/FAKETRIGGER'; @@ -135,6 +136,25 @@ describe('WebhookTrigger', () => { scope.done(); } }); + + it('should send app metadata added via addAppMetadata in the User-Agent header', async () => { + const scope = nock('https://hooks.slack.com', { + reqheaders: { + 'User-Agent': (value) => value.includes('my-tool/1.2.3') && /@slack:webhook/.test(value), + }, + }) + .post(/triggers/) + .reply(200, { ok: true }); + try { + // addAppMetadata mutates module state read by getUserAgent(), which the + // client captures at construction, so it must be added before the client. + addAppMetadata({ name: 'my-tool', version: '1.2.3' }); + const trigger = new WebhookTrigger(url); + await trigger.send({ key: 'value' }); + } finally { + scope.done(); + } + }); }); }); }); From c0f5c7efb0ba92dc34eeee21243d4c01c44f0fe9 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 16:04:29 -0700 Subject: [PATCH 18/22] test(webhook): drop explanatory comment from addAppMetadata UA test Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index db49116fb..61321df0c 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -146,8 +146,6 @@ describe('WebhookTrigger', () => { .post(/triggers/) .reply(200, { ok: true }); try { - // addAppMetadata mutates module state read by getUserAgent(), which the - // client captures at construction, so it must be added before the client. addAppMetadata({ name: 'my-tool', version: '1.2.3' }); const trigger = new WebhookTrigger(url); await trigger.send({ key: 'value' }); From 5d1a1edfddb78b8877efc82195ee17f00eaeeaea Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 16:12:57 -0700 Subject: [PATCH 19/22] refactor(webhook): inline the trigger response body, drop buildResult send() now returns response.data directly; the string-vs-object parse branch was dead code since axios already parses JSON responses. Removes the now-unused AxiosResponse import. Simplifies the eventual fetch swap. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 154de492b..f21a620ec 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -1,6 +1,6 @@ import type { Agent } from 'node:http'; -import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import axios, { type AxiosInstance } from 'axios'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; @@ -61,7 +61,7 @@ export class WebhookTrigger { public async send(payload: WebhookTriggerSendArguments = {}): Promise { try { const response = await this.axios.post(this.url, payload); - return this.buildResult(response); + return response.data; // biome-ignore lint/suspicious/noExplicitAny: errors can be anything } catch (error: any) { if (error.response !== undefined) { @@ -73,13 +73,6 @@ export class WebhookTrigger { throw error; } } - - /** - * Processes an HTTP response into a WebhookTriggerResult. - */ - private buildResult(response: AxiosResponse): WebhookTriggerResult { - return typeof response.data === 'string' ? JSON.parse(response.data) : response.data; - } } /* From e6604b60e26006969b1d6bf22b4aa870a65c3abf Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 16:19:02 -0700 Subject: [PATCH 20/22] refactor(webhook): tighten WebhookTriggerResult to { ok, error? } The trigger endpoint responds with { ok: true } or { ok: false, error }, so type the result closed rather than an open index signature. Widening later is backward-compatible; tightening a released `any` would not be. Co-Authored-By: Claude --- packages/webhook/src/WebhookTrigger.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index f21a620ec..1b990bf0b 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -89,8 +89,6 @@ export interface WebhookTriggerSendArguments { } export interface WebhookTriggerResult { - ok?: boolean; + ok: boolean; error?: string; - // biome-ignore lint/suspicious/noExplicitAny: webhook trigger responses are otherwise untyped - [key: string]: any; } From 1684fa8f9feb0f60ea63f86fca7bf3150495db44 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 17:05:07 -0700 Subject: [PATCH 21/22] docs(webhook): retitle README to Slack Webhooks The package now covers both Incoming Webhooks and Workflow Builder triggers, so the narrower "Incoming Webhooks" title is outdated. Co-Authored-By: Claude --- packages/webhook/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index b3060ed1d..d4f727f48 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -1,4 +1,4 @@ -# Slack Incoming Webhooks +# Slack Webhooks [![codecov](https://codecov.io/gh/slackapi/node-slack-sdk/graph/badge.svg?token=OcQREPvC7r&flag=webhook)](https://codecov.io/gh/slackapi/node-slack-sdk) From edd3ecb0f02d75e5a488c5a9e28510a361d0f02a Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 8 Jul 2026 05:42:51 -0700 Subject: [PATCH 22/22] docs(webhook): use singular Slack Webhook README title Co-Authored-By: Claude --- packages/webhook/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index d4f727f48..35215c82f 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -1,4 +1,4 @@ -# Slack Webhooks +# Slack Webhook [![codecov](https://codecov.io/gh/slackapi/node-slack-sdk/graph/badge.svg?token=OcQREPvC7r&flag=webhook)](https://codecov.io/gh/slackapi/node-slack-sdk)