Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7ce2c4a
feat(webhook): add WebhookTrigger class for Workflow Builder triggers
zimeg Jun 2, 2026
89c7af2
docs(webhook): update WebhookTrigger @see link to JSODC article
zimeg Jun 2, 2026
0aab45b
fix(webhook): constrain WebhookTriggerSendArguments values to string
zimeg Jun 2, 2026
2dcef0f
Merge remote-tracking branch 'origin/main' into eden/webhook-trigger
zimeg Jun 30, 2026
29ea38e
feat(webhook): default WebhookTrigger.send payload to empty object
zimeg Jul 3, 2026
d6677bf
fix(webhook): reject empty URL in WebhookTrigger constructor
zimeg Jul 3, 2026
236d954
test(webhook): cover 401 application failure, drop extra-field succes…
zimeg Jul 3, 2026
7d56341
feat(webhook): add WebhookTrigger-specific error types
zimeg Jul 3, 2026
cd50095
refactor(webhook): colocate each WebhookTrigger error alias with its …
zimeg Jul 3, 2026
2724cd7
test(webhook): group WebhookTrigger send() tests by success and failure
zimeg Jul 3, 2026
b8992d8
test(webhook): assert send() transmits the payload body
zimeg Jul 3, 2026
eab95b3
docs(webhook): add WebhookTrigger changeset and README usage example
zimeg Jul 3, 2026
ceba283
docs: readme
zimeg Jul 3, 2026
eace3ba
docs: readme
zimeg Jul 3, 2026
b02a78e
docs: readme
zimeg Jul 3, 2026
e3626f9
fix(webhook): derive WebhookTrigger result ok strictly from the body
zimeg Jul 3, 2026
7968168
Merge branch 'main' into eden/webhook-trigger
zimeg Jul 3, 2026
dc3dc2d
refactor(webhook): return the trigger response body directly from send()
zimeg Jul 7, 2026
3bed3dc
test(webhook): verify addAppMetadata expands WebhookTrigger User-Agent
zimeg Jul 7, 2026
c0f5c7e
test(webhook): drop explanatory comment from addAppMetadata UA test
zimeg Jul 7, 2026
5d1a1ed
refactor(webhook): inline the trigger response body, drop buildResult
zimeg Jul 7, 2026
e6604b6
refactor(webhook): tighten WebhookTriggerResult to { ok, error? }
zimeg Jul 7, 2026
1684fa8
docs(webhook): retitle README to Slack Webhooks
zimeg Jul 8, 2026
edd3ecb
docs(webhook): use singular Slack Webhook README title
zimeg Jul 8, 2026
5ddeb03
Merge remote-tracking branch 'origin/main' into eden/webhook-trigger
zimeg Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/webhook-trigger-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@slack/webhook": minor
---

feat: add `WebhookTrigger` class for Workflow Builder webhook triggers
27 changes: 24 additions & 3 deletions packages/webhook/README.md

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📚 note: I hoped to generalize this package README for now with added information but think we might revise this ongoing?

Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Slack Incoming 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)

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.
Expand Down Expand Up @@ -82,6 +82,27 @@ 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) which accepts an optional, flattened, stringified JSON payload sent to start a workflow.

```javascript
const { WebhookTrigger } = require('@slack/webhook');
const url = process.env.SLACK_WEBHOOK_TRIGGER_URL;

const trigger = new WebhookTrigger(url);

(async () => {
// Keys should match the variables your workflow expects
await trigger.send({
customer_name: 'Ada Lovelace',
order_id: '1024',
});
})();
```

---

### Proxy requests with a custom agent

The webhook allows you to customize the HTTP
Expand Down
4 changes: 2 additions & 2 deletions packages/webhook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 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/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",
"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"
},
"dependencies": {
"@slack/types": "^2.20.1",
Expand Down
158 changes: 158 additions & 0 deletions packages/webhook/src/WebhookTrigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
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';

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);
});

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()', () => {
let trigger: WebhookTrigger;
beforeEach(() => {
trigger = new WebhookTrigger(url);
});

describe('on success', () => {
it('should return results in a Promise', async () => {
const scope = nock('https://hooks.slack.com')
.post(/triggers/, (body) => {
assert.deepStrictEqual(body, { key: 'value' });
return true;
})
.reply(200, { ok: true });
const result = await trigger.send({ key: 'value' });
assert.deepStrictEqual(result, { ok: true });
scope.done();
});

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, {});
return true;
})
.reply(200, { ok: true });
const result = await trigger.send();
assert.strictEqual(result.ok, true);
scope.done();
});
});

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);
try {
await trigger.send({ key: 'value' });
assert.fail('expected rejection');
} catch (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);
}
});

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) {
const httpError = error as WebhookTriggerHTTPError;
assert.strictEqual(httpError.code, ErrorCode.HTTPError);
// biome-ignore lint/suspicious/noExplicitAny: reading the wrapped axios response body
const response = (httpError.original as any).response;
assert.strictEqual(response.status, 401);
assert.deepStrictEqual(response.data, { ok: false, error: 'invalid_auth' });
}
scope.done();
});
});

describe('User-Agent header', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 💯

should we also add a tests that covers using addAppMetadata to expand the user-agent value?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WilliamBergamin Thanks for calling this out! We're introducing a test in 3bed3dc for this.

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();
}
});

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({ name: 'my-tool', version: '1.2.3' });
const trigger = new WebhookTrigger(url);
await trigger.send({ key: 'value' });
} finally {
scope.done();
}
});
});
});
});
94 changes: 94 additions & 0 deletions packages/webhook/src/WebhookTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Agent } from 'node:http';

import axios, { type AxiosInstance } from 'axios';

import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors';
import { getUserAgent } from './instrument';

/**
* A client for Slack's Workflow Builder webhook triggers
* @see {@link https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack}
*/
export class WebhookTrigger {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paise for mirroring existing behaviors 💯

/**
* 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) {
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<WebhookTriggerResult> {
try {
const response = await this.axios.post(this.url, payload);
return response.data;
// 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;
}
}
}
Comment on lines +61 to +76

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️‍🗨️ note: Our return inlines the response values without more change to mirror implementations of the @slack/web-api package a bit more!


/*
* Exported types
*/

export interface WebhookTriggerDefaultArguments {
agent?: Agent;
timeout?: number;
}

export interface WebhookTriggerSendArguments {
[key: string]: string;
}

export interface WebhookTriggerResult {
ok: boolean;
error?: string;
}
Comment on lines +91 to +94

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪬 note: These values are more strict now too!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good 💯

If we need to add more fields in the future we can

3 changes: 3 additions & 0 deletions packages/webhook/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ export enum ErrorCode {
}

export type IncomingWebhookSendError = IncomingWebhookRequestError | IncomingWebhookHTTPError;
export type WebhookTriggerSendError = WebhookTriggerRequestError | WebhookTriggerHTTPError;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ note: The error handling is shared between webhook techniques but so we export these values under a similar namespace.


export interface IncomingWebhookRequestError extends CodedError {
code: ErrorCode.RequestError;
original: Error;
}
export type WebhookTriggerRequestError = IncomingWebhookRequestError;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safe move 🧠 I like it 💯


export interface IncomingWebhookHTTPError extends CodedError {
code: ErrorCode.HTTPError;
original: Error;
}
export type WebhookTriggerHTTPError = IncomingWebhookHTTPError;

/**
* Factory for producing a {@link CodedError} from a generic error
Expand Down
10 changes: 10 additions & 0 deletions packages/webhook/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export {
IncomingWebhookHTTPError,
IncomingWebhookRequestError,
IncomingWebhookSendError,
WebhookTriggerHTTPError,
WebhookTriggerRequestError,
WebhookTriggerSendError,
} from './errors';

export {
Expand All @@ -16,3 +19,10 @@ export {
} from './IncomingWebhook';

export { addAppMetadata } from './instrument';

export {
WebhookTrigger,
WebhookTriggerDefaultArguments,
WebhookTriggerResult,
WebhookTriggerSendArguments,
} from './WebhookTrigger';