Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
a0e3e1d
feat(webhook): add retry policies mirroring @slack/web-api
zimeg Jul 2, 2026
a0e845e
feat(webhook): add opt-in retries to WebhookTrigger
zimeg Jul 2, 2026
4f0f591
feat(webhook): add opt-in retries to IncomingWebhook
zimeg Jul 2, 2026
ef40e60
refactor(webhook): order retryConfig before timeout in default args
zimeg Jul 2, 2026
7c8e9ca
feat(webhook): make WebhookTrigger.send payload optional
zimeg Jul 2, 2026
29ea38e
feat(webhook): default WebhookTrigger.send payload to empty object
zimeg Jul 3, 2026
a4982fc
Merge remote-tracking branch 'origin/eden/webhook-trigger' into eden/…
zimeg Jul 3, 2026
74f0dcd
docs(webhook): revert send @param comment to original wording
zimeg Jul 3, 2026
700af6a
fix(webhook): reject empty URL in WebhookTrigger constructor
zimeg Jul 3, 2026
d6677bf
fix(webhook): reject empty URL in WebhookTrigger constructor
zimeg Jul 3, 2026
817cfd0
docs(webhook): drop retry-classification comment from send()
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
38a310a
Merge branch 'eden/webhook-trigger' into eden/webhook-retries
zimeg Jul 3, 2026
345ff5b
docs(webhook): add changeset for opt-in retries
zimeg Jul 3, 2026
3d9a0af
fix(webhook): abort retries on all 4xx, including 429
zimeg Jul 6, 2026
a2868ad
chore: release webhook@7.2.0-rc.1
zimeg Jul 7, 2026
ee7f29f
refactor(webhook): export retryPolicies as default namespace, pin p-r…
zimeg Jul 7, 2026
fce391b
chore: release webhook@7.2.0-rc.2
zimeg Jul 7, 2026
dc3dc2d
refactor(webhook): return the trigger response body directly from send()
zimeg Jul 7, 2026
4e0f4f5
Merge remote-tracking branch 'origin/eden/webhook-trigger' into eden/…
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
839751b
Merge remote-tracking branch 'origin/eden/webhook-trigger' into eden/…
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
692179f
Merge remote-tracking branch 'origin/eden/webhook-trigger' into eden/…
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
baea6ec
Merge remote-tracking branch 'origin/eden/webhook-trigger' into eden/…
zimeg Jul 8, 2026
24c4a3b
chore: release webhook@7.2.0-rc.3
zimeg Jul 8, 2026
3be520f
revert: stable version
zimeg Jul 8, 2026
338455c
docs(webhook): document opt-in retries in the README
zimeg Jul 8, 2026
35d7096
Merge remote-tracking branch 'origin/main' into eden/webhook-retries
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-opt-in-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@slack/webhook": minor
---

feat: add opt-in retries to `IncomingWebhook` and `WebhookTrigger`
5 changes: 4 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions packages/webhook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions packages/webhook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.6.2",
"retry": "^0.13.1"
},
"devDependencies": {
"nock": "^14.0.6"
Expand Down
74 changes: 74 additions & 0 deletions packages/webhook/src/IncomingWebhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -136,5 +137,78 @@ 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 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/)
.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<string, unknown>)));
scope.done();
});
});
});
});
42 changes: 29 additions & 13 deletions packages/webhook/src/IncomingWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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,
Expand All @@ -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;
Comment on lines +61 to +63

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 default options passed to requests isn't ideal practice IMHO but we avoid regressions with this line.

}

/**
Expand All @@ -67,20 +77,25 @@ 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);
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 wrapped = httpErrorWithOriginal(error);
throw status >= 500 ? wrapped : new AbortError(wrapped);

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 💯 thanks for simplifying this 🚀

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

/**
Expand All @@ -105,6 +120,7 @@ export interface IncomingWebhookDefaultArguments {
text?: string;
link_names?: boolean;
agent?: Agent;
retryConfig?: RetryOptions;
timeout?: number;
}

Expand Down
79 changes: 79 additions & 0 deletions packages/webhook/src/WebhookTrigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import nock from 'nock';
import type { CodedError, WebhookTriggerHTTPError } from './errors';
import { ErrorCode } from './errors';
import { addAppMetadata } from './instrument';
import { rapidRetryPolicy } from './retry-policies';
import { WebhookTrigger } from './WebhookTrigger';

const url = 'https://hooks.slack.com/triggers/FAKETRIGGER';
Expand Down Expand Up @@ -154,5 +155,83 @@ 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();
Comment on lines +172 to +185

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.

Praise 💯

});

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