feat(webhooks): admin replay for failed/dead-letter deliveries

Outbound webhook deliveries already retry with backoff, dead-letter
after maxAttempts, and notify super admins. This adds operator-level
replay: a per-row button on the deliveries log spawns a fresh pending
delivery + queues a new BullMQ job. The original failed row stays
intact so the response body remains for audit; the replay payload
carries retried_from/retried_at markers so receivers can deduplicate.

Inbound idempotency was already handled via the documentEvents
signatureHash unique index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 19:31:34 +02:00
parent 7274baf1e1
commit 44db579988
3 changed files with 137 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { redeliverWebhookDelivery } from '@/lib/services/webhooks.service';
import { errorResponse, NotFoundError } from '@/lib/errors';
/**
* Admin replay for a previously failed/dead-letter webhook delivery.
* Spawns a fresh `pending` row + enqueues a new BullMQ job so the
* original delivery's failure response is preserved for audit while
* the replay flows through the standard worker (HMAC-signed, SSRF
* gated, dead-lettered after max retries).
*/
export const POST = withAuth(
withPermission('admin', 'manage_webhooks', async (_req, ctx, params) => {
try {
const { webhookId, deliveryId } = params;
if (!webhookId || !deliveryId) throw new NotFoundError('Delivery');
const result = await redeliverWebhookDelivery(ctx.portId, webhookId, deliveryId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,6 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -39,6 +41,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [retrying, setRetrying] = useState<string | null>(null);
async function retry(deliveryId: string) {
setRetrying(deliveryId);
try {
await apiFetch(`/api/v1/admin/webhooks/${webhookId}/deliveries/${deliveryId}/redeliver`, {
method: 'POST',
});
toast.success('Replay queued');
await load(page);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Replay failed');
} finally {
setRetrying(null);
}
}
async function load(p: number) {
setLoading(true);
@@ -80,6 +98,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
<TableHead>HTTP</TableHead>
<TableHead>Attempt</TableHead>
<TableHead>Time</TableHead>
<TableHead className="w-16 text-right">Replay</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -96,6 +115,22 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
? new Date(d.deliveredAt).toLocaleString()
: new Date(d.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-right">
{(d.status === 'failed' || d.status === 'dead_letter') && (
<Button
variant="ghost"
size="sm"
disabled={retrying === d.id}
onClick={() => retry(d.id)}
title="Replay this delivery"
aria-label="Replay this delivery"
>
<RotateCcw
className={`h-3.5 w-3.5 ${retrying === d.id ? 'animate-spin' : ''}`}
/>
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -269,6 +269,78 @@ export async function listDeliveries(
return { data, total };
}
// ─── Redeliver a previously failed / dead-letter delivery ───────────────────
/**
* Clones a failed or dead-letter delivery into a fresh `pending` row and
* re-enqueues it. Multi-tenant safe: looks up the source delivery via
* its webhook → port. Idempotency: each redeliver creates a new row so
* the original record (and its failure response body) is preserved for
* audit. The new delivery's payload includes a `retried_from` marker
* that downstream receivers can use to recognise replays.
*/
export async function redeliverWebhookDelivery(
portId: string,
webhookId: string,
deliveryId: string,
meta: AuditMeta,
) {
const webhook = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
});
if (!webhook || webhook.portId !== portId) {
throw new NotFoundError('Webhook');
}
if (!webhook.isActive) {
throw new NotFoundError('Webhook is inactive');
}
const [source] = await db
.select()
.from(webhookDeliveries)
.where(and(eq(webhookDeliveries.id, deliveryId), eq(webhookDeliveries.webhookId, webhookId)))
.limit(1);
if (!source) throw new NotFoundError('Delivery');
const replayPayload = {
...(source.payload as Record<string, unknown>),
retried_from: deliveryId,
retried_at: new Date().toISOString(),
};
const [next] = await db
.insert(webhookDeliveries)
.values({
webhookId,
eventType: source.eventType,
payload: replayPayload,
status: 'pending',
})
.returning();
const queue = getQueue('webhooks');
await queue.add('deliver', {
webhookId,
portId,
event: source.eventType,
deliveryId: next!.id,
payload: replayPayload,
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'send',
entityType: 'webhook_delivery',
entityId: next!.id,
metadata: { redeliveredFrom: deliveryId, originalStatus: source.status },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return { deliveryId: next!.id, status: 'queued' };
}
// ─── Send Test Webhook ────────────────────────────────────────────────────────
export async function sendTestWebhook(portId: string, webhookId: string, eventType: WebhookEvent) {