Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ Use `all` with `--events` to subscribe to every event.
- `update`
- `delete`
- `listen`
- `events` (`list`, `get`, `attempts`)
- `events` (`list`, `get`, `attempts`, `replay`)

#### Aliases

Expand Down Expand Up @@ -571,6 +571,7 @@ Inspects what Resend already delivered to a webhook. Use it to debug an endpoint
1. `resend webhooks events list <webhook-id>` — find the event
2. `resend webhooks events get <webhook-id> <event-id>` — see the payload we sent
3. `resend webhooks events attempts <webhook-id> <event-id>` — see what your endpoint returned
4. `resend webhooks events replay <webhook-id> <event-id>` — queue another delivery
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

Running `resend webhooks events <webhook-id>` with no subcommand runs `list`.

Expand All @@ -581,13 +582,14 @@ Both list subcommands paginate forward only — there is no `--before`.
| `--limit <n>` | Max results to return (`1`–`100`, default `10`) |
| `--after <cursor>` | Return results after this ID (next page) |

`events list` reports each event's `type`, `status` (`pending`, `attempting`, `success`, `failed`), and `created_at`. `events get` adds `next_attempt_at` and the payload that was sent. `events attempts` reports the `http_status_code` and response body your endpoint returned for each try.
`events list` reports each event's `type`, `status` (`pending`, `attempting`, `success`, `failed`), and `created_at`. `events get` adds `next_attempt_at` and the payload that was sent. `events attempts` reports the `http_status_code` and response body your endpoint returned for each try. `events replay` queues one more delivery attempt and returns `{"object":"webhook_event","id":"<event-id>"}`; it does not schedule automatic retries.

```bash
resend webhooks events list wh_abc123
resend webhooks events list wh_abc123 --limit 50 --json
resend webhooks events get wh_abc123 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2
resend webhooks events attempts wh_abc123 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2
resend webhooks events replay wh_abc123 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2
```

---
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "resend-cli",
"version": "2.18.0",
"version": "2.19.0",
"description": "The official CLI for Resend",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -46,7 +46,7 @@
"esbuild": "0.28.1",
"esbuild-wasm": "0.28.0",
"picocolors": "1.1.1",
"resend": "6.25.0"
"resend": "6.26.0"
Comment thread
gabrielmfern marked this conversation as resolved.
},
"pkg": {
"scripts": [
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

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

4 changes: 2 additions & 2 deletions skills/resend-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ metadata:
author: resend
# Skill version is independent from the CLI/package.json version —
# bump it on skill content changes, not CLI releases.
version: "2.10.0"
version: "2.11.0"
homepage: https://resend.com/docs/cli-agents
source: https://github.com/resend/resend-cli
openclaw:
Expand Down Expand Up @@ -145,7 +145,7 @@ Auth resolves: `RESEND_API_KEY` env > config file (`resend login --key`). Use `-
| `segments` | create, get, list, update, delete, contacts |
| `templates` | create, publish, duplicate, delete, list |
| `topics` | create, update, delete, list |
| `webhooks` | create, update, listen, delete, list, events (list, get, attempts) |
| `webhooks` | create, update, listen, delete, list, events (list, get, attempts, replay) |
| `auth` | login, logout, switch, rename, remove |
| `whoami` / `doctor` / `update` / `open` / `commands` | Utility commands |

Expand Down
15 changes: 15 additions & 0 deletions skills/resend-cli/references/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ Lists the delivery attempts for one event, most recent first. Each attempt recor
1. `resend webhooks events list <webhook-id>` — find the event
2. `resend webhooks events get <webhook-id> <event-id>` — see what we sent
3. `resend webhooks events attempts <webhook-id> <event-id>` — see what your endpoint returned
4. `resend webhooks events replay <webhook-id> <event-id>` — queue another delivery

---

## webhooks events replay

Queues one more delivery of the event. Does not schedule automatic retries.

**Arguments:** `[webhookId]` `[eventId]`

The webhook must be enabled — re-enable it first with
`resend webhooks update <webhook-id> --status enabled`.

Returns `{"object":"webhook_event","id":"<event-id>"}` — no `type`, `status`,
or `payload` (use `events get` for those).

---

Expand Down
8 changes: 6 additions & 2 deletions src/commands/webhooks/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,30 @@ import { buildHelpText } from '../../../lib/help-text';
import { listWebhookEventAttemptsCommand } from './attempts';
import { getWebhookEventCommand } from './get';
import { listWebhookEventsCommand } from './list';
import { replayWebhookEventCommand } from './replay';

export const webhookEventsCommand = new Command('events')
.description('Inspect the events Resend delivered to a webhook')
.description('Inspect or replay the events Resend delivered to a webhook')
.addHelpText(
'after',
buildHelpText({
context: `Debugging a webhook that is missing deliveries:
1. resend webhooks events list <webhook-id> (find the event)
2. resend webhooks events get <webhook-id> <event-id> (see the payload we sent)
3. resend webhooks events attempts <webhook-id> <event-id> (see what your endpoint returned)
4. resend webhooks events replay <webhook-id> <event-id> (queue another delivery)

Events are listed most recent first. Both list commands paginate forward only,
with --after.`,
examples: [
'resend webhooks events list 4dd369bc-aa82-4ff3-97de-514ae3000ee0',
'resend webhooks events get 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2',
'resend webhooks events attempts 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2',
'resend webhooks events replay 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2',
],
}),
)
.addCommand(listWebhookEventsCommand, { isDefault: true })
.addCommand(getWebhookEventCommand)
.addCommand(listWebhookEventAttemptsCommand);
.addCommand(listWebhookEventAttemptsCommand)
.addCommand(replayWebhookEventCommand);
49 changes: 49 additions & 0 deletions src/commands/webhooks/events/replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Command } from '@commander-js/extra-typings';
import { runWrite } from '../../../lib/actions';
import type { GlobalOpts } from '../../../lib/client';
import { buildHelpText } from '../../../lib/help-text';
import { pickId } from '../../../lib/prompts';
import { webhookPickerConfig } from '../utils';
import { webhookEventPickerConfig } from './utils';

export const replayWebhookEventCommand = new Command('replay')
.description('Queue another delivery attempt for a webhook event')
.argument('[webhookId]', 'Webhook ID')
.argument('[eventId]', 'Webhook event ID')
.addHelpText(
'after',
buildHelpText({
context: `Queues one more delivery of the event. Does not schedule automatic retries.
The webhook must be enabled — re-enable it first with: resend webhooks update <webhook-id> --status enabled`,
output: ` {"object":"webhook_event","id":"msg_..."}`,
errorCodes: ['auth_error', 'replay_error'],
examples: [
'resend webhooks events replay 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2',
'resend webhooks events replay 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2 --json',
],
}),
)
.action(async (webhookIdArg, eventIdArg, _opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
const webhookId = await pickId(
webhookIdArg,
webhookPickerConfig,
globalOpts,
);
const eventId = await pickId(
eventIdArg,
webhookEventPickerConfig(webhookId),
globalOpts,
);

await runWrite(
{
loading: 'Replaying webhook event...',
sdkCall: (resend) =>
resend.webhooks.events.replay({ webhookId, eventId }),
errorCode: 'replay_error',
successMsg: `Webhook event replay queued: ${eventId}`,
},
globalOpts,
);
});
107 changes: 107 additions & 0 deletions tests/commands/webhooks/events/replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import { replayWebhookEventCommand } from '../../../../src/commands/webhooks/events/replay';
import {
captureTestEnv,
expectExit1,
mockExitThrow,
mockSdkError,
setNonInteractive,
setupOutputSpies,
} from '../../../helpers';

const WEBHOOK_ID = '4dd369bc-aa82-4ff3-97de-514ae3000ee0';
const EVENT_ID = 'msg_1srOrx2ZWZBpBUvZwXKQmoEYga2';

const mockReplay = vi.fn(async () => ({
data: {
object: 'webhook_event' as const,
id: EVENT_ID,
},
error: null,
}));

vi.mock('resend', () => ({
Resend: class MockResend {
constructor(public key: string) {}
webhooks = { events: { replay: mockReplay } };
},
}));

describe('webhooks events replay command', () => {
const restoreEnv = captureTestEnv();
let spies: ReturnType<typeof setupOutputSpies> | undefined;
let errorSpy: MockInstance | undefined;
let stderrSpy: MockInstance | undefined;
let exitSpy: MockInstance | undefined;

beforeEach(() => {
process.env.RESEND_API_KEY = 're_test_key';
mockReplay.mockClear();
});

afterEach(() => {
restoreEnv();
errorSpy?.mockRestore();
stderrSpy?.mockRestore();
exitSpy?.mockRestore();
spies = undefined;
errorSpy = undefined;
stderrSpy = undefined;
exitSpy = undefined;
});

it('maps the two positional args to webhookId and eventId in that order', async () => {
spies = setupOutputSpies();

await replayWebhookEventCommand.parseAsync([WEBHOOK_ID, EVENT_ID], {
from: 'user',
});

expect(mockReplay).toHaveBeenCalledTimes(1);
const opts = mockReplay.mock.calls[0][0] as Record<string, unknown>;
expect(opts.webhookId).toBe(WEBHOOK_ID);
expect(opts.eventId).toBe(EVENT_ID);
});

it('outputs the replayed event as JSON when non-interactive', async () => {
spies = setupOutputSpies();

await replayWebhookEventCommand.parseAsync([WEBHOOK_ID, EVENT_ID], {
from: 'user',
});

const output = spies.logSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.object).toBe('webhook_event');
expect(parsed.id).toBe(EVENT_ID);
});

it('errors with replay_error when the SDK returns an error', async () => {
setNonInteractive();
mockReplay.mockResolvedValueOnce(
mockSdkError('Webhook is disabled', 'validation_error'),
);
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
exitSpy = mockExitThrow();

await expectExit1(() =>
replayWebhookEventCommand.parseAsync([WEBHOOK_ID, EVENT_ID], {
from: 'user',
}),
);

const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('replay_error');
});
});