From 7ce2c4aaade76f0b95cf45bb567a5a1e662d499d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 2 Jun 2026 14:17:00 -0700 Subject: [PATCH 01/38] 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/38] 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/38] 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 a0e3e1de8db3cd5fcd14a335a9e1c6b9777844cb Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 2 Jul 2026 01:43:12 -0700 Subject: [PATCH 04/38] feat(webhook): add retry policies mirroring @slack/web-api --- package-lock.json | 5 ++- packages/webhook/package.json | 9 +++-- packages/webhook/src/index.ts | 7 ++++ packages/webhook/src/retry-policies.test.ts | 19 ++++++++++ packages/webhook/src/retry-policies.ts | 41 +++++++++++++++++++++ 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 packages/webhook/src/retry-policies.test.ts create mode 100644 packages/webhook/src/retry-policies.ts diff --git a/package-lock.json b/package-lock.json index 6eb80134a..33819d9ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6505,7 +6505,10 @@ "dependencies": { "@slack/types": "^2.20.1", "@types/node": ">=18", - "axios": "^1.16.0" + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "p-retry": "^4", + "retry": "^0.13.1" }, "devDependencies": { "nock": "^14.0.6" diff --git a/packages/webhook/package.json b/packages/webhook/package.json index a5ee5e60f..55f77e03d 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -37,13 +37,16 @@ "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 src/WebhookTrigger.test.ts src/instrument.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 src/instrument.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 src/instrument.test.ts src/retry-policies.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 src/instrument.test.ts src/retry-policies.test.ts" }, "dependencies": { "@slack/types": "^2.20.1", "@types/node": ">=18", - "axios": "^1.16.0" + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "p-retry": "^4", + "retry": "^0.13.1" }, "devDependencies": { "nock": "^14.0.6" diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index 239e4ee29..ebe63764c 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -17,6 +17,13 @@ export { export { addAppMetadata } from './instrument'; +export { + fiveRetriesInFiveMinutes, + RetryOptions, + rapidRetryPolicy, + tenRetriesInAboutThirtyMinutes, +} from './retry-policies'; + export { WebhookTrigger, WebhookTriggerDefaultArguments, diff --git a/packages/webhook/src/retry-policies.test.ts b/packages/webhook/src/retry-policies.test.ts new file mode 100644 index 000000000..4039ca6cd --- /dev/null +++ b/packages/webhook/src/retry-policies.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { fiveRetriesInFiveMinutes, rapidRetryPolicy, tenRetriesInAboutThirtyMinutes } from './retry-policies'; + +describe('retry-policies', () => { + it('fiveRetriesInFiveMinutes retries five times', () => { + assert.strictEqual(fiveRetriesInFiveMinutes.retries, 5); + }); + + it('tenRetriesInAboutThirtyMinutes retries ten times', () => { + assert.strictEqual(tenRetriesInAboutThirtyMinutes.retries, 10); + }); + + it('rapidRetryPolicy uses a tiny timeout', () => { + assert.strictEqual(rapidRetryPolicy.minTimeout, 0); + assert.strictEqual(rapidRetryPolicy.maxTimeout, 1); + }); +}); diff --git a/packages/webhook/src/retry-policies.ts b/packages/webhook/src/retry-policies.ts new file mode 100644 index 000000000..41a57a7e9 --- /dev/null +++ b/packages/webhook/src/retry-policies.ts @@ -0,0 +1,41 @@ +import type { OperationOptions } from 'retry'; + +/** + * Options to create retry policies. Extends from https://github.com/tim-kos/node-retry. + */ +export interface RetryOptions extends OperationOptions {} + +/** + * The default retry policy. Retry up to 10 times, over the span of about 30 minutes. It's not exact because + * randomization has been added to prevent a stampeding herd problem (if all instances in your application are retrying + * a request at the exact same intervals, they are more likely to cause failures for each other). + */ +export const tenRetriesInAboutThirtyMinutes: RetryOptions = { + retries: 10, + factor: 1.96821, + randomize: true, +}; + +/** + * Short & sweet, five retries in five minutes and then bail. + */ +export const fiveRetriesInFiveMinutes: RetryOptions = { + retries: 5, + factor: 3.86, +}; + +/** + * This policy is just to keep the tests running fast. + */ +export const rapidRetryPolicy: RetryOptions = { + minTimeout: 0, + maxTimeout: 1, +}; + +const policies = { + tenRetriesInAboutThirtyMinutes, + fiveRetriesInFiveMinutes, + rapidRetryPolicy, +}; + +export default policies; From a0e845e14721683f3939c71c1958200a2690292c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 2 Jul 2026 01:49:38 -0700 Subject: [PATCH 05/38] feat(webhook): add opt-in retries to WebhookTrigger --- packages/webhook/src/WebhookTrigger.test.ts | 63 +++++++++++++++++++++ packages/webhook/src/WebhookTrigger.ts | 42 ++++++++++---- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index aed8706d1..0853668e6 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 } from './errors'; import { ErrorCode } from './errors'; +import { rapidRetryPolicy } from './retry-policies'; import { WebhookTrigger } from './WebhookTrigger'; const url = 'https://hooks.slack.com/triggers/FAKETRIGGER'; @@ -124,5 +125,67 @@ describe('WebhookTrigger', () => { } }); }); + + describe('retries', () => { + it('retries a 5xx then succeeds when a retry policy is set', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(503) + .post(/triggers/) + .reply(200, { ok: true }); + const trigger = new WebhookTrigger(url, { retryConfig: rapidRetryPolicy }); + const result = await trigger.send({ key: 'value' }); + assert.strictEqual(result.ok, true); + scope.done(); + }); + + it('does not retry a 4xx even when a retry policy is set', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(400); + const trigger = new WebhookTrigger(url, { retryConfig: rapidRetryPolicy }); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + // Only one interceptor is registered; a retry would leave it unmatched + // and scope.done() would throw. + scope.done(); + }); + + it('gives up with the HTTP error after exhausting retries', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(500) + .post(/triggers/) + .reply(500); + const trigger = new WebhookTrigger(url, { + retryConfig: { retries: 1, minTimeout: 0, maxTimeout: 1 }, + }); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + scope.done(); + }); + + it('does not retry by default (no retryConfig)', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(503); + const trigger = new WebhookTrigger(url); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + scope.done(); + }); + }); }); }); diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 30929fe45..2324fd2ed 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -1,9 +1,11 @@ import type { Agent } from 'node:http'; import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import pRetry, { AbortError } from 'p-retry'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; +import type { RetryOptions } from './retry-policies'; /** * A client for Slack's Workflow Builder webhook triggers @@ -25,6 +27,11 @@ export class WebhookTrigger { */ private axios: AxiosInstance; + /** + * Retry policy applied to each send. Defaults to no retries. + */ + private retryConfig: RetryOptions; + public constructor( url: string, defaults: WebhookTriggerDefaultArguments = { @@ -37,6 +44,7 @@ export class WebhookTrigger { this.url = url; this.defaults = defaults; + this.retryConfig = defaults.retryConfig ?? { retries: 0 }; this.axios = axios.create({ baseURL: url, @@ -59,19 +67,28 @@ export class WebhookTrigger { * @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); + // Retries are limited to transient failures: rate limits (429), server + // errors (5xx), and network errors with no response. Client errors (other + // 4xx) abort immediately since they will not succeed on retry. + return pRetry(async () => { + 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) { + const status: number = error.response.status; + const retryable = status === 429 || status >= 500; + const wrapped = httpErrorWithOriginal(error); + throw retryable ? wrapped : new AbortError(wrapped); + } + if (error.request !== undefined) { + // No response received (network/timeout): retryable. + throw requestErrorWithOriginal(error); + } + throw new AbortError(error); } - if (error.request !== undefined) { - throw requestErrorWithOriginal(error); - } - throw error; - } + }, this.retryConfig); } /** @@ -93,6 +110,7 @@ export class WebhookTrigger { export interface WebhookTriggerDefaultArguments { agent?: Agent; timeout?: number; + retryConfig?: RetryOptions; } export interface WebhookTriggerSendArguments { From 4f0f5916cae32e853dd089fa58b859e97b844e4f Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 2 Jul 2026 01:51:23 -0700 Subject: [PATCH 06/38] feat(webhook): add opt-in retries to IncomingWebhook --- packages/webhook/src/IncomingWebhook.test.ts | 58 ++++++++++++++++++++ packages/webhook/src/IncomingWebhook.ts | 46 +++++++++++----- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index 36db5946b..c5a4e3452 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -5,6 +5,7 @@ import nock from 'nock'; import type { CodedError } from './errors'; import { ErrorCode } from './errors'; import { IncomingWebhook } from './IncomingWebhook'; +import { rapidRetryPolicy } from './retry-policies'; const url = 'https://hooks.slack.com/services/FAKEWEBHOOK'; @@ -136,5 +137,62 @@ describe('IncomingWebhook', () => { } }); }); + + describe('retries', () => { + it('retries a 5xx then succeeds when a retry policy is set', async () => { + const scope = nock('https://hooks.slack.com') + .post(/services/) + .reply(503) + .post(/services/) + .reply(200, 'ok'); + const webhook = new IncomingWebhook(url, { retryConfig: rapidRetryPolicy }); + const result = await webhook.send('hello'); + assert.strictEqual(result.text, 'ok'); + scope.done(); + }); + + it('does not retry a 4xx even when a retry policy is set', async () => { + const scope = nock('https://hooks.slack.com') + .post(/services/) + .reply(400); + const webhook = new IncomingWebhook(url, { retryConfig: rapidRetryPolicy }); + try { + await webhook.send('hello'); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + scope.done(); + }); + + it('does not retry by default (no retryConfig)', async () => { + const scope = nock('https://hooks.slack.com') + .post(/services/) + .reply(503); + const webhook = new IncomingWebhook(url); + try { + await webhook.send('hello'); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + scope.done(); + }); + + it('does not leak retryConfig into the posted payload', async () => { + let posted: unknown; + const scope = nock('https://hooks.slack.com') + .post(/services/, (body) => { + posted = body; + return true; + }) + .reply(200, 'ok'); + const webhook = new IncomingWebhook(url, { retryConfig: rapidRetryPolicy }); + await webhook.send('hello'); + assert.ok(posted && typeof posted === 'object'); + assert.ok(!('retryConfig' in (posted as Record))); + scope.done(); + }); + }); }); }); diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 8b5046121..d45053093 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -2,9 +2,11 @@ import type { Agent } from 'node:http'; import type { Block, KnownBlock, MessageAttachment } from '@slack/types'; // TODO: Block and KnownBlock will be merged into AnyBlock in upcoming types release import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import pRetry, { AbortError } from 'p-retry'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; +import type { RetryOptions } from './retry-policies'; /** * A client for Slack's Incoming Webhooks @@ -25,6 +27,11 @@ export class IncomingWebhook { */ private axios: AxiosInstance; + /** + * Retry policy applied to each send. Defaults to no retries. + */ + private retryConfig: RetryOptions; + public constructor( url: string, defaults: IncomingWebhookDefaultArguments = { @@ -37,6 +44,7 @@ export class IncomingWebhook { this.url = url; this.defaults = defaults; + this.retryConfig = defaults.retryConfig ?? { retries: 0 }; this.axios = axios.create({ baseURL: url, @@ -50,7 +58,9 @@ export class IncomingWebhook { }, }); + // Strip transport-only options so they do not leak into the posted payload. this.defaults.agent = undefined; + this.defaults.retryConfig = undefined; } /** @@ -67,20 +77,29 @@ export class IncomingWebhook { payload = Object.assign(payload, message); } - 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) { - // Wrap errors in this packages own error types (abstract the implementation details' types) - if (error.response !== undefined) { - throw httpErrorWithOriginal(error); + // Retries are limited to transient failures: rate limits (429), server + // errors (5xx), and network errors with no response. Client errors (other + // 4xx) abort immediately since they will not succeed on retry. + return pRetry(async () => { + 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) { + // Wrap errors in this packages own error types (abstract the implementation details' types) + if (error.response !== undefined) { + const status: number = error.response.status; + const retryable = status === 429 || status >= 500; + const wrapped = httpErrorWithOriginal(error); + throw retryable ? wrapped : new AbortError(wrapped); + } + if (error.request !== undefined) { + // No response received (network/timeout): retryable. + throw requestErrorWithOriginal(error); + } + throw new AbortError(error); } - if (error.request !== undefined) { - throw requestErrorWithOriginal(error); - } - throw error; - } + }, this.retryConfig); } /** @@ -106,6 +125,7 @@ export interface IncomingWebhookDefaultArguments { link_names?: boolean; agent?: Agent; timeout?: number; + retryConfig?: RetryOptions; } export interface IncomingWebhookSendArguments extends IncomingWebhookDefaultArguments { From ef40e60c868c07d905fcadb1f984fe22125c8ab8 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 2 Jul 2026 02:25:43 -0700 Subject: [PATCH 07/38] refactor(webhook): order retryConfig before timeout in default args --- packages/webhook/src/IncomingWebhook.ts | 2 +- packages/webhook/src/WebhookTrigger.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index d45053093..d10097925 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -124,8 +124,8 @@ export interface IncomingWebhookDefaultArguments { text?: string; link_names?: boolean; agent?: Agent; - timeout?: number; retryConfig?: RetryOptions; + timeout?: number; } export interface IncomingWebhookSendArguments extends IncomingWebhookDefaultArguments { diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 2324fd2ed..ad81427ca 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -109,8 +109,8 @@ export class WebhookTrigger { export interface WebhookTriggerDefaultArguments { agent?: Agent; - timeout?: number; retryConfig?: RetryOptions; + timeout?: number; } export interface WebhookTriggerSendArguments { From 7c8e9ca31cd6c4b8b17b0a25ae3d322ccbecb953 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 2 Jul 2026 03:16:57 -0700 Subject: [PATCH 08/38] feat(webhook): make WebhookTrigger.send payload optional Default the payload to an empty object so send() can 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 | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index 0853668e6..af20fd00c 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -56,6 +56,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 ad81427ca..2f2373163 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -64,9 +64,9 @@ export class WebhookTrigger { /** * Send a payload to the webhook trigger - * @param payload - arbitrary key-value data to send to the trigger + * @param payload - arbitrary key-value data to send to the trigger; defaults to an empty object */ - public async send(payload: WebhookTriggerSendArguments): Promise { + public async send(payload: WebhookTriggerSendArguments = {}): Promise { // Retries are limited to transient failures: rate limits (429), server // errors (5xx), and network errors with no response. Client errors (other // 4xx) abort immediately since they will not succeed on retry. From 29ea38ef6925dd69d4963686df8dafb4385ca816 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 10:05:42 -0700 Subject: [PATCH 09/38] 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 74f0dcdf96e751e6e0a412f7e6b7561ab69a5e00 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 10:17:01 -0700 Subject: [PATCH 10/38] docs(webhook): revert send @param comment to original wording The `= {}` default now lives in the WebhookTrigger PR; keep the @param description unchanged here. 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 2f2373163..6779deea1 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -64,7 +64,7 @@ export class WebhookTrigger { /** * Send a payload to the webhook trigger - * @param payload - arbitrary key-value data to send to the trigger; defaults to an empty object + * @param payload - arbitrary key-value data to send to the trigger */ public async send(payload: WebhookTriggerSendArguments = {}): Promise { // Retries are limited to transient failures: rate limits (429), server From 700af6a8e60ec5b7d6de97221f44e0a55fe90fd5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 10:22:28 -0700 Subject: [PATCH 11/38] 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 af20fd00c..59dad28d5 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -32,6 +32,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 6779deea1..f82120050 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -38,7 +38,7 @@ export class WebhookTrigger { timeout: 0, }, ) { - if (url === undefined) { + if (!url) { throw new Error('Webhook trigger URL is required'); } From d6677bfcd40a91134a791e5396dd1862d431dfc2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 10:25:49 -0700 Subject: [PATCH 12/38] 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 817cfd0fb1efbca0eabdebadfaf5ac63793ee916 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 11:52:02 -0700 Subject: [PATCH 13/38] docs(webhook): drop retry-classification comment from send() Co-Authored-By: Claude --- packages/webhook/src/IncomingWebhook.ts | 3 --- packages/webhook/src/WebhookTrigger.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index d10097925..99072713f 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -77,9 +77,6 @@ export class IncomingWebhook { payload = Object.assign(payload, message); } - // Retries are limited to transient failures: rate limits (429), server - // errors (5xx), and network errors with no response. Client errors (other - // 4xx) abort immediately since they will not succeed on retry. return pRetry(async () => { try { const response = await this.axios.post(this.url, payload); diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index f82120050..2e9ce1360 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -67,9 +67,6 @@ export class WebhookTrigger { * @param payload - arbitrary key-value data to send to the trigger */ public async send(payload: WebhookTriggerSendArguments = {}): Promise { - // Retries are limited to transient failures: rate limits (429), server - // errors (5xx), and network errors with no response. Client errors (other - // 4xx) abort immediately since they will not succeed on retry. return pRetry(async () => { try { const response = await this.axios.post(this.url, payload); From 236d95411c2c406ec0b1a5833bdb88ea2d7aa537 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:06:20 -0700 Subject: [PATCH 14/38] 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 15/38] 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 16/38] 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 17/38] 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 18/38] 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 19/38] 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 20/38] 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 21/38] 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 22/38] 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 23/38] 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 345ff5ba46279e66ba05ed3916f02aa5747c18fb Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 3 Jul 2026 12:53:52 -0700 Subject: [PATCH 24/38] docs(webhook): add changeset for opt-in retries Co-Authored-By: Claude --- .changeset/webhook-opt-in-retries.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/webhook-opt-in-retries.md diff --git a/.changeset/webhook-opt-in-retries.md b/.changeset/webhook-opt-in-retries.md new file mode 100644 index 000000000..11d1db909 --- /dev/null +++ b/.changeset/webhook-opt-in-retries.md @@ -0,0 +1,5 @@ +--- +"@slack/webhook": minor +--- + +feat: add opt-in retries to `IncomingWebhook` and `WebhookTrigger` From 3d9a0af4e6c2c859dc55c1d05c689349d77727e5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 6 Jul 2026 15:30:22 -0700 Subject: [PATCH 25/38] fix(webhook): abort retries on all 4xx, including 429 The webhook clients have no request queue to honor a Retry-After header, so a 429 cannot respect the server's backoff on retry. Treat all 4xx responses uniformly and leave rate-limit handling to the caller; only 5xx responses are retried. Addresses review feedback on #2641. Co-Authored-By: William Bergamin <25348381+WilliamBergamin@users.noreply.github.com> Co-Authored-By: Claude --- packages/webhook/src/IncomingWebhook.test.ts | 16 ++++++++++++++++ packages/webhook/src/IncomingWebhook.ts | 3 +-- packages/webhook/src/WebhookTrigger.test.ts | 16 ++++++++++++++++ packages/webhook/src/WebhookTrigger.ts | 3 +-- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/webhook/src/IncomingWebhook.test.ts b/packages/webhook/src/IncomingWebhook.test.ts index c5a4e3452..96dfafa0f 100644 --- a/packages/webhook/src/IncomingWebhook.test.ts +++ b/packages/webhook/src/IncomingWebhook.test.ts @@ -165,6 +165,22 @@ describe('IncomingWebhook', () => { scope.done(); }); + it('does not retry a 429 even when a retry policy is set', async () => { + // Only one interceptor is registered; a retry would leave it unmatched + // and scope.done() would throw. + const scope = nock('https://hooks.slack.com') + .post(/services/) + .reply(429); + const webhook = new IncomingWebhook(url, { retryConfig: rapidRetryPolicy }); + try { + await webhook.send('hello'); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + scope.done(); + }); + it('does not retry by default (no retryConfig)', async () => { const scope = nock('https://hooks.slack.com') .post(/services/) diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 99072713f..e9160664a 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -86,9 +86,8 @@ export class IncomingWebhook { // Wrap errors in this packages own error types (abstract the implementation details' types) if (error.response !== undefined) { const status: number = error.response.status; - const retryable = status === 429 || status >= 500; const wrapped = httpErrorWithOriginal(error); - throw retryable ? wrapped : new AbortError(wrapped); + throw status >= 500 ? wrapped : new AbortError(wrapped); } if (error.request !== undefined) { // No response received (network/timeout): retryable. diff --git a/packages/webhook/src/WebhookTrigger.test.ts b/packages/webhook/src/WebhookTrigger.test.ts index d80db9625..78dc08e77 100644 --- a/packages/webhook/src/WebhookTrigger.test.ts +++ b/packages/webhook/src/WebhookTrigger.test.ts @@ -168,6 +168,22 @@ describe('WebhookTrigger', () => { scope.done(); }); + it('does not retry a 429 even when a retry policy is set', async () => { + const scope = nock('https://hooks.slack.com') + .post(/triggers/) + .reply(429); + const trigger = new WebhookTrigger(url, { retryConfig: rapidRetryPolicy }); + try { + await trigger.send({ key: 'value' }); + assert.fail('expected rejection'); + } catch (error) { + assert.strictEqual((error as CodedError).code, ErrorCode.HTTPError); + } + // Only one interceptor is registered; a retry would leave it unmatched + // and scope.done() would throw. + scope.done(); + }); + it('gives up with the HTTP error after exhausting retries', async () => { const scope = nock('https://hooks.slack.com') .post(/triggers/) diff --git a/packages/webhook/src/WebhookTrigger.ts b/packages/webhook/src/WebhookTrigger.ts index 04e4cdf5e..552bdb8b6 100644 --- a/packages/webhook/src/WebhookTrigger.ts +++ b/packages/webhook/src/WebhookTrigger.ts @@ -75,9 +75,8 @@ export class WebhookTrigger { } catch (error: any) { if (error.response !== undefined) { const status: number = error.response.status; - const retryable = status === 429 || status >= 500; const wrapped = httpErrorWithOriginal(error); - throw retryable ? wrapped : new AbortError(wrapped); + throw status >= 500 ? wrapped : new AbortError(wrapped); } if (error.request !== undefined) { // No response received (network/timeout): retryable. From a2868adf4c9e42fd6f1beaabc40e7a2457ca28ba Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 6 Jul 2026 23:47:45 -0700 Subject: [PATCH 26/38] chore: release webhook@7.2.0-rc.1 --- packages/webhook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 8d663ccc2..b780f5d11 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@slack/webhook", - "version": "7.1.0", + "version": "7.2.0-rc.1", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", From ee7f29f286d4002fea0e00ab9fe1730516554393 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 13:04:49 -0700 Subject: [PATCH 27/38] refactor(webhook): export retryPolicies as default namespace, pin p-retry Co-Authored-By: Claude --- packages/webhook/package.json | 2 +- packages/webhook/src/index.ts | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index b780f5d11..7f806be3a 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -45,7 +45,7 @@ "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", - "p-retry": "^4", + "p-retry": "^4.6.2", "retry": "^0.13.1" }, "devDependencies": { diff --git a/packages/webhook/src/index.ts b/packages/webhook/src/index.ts index fd6c1cfab..4a28e78bf 100644 --- a/packages/webhook/src/index.ts +++ b/packages/webhook/src/index.ts @@ -20,12 +20,7 @@ export { export { addAppMetadata } from './instrument'; -export { - fiveRetriesInFiveMinutes, - RetryOptions, - rapidRetryPolicy, - tenRetriesInAboutThirtyMinutes, -} from './retry-policies'; +export { default as retryPolicies, RetryOptions } from './retry-policies'; export { WebhookTrigger, From fce391b8312c1b8834a7262f5fd547bd8bfdd0c5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 13:10:59 -0700 Subject: [PATCH 28/38] chore: release webhook@7.2.0-rc.2 --- packages/webhook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 7f806be3a..6dcd999b2 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@slack/webhook", - "version": "7.2.0-rc.1", + "version": "7.2.0-rc.2", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", From dc3dc2d5295791beff239576ab8cfc44e90b736b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 7 Jul 2026 13:54:12 -0700 Subject: [PATCH 29/38] 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 30/38] 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 31/38] 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 32/38] 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 33/38] 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 34/38] 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 35/38] 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) From 24c4a3b80ab66b42e2e11a20bc8ee50389e76c3b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 8 Jul 2026 05:46:20 -0700 Subject: [PATCH 36/38] chore: release webhook@7.2.0-rc.3 --- packages/webhook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 6dcd999b2..60d2e066e 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@slack/webhook", - "version": "7.2.0-rc.2", + "version": "7.2.0-rc.3", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", From 3be520f2f09de1dfa5ee68e5a5106c6bd3fd06e7 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 8 Jul 2026 05:52:50 -0700 Subject: [PATCH 37/38] revert: stable version --- packages/webhook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 60d2e066e..6d8cce589 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -1,6 +1,6 @@ { "name": "@slack/webhook", - "version": "7.2.0-rc.3", + "version": "7.1.0", "description": "Official library for using the Slack Platform's Incoming Webhooks", "author": "Slack Technologies, LLC", "license": "MIT", From 338455c29fc6dc564abd2d6f41cf92a795cc492f Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 8 Jul 2026 05:56:03 -0700 Subject: [PATCH 38/38] docs(webhook): document opt-in retries in the README Co-Authored-By: Claude --- packages/webhook/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 35215c82f..c76cc1c5a 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -103,6 +103,23 @@ const trigger = new WebhookTrigger(url); --- +### Retry failed requests + +Both `IncomingWebhook` and `WebhookTrigger` can retry failed requests. Retries are **off by default**; pass a `retryConfig` to opt in. The package re-exports the same named policies as `@slack/web-api` on `retryPolicies`, or you can supply your own [`retry`](https://github.com/tim-kos/node-retry) options. + +```javascript +const { IncomingWebhook, retryPolicies } = require('@slack/webhook'); +const url = process.env.SLACK_WEBHOOK_URL; + +const webhook = new IncomingWebhook(url, { + retryConfig: retryPolicies.fiveRetriesInFiveMinutes, +}); +``` + +Only transient failures are retried: server errors (`5xx`) and network errors with no response. Client errors (`4xx`), including rate limits (`429`), fail immediately without a retry. + +--- + ### Proxy requests with a custom agent The webhook allows you to customize the HTTP