import { FetchAdapter, buildUrl, toServiceException } from '@agentuity/adapter'; import { StructuredError } from '@agentuity/adapter'; import type { CreateWebhookDestinationRequest, CreateWebhookRequest, UpdateWebhookRequest, Webhook, WebhookDelivery, WebhookDestination, WebhookOrgAnalytics, WebhookReceipt, } from './types.ts'; /** * Creates an {@link AbortSignal} that will abort after the specified timeout. * * @remarks * Falls back to a manual `AbortController` + `setTimeout` if `AbortSignal.timeout` * is not available in the runtime. * * @param ms - Timeout in milliseconds * @returns An abort signal that triggers after `ms` milliseconds * * @default 30000 */ function createTimeoutSignal(ms = 30_000): AbortSignal { if (typeof AbortSignal.timeout === 'function') { return AbortSignal.timeout(ms); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true }); return controller.signal; } /** * Parameters for creating a new webhook. */ export type CreateWebhookParams = CreateWebhookRequest; /** * Parameters for updating a webhook. */ export type UpdateWebhookParams = UpdateWebhookRequest; /** * Parameters for creating a new webhook destination. */ export type CreateWebhookDestinationParams = CreateWebhookDestinationRequest; /** * Paginated list of webhooks. */ export type WebhookListResult = { /** Array of webhooks. */ webhooks: Webhook[]; /** Total number of webhooks. */ total: number; }; /** * Result of fetching a single webhook, including its configured destinations. */ export type WebhookGetResult = { /** The requested webhook. */ webhook: Webhook; /** Array of destinations configured for this webhook. */ destinations: WebhookDestination[]; }; /** * Result of creating a webhook. */ export type WebhookCreateResult = { /** The newly created webhook. */ webhook: Webhook; }; /** * Result of updating a webhook. */ export type UpdateWebhookResult = { /** The updated webhook. */ webhook: Webhook; }; /** * Result of creating a webhook destination. */ export type CreateDestinationResult = { /** The newly created destination. */ destination: WebhookDestination; }; /** * List of destinations for a webhook. */ export type ListDestinationsResult = { /** Array of destinations. */ destinations: WebhookDestination[]; }; /** * List of receipts (incoming requests) for a webhook. */ export type WebhookReceiptListResult = { /** Array of receipt records. */ receipts: WebhookReceipt[]; }; /** * List of delivery attempts for a webhook. */ export type WebhookDeliveryListResult = { /** Array of delivery records. */ deliveries: WebhookDelivery[]; }; /** * Internal API success response envelope for webhook operations. */ type WebhookSuccessResponse = { success: true; data: T; }; /** * Internal API error response envelope for webhook operations. */ type WebhookErrorResponse = { success: false; message: string; }; /** * Discriminated union of API success and error responses for webhook operations. */ type WebhookResponse = WebhookSuccessResponse | WebhookErrorResponse; /** * Thrown when the API returns a success HTTP status but the response body indicates failure. */ const WebhookResponseError = StructuredError('WebhookResponseError')<{ status: number; }>(); /** * Client for the Agentuity Webhook service. * * Provides methods for creating and managing webhook endpoints that can receive * HTTP requests and forward them to configured destinations. The service supports: * * - **Webhooks**: Named endpoints with unique ingest URLs * - **Destinations**: URL-based targets that receive forwarded payloads * - **Receipts**: Records of incoming HTTP requests for auditing * - **Deliveries**: Records of forwarding attempts with retry support * * When an HTTP request hits a webhook's ingest URL, it is recorded as a receipt * and then delivered to all configured destinations. Failed deliveries can be * retried via {@link WebhookService.retryDelivery}. * * All methods are instrumented with OpenTelemetry spans for observability. * * @example * ```typescript * const webhooks = new WebhookService(baseUrl, adapter); * * // Create a webhook * const { webhook } = await webhooks.create({ name: 'GitHub Events' }); * console.log('Ingest URL:', webhook.url); * * // Add a destination * await webhooks.createDestination(webhook.id, { * type: 'url', * config: { url: 'https://example.com/handle-github' }, * }); * * // Check delivery history * const { deliveries } = await webhooks.listDeliveries(webhook.id); * ``` */ export class WebhookService { #adapter: FetchAdapter; #baseUrl: string; /** * Creates a new WebhookService instance. * * @param baseUrl - The base URL of the webhook API * @param adapter - The HTTP fetch adapter used for making API requests */ constructor(baseUrl: string, adapter: FetchAdapter) { this.#adapter = adapter; this.#baseUrl = baseUrl; } /** * Unwrap a potentially nested response envelope. Some API responses wrap the data * in an additional `{ data: T }` layer; this method normalizes both shapes. * * @param raw - The raw response data to unwrap * @returns The unwrapped data of type `T` */ #unwrap(raw: unknown): T { if (raw !== null && typeof raw === 'object' && 'data' in raw) { return (raw as Record).data as T; } return raw as T; } /** * Create a new webhook endpoint. * * @param params - The webhook creation parameters including name and optional description * @returns The newly created webhook with its unique ingest URL * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { webhook } = await webhooks.create({ * name: 'Stripe Events', * description: 'Receives payment webhooks from Stripe', * }); * console.log('Ingest URL:', webhook.url); * ``` */ async create(params: CreateWebhookParams): Promise { const url = buildUrl(this.#baseUrl, '/webhook/create'); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'POST', signal, body: JSON.stringify(params), contentType: 'application/json', telemetry: { name: 'agentuity.webhook.create', attributes: { name: params.name, }, }, }); if (res.ok) { if (res.data.success) { return { webhook: res.data.data }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('POST', url, res.response); } /** * List all webhooks with optional pagination. * * @param params - Optional pagination parameters * @returns Paginated list of webhooks with total count * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { webhooks, total } = await webhooks.list({ limit: 10, offset: 0 }); * console.log(`Showing ${webhooks.length} of ${total} webhooks`); * for (const wh of webhooks) { * console.log(`${wh.name}: ${wh.url}`); * } * ``` */ async list(params?: { limit?: number; offset?: number }): Promise { const qs = new URLSearchParams(); if (params?.limit !== undefined) { qs.set('limit', String(params.limit)); } if (params?.offset !== undefined) { qs.set('offset', String(params.offset)); } const path = qs.toString() ? `/webhook/list?${qs.toString()}` : '/webhook/list'; const url = buildUrl(this.#baseUrl, path); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.list', attributes: { limit: String(params?.limit ?? ''), offset: String(params?.offset ?? ''), }, }, }); if (res.ok) { if (res.data.success) { const unwrapped = this.#unwrap( res.data.data ); if (Array.isArray(unwrapped)) { return { webhooks: unwrapped, total: unwrapped.length }; } const arr = Array.isArray(unwrapped.data) ? unwrapped.data : []; return { webhooks: arr, total: unwrapped.total ?? arr.length }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * Get a webhook by its ID, including its configured destinations. * * @param webhookId - The unique webhook identifier * @returns The webhook and its list of destinations * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { webhook, destinations } = await webhooks.get('wh_abc123'); * console.log(`${webhook.name} has ${destinations.length} destination(s)`); * ``` */ async get(webhookId: string): Promise { const url = buildUrl(this.#baseUrl, `/webhook/get/${encodeURIComponent(webhookId)}`); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.get', attributes: { webhookId, }, }, }); if (res.ok) { if (res.data.success) { const { destinations } = await this.listDestinations(webhookId); return { webhook: res.data.data, destinations }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * Update a webhook's name and/or description. * * @param webhookId - The unique webhook identifier * @param params - Fields to update; only provided fields are changed * @returns The updated webhook * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { webhook } = await webhooks.update('wh_abc123', { * name: 'GitHub Events (Production)', * description: 'Production webhook for GitHub push events', * }); * console.log('Updated:', webhook.name); * ``` */ async update(webhookId: string, params: UpdateWebhookParams): Promise { const url = buildUrl(this.#baseUrl, `/webhook/update/${encodeURIComponent(webhookId)}`); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'PUT', signal, body: JSON.stringify(params), contentType: 'application/json', telemetry: { name: 'agentuity.webhook.update', attributes: { webhookId, }, }, }); if (res.ok) { if (res.data.success) { return { webhook: res.data.data }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('PUT', url, res.response); } /** * Delete a webhook and all its associated destinations, receipts, and deliveries. * * @param webhookId - The unique webhook identifier * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * await webhooks.delete('wh_abc123'); * console.log('Webhook deleted'); * ``` */ async delete(webhookId: string): Promise { const url = buildUrl(this.#baseUrl, `/webhook/delete/${encodeURIComponent(webhookId)}`); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'DELETE', signal, telemetry: { name: 'agentuity.webhook.delete', attributes: { webhookId, }, }, }); if (res.ok) { if (res.data?.success !== false) { return; } throw new WebhookResponseError({ status: res.response.status, message: res.data?.message ?? 'Delete failed', }); } throw await toServiceException('DELETE', url, res.response); } /** * Create a new destination for a webhook. When the webhook receives a request, * the payload will be forwarded to this destination. * * @param webhookId - The ID of the parent webhook * @param params - Destination configuration including type and type-specific config * @returns The newly created destination * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { destination } = await webhooks.createDestination('wh_abc123', { * type: 'url', * config: { * url: 'https://example.com/webhook-handler', * headers: { 'X-Custom-Header': 'my-value' }, * }, * }); * console.log('Destination created:', destination.id); * ``` */ async createDestination( webhookId: string, params: CreateWebhookDestinationParams ): Promise { const url = buildUrl( this.#baseUrl, `/webhook/destination-create/${encodeURIComponent(webhookId)}` ); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'POST', signal, body: JSON.stringify(params), contentType: 'application/json', telemetry: { name: 'agentuity.webhook.createDestination', attributes: { webhookId, type: params.type, }, }, }); if (res.ok) { if (res.data.success) { return { destination: res.data.data }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('POST', url, res.response); } /** * List all destinations configured for a webhook. * * @param webhookId - The ID of the webhook * @returns List of destinations * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { destinations } = await webhooks.listDestinations('wh_abc123'); * for (const dest of destinations) { * console.log(`${dest.type}: ${JSON.stringify(dest.config)}`); * } * ``` */ async listDestinations(webhookId: string): Promise { const url = buildUrl( this.#baseUrl, `/webhook/destination-list/${encodeURIComponent(webhookId)}` ); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.listDestinations', attributes: { webhookId, }, }, }); if (res.ok) { if (res.data.success) { return { destinations: Array.isArray(res.data.data) ? res.data.data : [] }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * Delete a destination from a webhook. * * @param webhookId - The ID of the parent webhook * @param destinationId - The ID of the destination to delete * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * await webhooks.deleteDestination('wh_abc123', 'dest_xyz789'); * console.log('Destination deleted'); * ``` */ async deleteDestination(webhookId: string, destinationId: string): Promise { const url = buildUrl( this.#baseUrl, `/webhook/destination-delete/${encodeURIComponent(webhookId)}/${encodeURIComponent(destinationId)}` ); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'DELETE', signal, telemetry: { name: 'agentuity.webhook.deleteDestination', attributes: { webhookId, destinationId, }, }, }); if (res.ok) { if (res.data?.success !== false) { return; } throw new WebhookResponseError({ status: res.response.status, message: res.data?.message ?? 'Delete destination failed', }); } throw await toServiceException('DELETE', url, res.response); } /** * List receipts (records of incoming HTTP requests) for a webhook. * * @param webhookId - The ID of the webhook * @param params - Optional pagination parameters * @returns List of receipt records * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { receipts } = await webhooks.listReceipts('wh_abc123', { * limit: 50, * }); * for (const receipt of receipts) { * console.log(`${receipt.date}: ${JSON.stringify(receipt.headers)}`); * } * ``` */ async listReceipts( webhookId: string, params?: { limit?: number; offset?: number } ): Promise { const qs = new URLSearchParams(); if (params?.limit !== undefined) { qs.set('limit', String(params.limit)); } if (params?.offset !== undefined) { qs.set('offset', String(params.offset)); } const basePath = `/webhook/receipt-list/${encodeURIComponent(webhookId)}`; const path = qs.toString() ? `${basePath}?${qs.toString()}` : basePath; const url = buildUrl(this.#baseUrl, path); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.listReceipts', attributes: { webhookId, limit: String(params?.limit ?? ''), offset: String(params?.offset ?? ''), }, }, }); if (res.ok) { if (res.data.success) { return { receipts: Array.isArray(res.data.data) ? res.data.data : [] }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * Get a single receipt by its ID, including the full payload. * * @param webhookId - The ID of the webhook * @param receiptId - The unique receipt identifier * @returns The receipt record with headers and payload * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const receipt = await webhooks.getReceipt('wh_abc123', 'rcpt_def456'); * console.log('Payload:', receipt.payload); * console.log('Headers:', receipt.headers); * ``` */ async getReceipt(webhookId: string, receiptId: string): Promise { const url = buildUrl( this.#baseUrl, `/webhook/receipt-get/${encodeURIComponent(webhookId)}/${encodeURIComponent(receiptId)}` ); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.getReceipt', attributes: { webhookId, receiptId, }, }, }); if (res.ok) { if (res.data.success) { return res.data.data; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * List delivery attempts for a webhook with optional pagination. * * @param webhookId - The ID of the webhook * @param params - Optional pagination parameters * @returns List of delivery records showing forwarding status * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * const { deliveries } = await webhooks.listDeliveries('wh_abc123'); * for (const d of deliveries) { * console.log(`${d.status} → dest ${d.webhook_destination_id} (retries: ${d.retries})`); * } * ``` */ async listDeliveries( webhookId: string, params?: { limit?: number; offset?: number } ): Promise { const qs = new URLSearchParams(); if (params?.limit !== undefined) { qs.set('limit', String(params.limit)); } if (params?.offset !== undefined) { qs.set('offset', String(params.offset)); } const basePath = `/webhook/delivery-list/${encodeURIComponent(webhookId)}`; const path = qs.toString() ? `${basePath}?${qs.toString()}` : basePath; const url = buildUrl(this.#baseUrl, path); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.listDeliveries', attributes: { webhookId, limit: String(params?.limit ?? ''), offset: String(params?.offset ?? ''), }, }, }); if (res.ok) { if (res.data.success) { return { deliveries: Array.isArray(res.data.data) ? res.data.data : [] }; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * Retry a failed delivery attempt. Only deliveries with `'failed'` status can be retried. * * @param webhookId - The ID of the parent webhook * @param deliveryId - The ID of the failed delivery to retry * @throws {@link ServiceException} if the API request fails * * @example * ```typescript * // Find and retry failed deliveries * const { deliveries } = await webhooks.listDeliveries('wh_abc123'); * for (const d of deliveries) { * if (d.status === 'failed') { * await webhooks.retryDelivery('wh_abc123', d.id); * console.log('Retrying delivery:', d.id); * } * } * ``` */ async retryDelivery(webhookId: string, deliveryId: string): Promise { const url = buildUrl( this.#baseUrl, `/webhook/delivery-retry/${encodeURIComponent(webhookId)}/${encodeURIComponent(deliveryId)}` ); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke>(url, { method: 'POST', signal, telemetry: { name: 'agentuity.webhook.retryDelivery', attributes: { webhookId, deliveryId, }, }, }); if (res.ok) { if (res.data?.success !== false) { return; } throw new WebhookResponseError({ status: res.response.status, message: res.data?.message ?? 'Retry delivery failed', }); } throw await toServiceException('POST', url, res.response); } /** * Get org-level webhook analytics summary. * * Returns total received, delivered, and failed counts for all webhooks * in the org within the specified time period. * * @param options - Analytics options (start, end, granularity) * @returns Org-level webhook analytics with period and summary * @throws {@link ServiceException} if the API request fails */ async getOrgAnalytics(options?: { start?: string; end?: string; granularity?: string; }): Promise { const params = new URLSearchParams(); if (options?.start) params.set('start', options.start); if (options?.end) params.set('end', options.end); if (options?.granularity) params.set('granularity', options.granularity); const query = params.toString(); const path = query ? `/webhook/analytics/org?${query}` : '/webhook/analytics/org'; const url = buildUrl(this.#baseUrl, path); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke< WebhookResponse<{ analytics: { period: { start: string; end: string }; summary: { total_received: number; total_delivered: number; total_failed: number }; }; }> >(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.analytics.org' }, }); if (res.ok) { if (res.data.success) { const unwrapped = this.#unwrap<{ analytics: { period: { start: string; end: string }; summary: { total_received: number; total_delivered: number; total_failed: number; }; }; }>(res.data.data); return unwrapped.analytics; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } /** * Get org-level webhook time series data. * * Returns time-bucketed received, delivered, and failed counts for all webhooks * in the org within the specified time period. * * @param options - Analytics options (start, end, granularity) * @returns Webhook time series data with period and series array * @throws {@link ServiceException} if the API request fails */ async getOrgTimeSeries(options?: { start?: string; end?: string; granularity?: string; }): Promise<{ period: { start: string; end: string; granularity?: string }; series: Array<{ timestamp: string; received: number; delivered: number; failed: number }>; }> { const params = new URLSearchParams(); if (options?.start) params.set('start', options.start); if (options?.end) params.set('end', options.end); if (options?.granularity) params.set('granularity', options.granularity); const query = params.toString(); const path = query ? `/webhook/analytics/org/timeseries?${query}` : '/webhook/analytics/org/timeseries'; const url = buildUrl(this.#baseUrl, path); const signal = createTimeoutSignal(); const res = await this.#adapter.invoke< WebhookResponse<{ timeseries: { period: { start: string; end: string; granularity?: string }; series: Array<{ timestamp: string; received: number; delivered: number; failed: number; }>; }; }> >(url, { method: 'GET', signal, telemetry: { name: 'agentuity.webhook.analytics.org.timeseries' }, }); if (res.ok) { if (res.data.success) { const unwrapped = this.#unwrap<{ timeseries: { period: { start: string; end: string; granularity?: string }; series: Array<{ timestamp: string; received: number; delivered: number; failed: number; }>; }; }>(res.data.data); return unwrapped.timeseries; } throw new WebhookResponseError({ status: res.response.status, message: res.data.message }); } throw await toServiceException('GET', url, res.response); } }