Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/webhook-dispatch-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dsar": minor
---

Add outbound webhook dispatch recovery: `GET /webhooks/dispatches` listing with filters, single and bulk replay endpoints with idempotent audit-logged replays, and `dsar webhooks list`, `dsar webhooks replay`, `dsar webhooks replay-all`, and `dsar webhooks tail` CLI commands.
2 changes: 1 addition & 1 deletion docs/reference/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ These pages describe the DSAR HTTP surface exposed by `@dsar/backend`.
- [Init API](./init.md) for `POST /init`
- [Status API](./status.md) for `GET /status`
- [Policies API](./policies.md) for policy discovery and upgrade lifecycle
- [Webhooks API](./webhooks.md) for inbound Resend and Slack entrypoints
- [Webhooks API](./webhooks.md) for inbound Resend and Slack entrypoints plus outbound dispatch inspection/replay

## Request Lifecycle

Expand Down
141 changes: 138 additions & 3 deletions docs/reference/api/webhooks.mdx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
title: "Webhooks API"
description: "These public endpoints accept inbound provider events and map them into DSAR intake flows."
description: "Webhook endpoints accept public inbound provider events and expose protected outbound dispatch management."
group: reference-api
---

<include src="../../_shared/alpha-warning.mdx" />

These public endpoints accept inbound provider events and map them into DSAR
intake flows.
Webhook endpoints accept public inbound provider events and map them into DSAR
intake flows. Protected outbound endpoints let operator and service principals
inspect delivery attempts and replay failed webhook dispatches.

## POST /webhooks/inbound/resend

Expand Down Expand Up @@ -86,3 +87,137 @@ Related guides:

- [Inbound Resend](../../integrations/integrations/inbound-resend.md)
- [Inbound Slack](../../integrations/integrations/inbound-slack.md)

## GET /webhooks/dispatches

List outbound webhook delivery attempts recorded by the notification delivery
ledger.

This endpoint is protected and reserved for operator or service principals.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Supported query parameters:

- `status` (string, optional): comma-separated delivery statuses, such as
`failed` or `failed,pending`
- `endpoint_id` (string, optional): configured outbound webhook endpoint id
- `created_after` (string, optional): only include attempts created after this
timestamp
- `created_before` (string, optional): only include attempts created before this
timestamp
- `limit` (number, optional): page size, from 1 to 500
- `offset` (number, optional): zero-based offset

**Response (200):**

```json
{
"items": [
{
"attempt": 3,
"createdAt": "2026-01-01T00:00:00.000Z",
"destination": "https://customer.example/webhooks/dsar",
"dispatchId": "dispatch-123",
"endpointId": "default",
"error": "500 Internal Server Error",
"eventId": "event-123",
"eventType": "request_fulfilled",
"replayable": true,
"requestId": "req-123",
"responseCode": 500,
"status": "failed"
}
],
"limit": 50,
"offset": 0,
"total": 1
}
```

## POST /webhooks/dispatches/{id}/replay

Replay one failed outbound webhook dispatch.

This endpoint is protected and reserved for operator or service principals. The
dispatch must be a failed webhook delivery attempt. Successful, pending,
skipped, or non-webhook attempts are not replayable.

The `x-idempotency-key` header is required. When the same dispatch id and
idempotency key have already been accepted, the endpoint returns
`already_replayed` without sending the webhook again.

Required headers:

- `x-idempotency-key` (string, required): caller-provided replay dedupe key

**Response (202):**

```json
{
"dispatchId": "dispatch-123",
"eventId": "event-123",
"status": "replayed"
}
```

## POST /webhooks/dispatches/replay

Replay a batch of failed outbound webhook dispatches.

This endpoint is protected and reserved for operator or service principals. Only
failed webhook delivery attempts are replayable; the endpoint always selects
`channel="webhook"` and `status="failed"` attempts in the caller's tenant.

The `x-idempotency-key` header is required. The server derives a per-dispatch
idempotency key from the caller key and dispatch id, so retrying the same bulk
request does not re-send dispatches that were already accepted.

Bulk replay is capped at 100 dispatches per request. Use filters to narrow large
recovery batches.

Required headers:

- `x-idempotency-key` (string, required): caller-provided bulk replay dedupe key

**Request body:**

- `status` (string, optional): must be `"failed"` when present
- `endpoint_id` (string, optional): configured outbound webhook endpoint id
- `created_after` (string, optional): only include attempts created after this
timestamp
- `created_before` (string, optional): only include attempts created before this
timestamp
- `limit` (number, optional): maximum dispatches to replay, from 1 to 100

An empty body replays failed webhook dispatches for the tenant up to the cap.

**Response (202):**

```json
{
"alreadyReplayed": 1,
"replayed": 2,
"results": [
{
"dispatchId": "dispatch-123",
"eventId": "event-123",
"status": "replayed"
},
{
"dispatchId": "dispatch-456",
"eventId": "event-456",
"status": "already_replayed"
},
{
"dispatchId": "dispatch-789",
"error": "Webhook dispatch cannot be replayed because no webhook endpoint is configured.",
"eventId": "event-789",
"status": "failed"
}
],
"total": 3
}
```

Per-dispatch delivery failures are reported in `results` and do not fail the
whole bulk request. Delivery attempts and replay audit events are recorded per
dispatch.
16 changes: 16 additions & 0 deletions docs/reference/developer/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Global flags:
- `policies list` -> `GET /policies`
- `policies custom register|activate|deactivate` -> custom policy endpoints
- `webhooks inbound resend` -> `POST /webhooks/inbound/resend`
- `webhooks list|replay|replay-all|tail ...` -> outbound webhook dispatch inspection, replay, and polling
- `requests create|capture` -> `POST /requests`, `POST /requests/capture`
- `requests clock explain <id>` -> `GET /requests/{id}/clock/explain`
- `requests verification ...` -> verification endpoints
Expand Down Expand Up @@ -83,6 +84,21 @@ Notification replay is part of the current CLI surface:
- `dsar requests notifications list <request-id>`
- `dsar requests notifications replay <request-id> <event-id>`

Outbound webhook dispatch replay is available through:

- `dsar webhooks list --status failed`
- `dsar webhooks replay <dispatch-id> --idempotency-key replay-1`
- `dsar webhooks replay-all --status failed --endpoint-id default --limit 100 --idempotency-key replay-all-1`
- `dsar webhooks tail --status failed --endpoint-id default --interval 2000`

`replay` targets one failed webhook dispatch. `replay-all` maps to
`POST /webhooks/dispatches/replay` and builds the bulk replay body from
`--status`, `--endpoint-id`, `--created-after`, `--created-before`, and
`--limit`. `--idempotency-key` is required for both replay commands.

`tail` polls `GET /webhooks/dispatches` and streams newly observed dispatches.
It accepts `--status`, `--endpoint-id`, `--interval`, `--limit`, and `--once`.

## Parity policy

Any new backend OpenAPI path + method pair must include:
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/adapters/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ export interface NotificationDispatchResult {
readonly error?: string;
}

/**
* Notification delivery channels that a notification adapter can handle.
*/
export type NotificationAdapterChannel = "email" | "webhook";

/**
* Adapter contract for notification delivery channels (email, webhook, etc.).
*/
Expand All @@ -145,6 +150,8 @@ export interface NotificationAdapterContract extends AdapterContractBase<
> {
/** Adapter capability handled by this contract entry. */
readonly capability: "notifications";
/** Explicit channels handled by this adapter. Omit only for legacy adapters. */
readonly channels?: readonly NotificationAdapterChannel[];
/** Sends a notification message through the adapter provider. */
readonly send: (
input: NotificationDispatchInput
Expand Down
4 changes: 3 additions & 1 deletion packages/backend/src/audit/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const toCauseDetails = (error: unknown): Readonly<Record<string, string>> => {
* audit export and hash-chain verification remain deterministic.
*/
export interface AppendAuditInput {
/** Optional deterministic identifier for idempotent audit markers. */
readonly id?: string;
/**
* Request identifier for request-scoped events.
*
Expand Down Expand Up @@ -145,7 +147,7 @@ export const appendAuditEvent = (
sequence,
});

const id = makeRequestId();
const id = input.id ?? makeRequestId();
yield* services.repos.persistence.auditEvents
.append({
action: input.action,
Expand Down
98 changes: 98 additions & 0 deletions packages/backend/src/http-api/groups/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,56 @@ const WebhookRotateKeyResponseSchema = Schema.Struct({
previousKeyId: Schema.optional(Schema.String),
});

const WebhookDispatchSchema = Schema.Struct({
attempt: Schema.Number,
createdAt: Schema.String,
destination: Schema.String,
dispatchId: Schema.String,
endpointId: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
eventId: Schema.String,
eventType: Schema.optional(Schema.String),
replayable: Schema.Boolean,
requestId: Schema.String,
responseCode: Schema.optional(Schema.Number),
status: Schema.Literals(["pending", "delivered", "failed", "skipped"]),
});

const WebhookDispatchListResponseSchema = Schema.Struct({
items: Schema.Array(WebhookDispatchSchema),
limit: Schema.Number,
offset: Schema.Number,
total: Schema.Number,
});

const WebhookDispatchReplayResponseSchema = Schema.Struct({
dispatchId: Schema.String,
eventId: Schema.String,
status: Schema.Literals(["replayed", "already_replayed"]),
});

const WebhookDispatchBulkReplayPayloadSchema = Schema.Struct({
created_after: Schema.optional(Schema.String),
created_before: Schema.optional(Schema.String),
endpoint_id: Schema.optional(Schema.String),
limit: Schema.optional(Schema.Number),
status: Schema.optional(Schema.Literal("failed")),
});

const WebhookDispatchBulkReplayResultSchema = Schema.Struct({
dispatchId: Schema.String,
error: Schema.optional(Schema.String),
eventId: Schema.String,
status: Schema.Literals(["replayed", "already_replayed", "failed"]),
});

const WebhookDispatchBulkReplayResponseSchema = Schema.Struct({
alreadyReplayed: Schema.Number,
replayed: Schema.Number,
results: Schema.Array(WebhookDispatchBulkReplayResultSchema),
total: Schema.Number,
});

/** OpenAPI group describing public inbound webhook endpoints. */
export const webhooksGroup = HttpApiGroup.make("webhooks", { topLevel: true })
.add(
Expand Down Expand Up @@ -81,4 +131,52 @@ export const webhooksGroup = HttpApiGroup.make("webhooks", { topLevel: true })
),
"Rotate webhook endpoint signing key"
)
)
.add(
protectedOperation(
HttpApiEndpoint.get("webhooks_dispatches_list", "/webhooks/dispatches", {
query: {
created_after: Schema.optional(Schema.String),
created_before: Schema.optional(Schema.String),
endpoint_id: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
offset: Schema.optional(Schema.NumberFromString),
status: Schema.optional(Schema.String),
},
success: successEnvelope(WebhookDispatchListResponseSchema),
}),
"List outbound webhook dispatches"
)
)
.add(
protectedOperation(
HttpApiEndpoint.post(
"webhooks_dispatches_replay_bulk",
"/webhooks/dispatches/replay",
{
headers: { "x-idempotency-key": Schema.String },
payload: WebhookDispatchBulkReplayPayloadSchema,
success: successEnvelope(
WebhookDispatchBulkReplayResponseSchema
).pipe(s202),
}
),
"Replay failed outbound webhook dispatches"
)
)
.add(
protectedOperation(
HttpApiEndpoint.post(
"webhooks_dispatches_replay",
"/webhooks/dispatches/:id/replay",
{
headers: { "x-idempotency-key": Schema.String },
params: { id: Schema.String },
success: successEnvelope(WebhookDispatchReplayResponseSchema).pipe(
s202
),
}
),
"Replay outbound webhook dispatch"
)
);
8 changes: 8 additions & 0 deletions packages/backend/src/routes/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { RouteDefinition } from "./types";
import {
bulkReplayWebhookDispatchesRoute,
listWebhookDispatchesRoute,
replayWebhookDispatchRoute,
} from "./webhooks/dispatches";
import { resendWebhookRoute } from "./webhooks/resend";
import { rotateWebhookKeyRoute } from "./webhooks/rotate-key";
import { slackWebhookRoute } from "./webhooks/slack";
Expand All @@ -11,4 +16,7 @@ export const webhookRoutes: readonly RouteDefinition[] = [
resendWebhookRoute,
slackWebhookRoute,
rotateWebhookKeyRoute,
listWebhookDispatchesRoute,
bulkReplayWebhookDispatchesRoute,
replayWebhookDispatchRoute,
];
Loading
Loading