Skip to content

Commit 6b8d0de

Browse files
gabrielmfernclaude
andauthored
feat(webhooks): add rotate-signing-secret command (#385)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 240709b commit 6b8d0de

8 files changed

Lines changed: 179 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,7 @@ Use `all` with `--events` to subscribe to every event.
437437
- `get`
438438
- `update`
439439
- `delete`
440+
- `rotate-signing-secret`
440441
- `listen`
441442
- `events` (`list`, `get`, `attempts`, `replay`)
442443

@@ -498,7 +499,7 @@ resend webhooks get wh_abc123
498499
resend webhooks get wh_abc123 --json
499500
```
500501

501-
The signing secret is not returned from `get`. To rotate secrets, delete the webhook and create a new one.
502+
The signing secret is not returned from `get`. Use `resend webhooks rotate-signing-secret <id>` to get a new one.
502503

503504
#### **`resend webhooks update`**
504505

@@ -536,6 +537,17 @@ resend webhooks delete wh_abc123 --yes
536537

537538
To pause delivery temporarily, prefer `resend webhooks update <id> --status disabled`.
538539

540+
#### **`resend webhooks rotate-signing-secret`**
541+
542+
Generates a new signing secret for the webhook and returns it. For 24 hours, payloads are signed with both the new and the previous secret, so either one verifies them. After that, only the new secret does. Like `create`, the `signing_secret` is shown once — save it immediately.
543+
544+
Omit the ID in a terminal to pick from a list.
545+
546+
```bash
547+
resend webhooks rotate-signing-secret wh_abc123
548+
resend webhooks rotate-signing-secret wh_abc123 --json
549+
```
550+
539551
#### **`resend webhooks listen`**
540552

541553
Built-in local development helper. It:

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"esbuild": "0.28.1",
4747
"esbuild-wasm": "0.28.0",
4848
"picocolors": "1.1.1",
49-
"resend": "6.26.0"
49+
"resend": "6.27.0"
5050
},
5151
"pkg": {
5252
"scripts": [

‎pnpm-lock.yaml‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎skills/resend-cli/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ Auth resolves: `RESEND_API_KEY` env > config file (`resend login --key`). Use `-
145145
| `segments` | create, get, list, update, delete, contacts |
146146
| `templates` | create, publish, duplicate, delete, list |
147147
| `topics` | create, update, delete, list |
148-
| `webhooks` | create, update, listen, delete, list, events (list, get, attempts, replay) |
148+
| `webhooks` | create, update, rotate-signing-secret, listen, delete, list, events (list, get, attempts, replay) |
149149
| `auth` | login, logout, switch, rename, remove |
150150
| `whoami` / `doctor` / `update` / `open` / `commands` | Utility commands |
151151

‎skills/resend-cli/references/webhooks.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Detailed flag specifications for `resend webhooks` commands.
3434

3535
**Argument:** `<id>` — Webhook ID
3636

37-
**Note:** `signing_secret` is NOT returned by get (only at creation).
37+
**Note:** `signing_secret` is NOT returned by get (only at creation or rotation).
3838

3939
---
4040

@@ -60,6 +60,19 @@ Detailed flag specifications for `resend webhooks` commands.
6060

6161
---
6262

63+
## webhooks rotate-signing-secret
64+
65+
**Argument:** `<id>` — Webhook ID
66+
67+
Generates a new signing secret. For 24 hours, payloads are signed with both the
68+
new and the previous secret, so either one verifies them. After that, only the
69+
new secret does.
70+
71+
Returns `{"object":"webhook","id":"<uuid>","signing_secret":"whsec_..."}` —
72+
**shown once only**, save immediately.
73+
74+
---
75+
6376
## webhooks events list
6477

6578
Lists the events Resend delivered to a webhook, most recent first.

‎src/commands/webhooks/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { webhookEventsCommand } from './events';
66
import { getWebhookCommand } from './get';
77
import { listWebhooksCommand } from './list';
88
import { listenWebhookCommand } from './listen';
9+
import { rotateWebhookSigningSecretCommand } from './rotate-signing-secret';
910
import { updateWebhookCommand } from './update';
1011

1112
export const webhooksCommand = new Command('webhooks')
@@ -27,6 +28,7 @@ Event categories (19 total):
2728
Signature verification (Svix):
2829
Each delivery includes headers: svix-id, svix-timestamp, svix-signature
2930
Verify payloads in your application using: resend.webhooks.verify({ payload, headers, webhookSecret })
31+
Rotate a secret with "resend webhooks rotate-signing-secret <id>"; the new one is shown once.
3032
3133
Delivery history:
3234
Inspect what Resend sent and what your endpoint returned with "resend webhooks events".`,
@@ -35,6 +37,7 @@ Delivery history:
3537
'resend webhooks create --endpoint https://app.example.com/hooks/resend --events all',
3638
'resend webhooks get wh_abc123',
3739
'resend webhooks update wh_abc123 --status disabled',
40+
'resend webhooks rotate-signing-secret wh_abc123',
3841
'resend webhooks delete wh_abc123 --yes',
3942
'resend webhooks events list wh_abc123',
4043
],
@@ -46,4 +49,5 @@ Delivery history:
4649
.addCommand(listenWebhookCommand)
4750
.addCommand(listWebhooksCommand, { isDefault: true })
4851
.addCommand(updateWebhookCommand)
52+
.addCommand(rotateWebhookSigningSecretCommand)
4953
.addCommand(deleteWebhookCommand);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Command } from '@commander-js/extra-typings';
2+
import { runCreate } from '../../lib/actions';
3+
import type { GlobalOpts } from '../../lib/client';
4+
import { buildHelpText } from '../../lib/help-text';
5+
import { pickId } from '../../lib/prompts';
6+
import { webhookPickerConfig } from './utils';
7+
8+
export const rotateWebhookSigningSecretCommand = new Command(
9+
'rotate-signing-secret',
10+
)
11+
.description('Generate a new signing secret for a webhook')
12+
.argument('[id]', 'Webhook UUID')
13+
.addHelpText(
14+
'after',
15+
buildHelpText({
16+
context: `Generates a new signing secret and returns it. For 24 hours, payloads are
17+
signed with both the new and the previous secret, so either one verifies them.
18+
After that, only the new secret does. Update your verification code within that window.
19+
20+
The signing_secret in the response is shown ONCE — save it immediately.`,
21+
output: ` {"object":"webhook","id":"<uuid>","signing_secret":"<whsec_...>"}`,
22+
errorCodes: ['auth_error', 'create_error'],
23+
examples: [
24+
'resend webhooks rotate-signing-secret 4dd369bc-aa82-4ff3-97de-514ae3000ee0',
25+
'resend webhooks rotate-signing-secret 4dd369bc-aa82-4ff3-97de-514ae3000ee0 --json',
26+
],
27+
}),
28+
)
29+
.action(async (idArg, _opts, cmd) => {
30+
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
31+
const id = await pickId(idArg, webhookPickerConfig, globalOpts);
32+
33+
await runCreate(
34+
{
35+
loading: 'Rotating webhook signing secret...',
36+
sdkCall: (resend) => resend.webhooks.rotateSigningSecret(id),
37+
onInteractive: (d) => {
38+
console.log(`Webhook signing secret rotated`);
39+
console.log(`ID: ${d.id}`);
40+
console.log(`Signing Secret: ${d.signing_secret}`);
41+
console.log(`Save the signing secret — it is only shown once.`);
42+
},
43+
},
44+
globalOpts,
45+
);
46+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
it,
7+
type MockInstance,
8+
vi,
9+
} from 'vitest';
10+
import { rotateWebhookSigningSecretCommand } from '../../../src/commands/webhooks/rotate-signing-secret';
11+
import {
12+
captureTestEnv,
13+
expectExit1,
14+
mockExitThrow,
15+
mockSdkError,
16+
setNonInteractive,
17+
setupOutputSpies,
18+
} from '../../helpers';
19+
20+
const WEBHOOK_ID = '4dd369bc-aa82-4ff3-97de-514ae3000ee0';
21+
22+
const mockRotateSigningSecret = vi.fn(async () => ({
23+
data: {
24+
object: 'webhook' as const,
25+
id: WEBHOOK_ID,
26+
signing_secret: 'whsec_new_secret',
27+
},
28+
error: null,
29+
}));
30+
31+
vi.mock('resend', () => ({
32+
Resend: class MockResend {
33+
constructor(public key: string) {}
34+
webhooks = { rotateSigningSecret: mockRotateSigningSecret };
35+
},
36+
}));
37+
38+
describe('webhooks rotate-signing-secret command', () => {
39+
const restoreEnv = captureTestEnv();
40+
let spies: ReturnType<typeof setupOutputSpies> | undefined;
41+
let errorSpy: MockInstance | undefined;
42+
let stderrSpy: MockInstance | undefined;
43+
let exitSpy: MockInstance | undefined;
44+
45+
beforeEach(() => {
46+
process.env.RESEND_API_KEY = 're_test_key';
47+
mockRotateSigningSecret.mockClear();
48+
});
49+
50+
afterEach(() => {
51+
restoreEnv();
52+
errorSpy?.mockRestore();
53+
stderrSpy?.mockRestore();
54+
exitSpy?.mockRestore();
55+
spies = undefined;
56+
errorSpy = undefined;
57+
stderrSpy = undefined;
58+
exitSpy = undefined;
59+
});
60+
61+
it('rotates the secret for the given webhook and outputs it as JSON when non-interactive', async () => {
62+
spies = setupOutputSpies();
63+
64+
await rotateWebhookSigningSecretCommand.parseAsync([WEBHOOK_ID], {
65+
from: 'user',
66+
});
67+
68+
expect(mockRotateSigningSecret).toHaveBeenCalledWith(WEBHOOK_ID);
69+
const parsed = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
70+
expect(parsed.object).toBe('webhook');
71+
expect(parsed.id).toBe(WEBHOOK_ID);
72+
expect(parsed.signing_secret).toBe('whsec_new_secret');
73+
});
74+
75+
it('errors with create_error when the SDK returns an error', async () => {
76+
setNonInteractive();
77+
mockRotateSigningSecret.mockResolvedValueOnce(
78+
mockSdkError('Webhook not found', 'not_found'),
79+
);
80+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
81+
stderrSpy = vi
82+
.spyOn(process.stderr, 'write')
83+
.mockImplementation(() => true);
84+
exitSpy = mockExitThrow();
85+
86+
await expectExit1(() =>
87+
rotateWebhookSigningSecretCommand.parseAsync([WEBHOOK_ID], {
88+
from: 'user',
89+
}),
90+
);
91+
92+
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
93+
expect(output).toContain('create_error');
94+
});
95+
});

0 commit comments

Comments
 (0)