Webhook Tester
blockdevopsSvix and Stripe style live webhook delivery inspector and event simulator with signature generation, payload editor, and request/response inspection.
Installation
$pnpm dlx uipkge-ng@latest add webhook-tester$npx uipkge-ng@latest add webhook-tester$yarn dlx uipkge-ng@latest add webhook-tester$bunx uipkge-ng@latest add webhook-testerManifest:
https://uipkge.dev/r/angular/webhook-tester.jsonInstalls to:src/app/components/blocks/webhook-tester/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
WebhookEventPresetinterface WebhookEventPreset {
id: string
name: string
category: string
description: string
defaultPayload: object
}DeliveryAttemptinterface DeliveryAttempt {
id: string
eventId: string
eventType: string
status: number
statusText: string
timestamp: string
timeAgo: string
latencyMs: number
endpointUrl: string
signature: string
requestHeaders: Record<string, string>
requestBody: string
responseHeaders: Record<string, string>
responseBody: string
timings: {
dns: number
tls: number
ttfb: number
download: number
total: number
}
}Files installed (2)
src/app/components/blocks/webhook-tester/webhook-tester.component.ts47.7 kB
// Svix and Stripe style live webhook delivery inspector and event simulator with // signature generation, payload editor, and request/response inspection. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, computed, signal } from '@angular/core' import { Activity, ArrowRight, Check, CheckCircle2, Clock, Copy, FileCode, Globe, Key, Layers, LucideAngularModule, RefreshCw, RotateCw, Send, ShieldCheck, Zap, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, } from '@/ui/tabs/tabs.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export interface WebhookEventPreset { id: string name: string category: string description: string defaultPayload: object } export interface DeliveryAttempt { id: string eventId: string eventType: string status: number statusText: string timestamp: string timeAgo: string latencyMs: number endpointUrl: string signature: string requestHeaders: Record<string, string> requestBody: string responseHeaders: Record<string, string> responseBody: string timings: { dns: number tls: number ttfb: number download: number total: number } } export type WebhookSignatureFormat = 'stripe' | 'svix' const eventPresets: WebhookEventPreset[] = [ { id: 'customer.created', name: 'customer.created', category: 'Customers', description: 'Dispatched whenever a new customer account or profile is created.', defaultPayload: { id: 'evt_1PqN8y2eZvKYlo2C79x01abc', object: 'event', api_version: '2026-08-01', created: 1787313600, type: 'customer.created', data: { object: { id: 'cus_N0wP9XyZ12345', object: 'customer', name: 'Sarah Connor', email: '[email protected]', phone: '+1 415 555 0199', currency: 'usd', metadata: { source: 'onboarding_flow_v2', tier: 'enterprise', }, }, }, }, }, { id: 'invoice.payment_succeeded', name: 'invoice.payment_succeeded', category: 'Billing', description: 'Triggered upon successful capture and settlement of recurring invoice payment.', defaultPayload: { id: 'evt_2MqK7x3eZvKYlo2C88y02def', object: 'event', api_version: '2026-08-01', created: 1787313580, type: 'invoice.payment_succeeded', data: { object: { id: 'in_1QpZ2w3eZvKYlo2C99182312', object: 'invoice', customer: 'cus_N0wP9XyZ12345', amount_paid: 49000, amount_due: 49000, currency: 'usd', status: 'paid', paid_at: 1787313575, lines: { data: [ { description: 'Pro Tier Subscription (10 Seats)', amount: 49000, period: { start: 1787313500, end: 1789905500, }, }, ], }, }, }, }, }, { id: 'order.fulfilled', name: 'order.fulfilled', category: 'Fulfillment', description: 'Emitted when logistics carrier records the parcel as dispatched.', defaultPayload: { id: 'evt_3KpJ5v4eZvKYlo2C77z03ghi', object: 'event', api_version: '2026-08-01', created: 1787313520, type: 'order.fulfilled', data: { object: { id: 'ord_88192341', object: 'order', customer_id: 'cus_N0wP9XyZ12345', carrier: 'FedEx Priority', tracking_number: '794829103948', status: 'fulfilled', items_count: 3, fulfillment_date: '2026-08-21T14:38:40Z', }, }, }, }, { id: 'subscription.updated', name: 'subscription.updated', category: 'Subscriptions', description: 'Emitted when plan, billing frequency, or seat quantities update.', defaultPayload: { id: 'evt_4TqL9m1eZvKYlo2C66w04jkl', object: 'event', api_version: '2026-08-01', created: 1787313460, type: 'subscription.updated', data: { object: { id: 'sub_1QzX89aZvKYlo2C01', object: 'subscription', customer: 'cus_N0wP9XyZ12345', current_period_end: 1792497600, plan: { id: 'price_enterprise_yearly', name: 'Enterprise Annual', amount: 588000, }, quantity: 25, status: 'active', }, }, }, }, ] export function generateDeterministicSignature(secret: string, payload: string, timestamp: number): string { let hash = 0 const str = `${timestamp}.${secret}.${payload}` for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i) hash = (hash << 5) - hash + char hash |= 0 } const hex1 = Math.abs(hash).toString(16).padStart(8, '0') const hex2 = Math.abs((hash * 31 + 17) | 0) .toString(16) .padStart(8, '0') const hex3 = Math.abs((hash * 127 + 59) | 0) .toString(16) .padStart(8, '0') const hex4 = Math.abs((hash * 8191 + 97) | 0) .toString(16) .padStart(8, '0') return `${hex1}${hex2}${hex3}${hex4}` } const initialDeliveries: DeliveryAttempt[] = [ { id: 'del_01J6A7BC8D9EF01', eventId: 'evt_1PqN8y2eZvKYlo2C79x01abc', eventType: 'customer.created', status: 200, statusText: 'OK', timestamp: '2026-08-21 14:40:12 UTC', timeAgo: '12s ago', latencyMs: 142, endpointUrl: 'https://api.acme.dev/webhooks', signature: 't=1787313612,v1=5257a869e7eceeda32ab62f1a9338f6cf60c9b07', requestHeaders: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)', 'stripe-signature': 't=1787313612,v1=5257a869e7eceeda32ab62f1a9338f6cf60c9b07', 'webhook-id': 'msg_01J6A7BC8D9EF01', 'webhook-timestamp': '1787313612', }, requestBody: JSON.stringify(eventPresets[0]!.defaultPayload, null, 2), responseHeaders: { 'content-type': 'application/json; charset=utf-8', date: 'Fri, 21 Aug 2026 14:40:12 GMT', server: 'cloudflare-worker', 'x-request-id': 'req_98b47120aef1', }, responseBody: JSON.stringify( { received: true, handler: 'customer_sync_v2', job_id: 'job_984129', processed_at: '2026-08-21T14:40:12.140Z', }, null, 2, ), timings: { dns: 14, tls: 26, ttfb: 88, download: 14, total: 142, }, }, { id: 'del_01J6A6ZZ7E8D9C0', eventId: 'evt_2MqK7x3eZvKYlo2C88y02def', eventType: 'invoice.payment_succeeded', status: 200, statusText: 'OK', timestamp: '2026-08-21 14:38:10 UTC', timeAgo: '2m ago', latencyMs: 98, endpointUrl: 'https://api.acme.dev/webhooks', signature: 't=1787313490,v1=99fa1b2388c4710aef12d098bc762a41289fe1b0', requestHeaders: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)', 'stripe-signature': 't=1787313490,v1=99fa1b2388c4710aef12d098bc762a41289fe1b0', 'webhook-id': 'msg_01J6A6ZZ7E8D9C0', 'webhook-timestamp': '1787313490', }, requestBody: JSON.stringify(eventPresets[1]!.defaultPayload, null, 2), responseHeaders: { 'content-type': 'application/json; charset=utf-8', date: 'Fri, 21 Aug 2026 14:38:10 GMT', server: 'nginx/1.24.0', 'x-request-id': 'req_77a1902bc450', }, responseBody: JSON.stringify( { status: 'ok', ledger_updated: true, invoice_id: 'in_1QpZ2w3eZvKYlo2C99182312', }, null, 2, ), timings: { dns: 10, tls: 20, ttfb: 56, download: 12, total: 98, }, }, { id: 'del_01J6A5YY4C2B1A9', eventId: 'evt_3KpJ5v4eZvKYlo2C77z03ghi', eventType: 'order.fulfilled', status: 500, statusText: 'Internal Server Error', timestamp: '2026-08-21 14:34:00 UTC', timeAgo: '6m ago', latencyMs: 1240, endpointUrl: 'https://api.acme.dev/webhooks', signature: 't=1787313240,v1=12fe48a90bb76c123490fdba891230cd78129aef', requestHeaders: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)', 'stripe-signature': 't=1787313240,v1=12fe48a90bb76c123490fdba891230cd78129aef', 'webhook-id': 'msg_01J6A5YY4C2B1A9', 'webhook-timestamp': '1787313240', }, requestBody: JSON.stringify(eventPresets[2]!.defaultPayload, null, 2), responseHeaders: { 'content-type': 'application/json; charset=utf-8', date: 'Fri, 21 Aug 2026 14:34:01 GMT', server: 'express-gateway', 'x-request-id': 'req_33f8101cd991', }, responseBody: JSON.stringify( { error: 'InternalServerError', message: 'Failed to connect to upstream ERP database at 10.0.4.12:5432 (ETIMEDOUT)', code: 'DB_CONN_TIMEOUT', retryable: true, }, null, 2, ), timings: { dns: 12, tls: 24, ttfb: 1190, download: 14, total: 1240, }, }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-webhook-tester, [ui-webhook-tester]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, UiTextareaComponent, ], template: ` <div data-slot="webhook-tester" [class]="rootClass"> <!-- Top Header Banner & Primary Endpoint Controls --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-4"> <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg"> <lucide-icon [img]="Zap" class="size-4" /> </div> <h3 ui-card-title class="text-base font-semibold">Webhook Event Simulator & Inspector</h3> </div> <p ui-card-description class="text-xs"> Trigger test events and verify signature headers against your endpoint. </p> </div> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="border-border bg-muted/50 font-mono text-xs"> <span class="bg-success mr-1.5 inline-block size-2 animate-pulse rounded-full"></span> Ingress Ready </span> </div> </div> </div> <div ui-card-content class="pt-0"> <div class="grid grid-cols-1 gap-3 md:grid-cols-12"> <!-- Endpoint URL Input --> <div class="md:col-span-8 lg:col-span-9"> <div class="relative flex items-center"> <span class="text-muted-foreground pointer-events-none absolute left-3 flex items-center gap-1.5 font-mono text-xs" > <lucide-icon [img]="Globe" class="size-3.5" /> <span class="text-foreground font-semibold">POST</span> </span> <ui-input [value]="endpointUrl()" (valueChange)="endpointUrl.set($event)" placeholder="https://api.acme.dev/webhooks" class="pl-20 font-mono text-xs" /> </div> </div> <!-- Trigger Button --> <div class="md:col-span-4 lg:col-span-3"> <button ui-button size="default" class="w-full gap-2 text-xs font-medium shadow-xs" [disabled]="isSending() || !endpointUrl()" (click)="sendWebhook()" > @if (isSending()) { <span class="border-primary-foreground size-3.5 animate-spin rounded-full border-2 border-t-transparent" ></span> } @else { <lucide-icon [img]="Send" class="size-3.5" /> } <span>{{ isSending() ? 'Delivering...' : 'Send Test Webhook' }}</span> <kbd class="bg-primary-foreground/20 hidden rounded px-1 py-0.5 font-mono text-xs sm:inline-flex" >⌘↵</kbd > </button> </div> </div> </div> </div> <!-- 2-Column Inspector Layout --> <div class="grid grid-cols-1 gap-6 lg:grid-cols-12"> <!-- Left Panel: Event Payload Selector, JSON Editor & Signing Secret --> <div class="space-y-6 lg:col-span-6"> <!-- Event Selector & Payload Schema Card --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="Layers" class="size-4" /> </div> <div> <h3 ui-card-title class="text-sm font-semibold">Event Configuration</h3> <p ui-card-description class="text-xs">Select event payload and adjust test parameters</p> </div> </div> <span ui-badge variant="secondary" class="font-mono text-xs">{{ activePreset().category }}</span> </div> </div> <div ui-card-content class="space-y-4 pt-0"> <!-- Event Dropdown --> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Event Type</label> <ui-select [value]="selectedEvent()" (valueChange)="handleEventChange($event)"> <button ui-select-trigger class="font-mono text-xs"> <ui-select-value placeholder="Select event preset" /> </button> <ui-select-content> @for (preset of presets; track preset.id) { <ui-select-item [value]="preset.id" class="font-mono text-xs"> {{ preset.name }} </ui-select-item> } </ui-select-content> </ui-select> <p class="text-muted-foreground text-xs">{{ activePreset().description }}</p> </div> <!-- Quick Selection Badges --> <div class="flex flex-wrap items-center gap-1.5 pt-1"> @for (preset of presets; track preset.id) { <button type="button" [class]="presetChipClass(preset.id)" (click)="handleEventChange(preset.id)"> {{ preset.id }} </button> } </div> <ui-separator /> <!-- JSON Payload Editor --> <div class="space-y-2"> <div class="flex items-center justify-between"> <label class="text-foreground flex items-center gap-1.5 text-xs font-medium"> <lucide-icon [img]="FileCode" class="text-primary size-3.5" /> <span>Payload Body (JSON)</span> </label> <div class="flex items-center gap-1.5"> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs" (click)="handleFormatJson()" > Format </button> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs" (click)="handleResetPayload()" > <lucide-icon [img]="RefreshCw" class="mr-1 size-3" /> Reset </button> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs" (click)="handleCopy(currentPayloadStr(), 'payload')" > @if (copiedKey() === 'payload') { <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> } @else { <lucide-icon [img]="Copy" class="mr-1 size-3" /> } <span>{{ copiedKey() === 'payload' ? 'Copied' : 'Copy' }}</span> </button> </div> </div> <ui-textarea [value]="currentPayloadStr()" (valueChange)="currentPayloadStr.set($event)" [rows]="11" [noResize]="true" class="border-border/70 focus:border-primary font-mono text-xs leading-relaxed" placeholder='{ "type": "event.name" }' /> </div> </div> <div ui-card-footer class="border-border text-muted-foreground flex items-center justify-between border-t pt-3 text-xs" > <span class="font-mono">{{ currentPayloadStr().length }} bytes</span> <span class="font-mono">Content-Type: application/json</span> </div> </div> <!-- Signing Secret & Signature Verification Preview Card --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="ShieldCheck" class="size-4" /> </div> <div> <h3 ui-card-title class="text-sm font-semibold">Signature & Security Headers</h3> <p ui-card-description class="text-xs">HMAC-SHA256 signature calculated from secret & timestamp</p> </div> </div> <div class="flex items-center gap-1"> <button type="button" [class]="formatChipClass('stripe')" (click)="signatureFormat.set('stripe')"> Stripe </button> <button type="button" [class]="formatChipClass('svix')" (click)="signatureFormat.set('svix')"> Svix Standard </button> </div> </div> </div> <div ui-card-content class="space-y-4 pt-0"> <!-- Signing Secret Input --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">Endpoint Signing Secret</label> <button type="button" class="text-primary flex min-h-6 cursor-pointer items-center gap-1 text-xs hover:underline" (click)="handleRegenerateSecret()" > <lucide-icon [img]="RotateCw" class="size-3" /> <span>Regenerate</span> </button> </div> <div class="relative flex items-center"> <lucide-icon [img]="Key" class="text-muted-foreground pointer-events-none absolute left-3 size-3.5" /> <ui-input [value]="signingSecret()" (valueChange)="signingSecret.set($event)" class="pr-16 pl-8 font-mono text-xs" placeholder="whsec_..." /> <button ui-button aria-label="Copy signing secret" variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground absolute right-1 h-7 px-2 text-xs" (click)="handleCopy(signingSecret(), 'secret')" > @if (copiedKey() === 'secret') { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } </button> </div> </div> <!-- Signature Header Box --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">Generated Request Headers</label> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs" (click)="handleCopy(computedAllHeaders(), 'headers')" > @if (copiedKey() === 'headers') { <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> } @else { <lucide-icon [img]="Copy" class="mr-1 size-3" /> } <span>{{ copiedKey() === 'headers' ? 'Copied' : 'Copy Headers' }}</span> </button> </div> <pre class="border-border bg-muted/40 text-foreground overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed" ><code>{{ computedAllHeaders() }}</code></pre> </div> </div> </div> </div> <!-- Right Panel: Delivery History & Active Delivery Inspector --> <div class="space-y-6 lg:col-span-6"> <!-- Recent Delivery Attempts List --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="Activity" class="size-4" /> </div> <div> <h3 ui-card-title class="text-sm font-semibold">Delivery History</h3> <p ui-card-description class="text-xs">Recent dispatch logs and response telemetry</p> </div> </div> <span class="text-muted-foreground font-mono text-xs" >{{ deliveryHistory().length }} events logged</span > </div> </div> <div ui-card-content class="p-0"> <div class="divide-border max-h-[250px] divide-y overflow-y-auto"> @for (attempt of deliveryHistory(); track attempt.id) { <div [class]="attemptRowClass(attempt.id)" (click)="selectedDeliveryId.set(attempt.id)"> <div class="flex min-w-0 items-center gap-2.5"> <span ui-badge [variant]="attempt.status === 200 ? 'success' : 'destructive'" class="shrink-0 px-1.5 py-0.5 font-mono text-xs" > {{ attempt.status }} </span> <div class="min-w-0 truncate"> <p class="text-foreground truncate font-mono">{{ attempt.eventType }}</p> <p class="text-muted-foreground truncate font-mono text-xs">{{ attempt.id }}</p> </div> </div> <div class="flex shrink-0 items-center gap-3 text-right"> <span class="text-muted-foreground font-mono">{{ attempt.latencyMs }}ms</span> <span class="text-muted-foreground text-xs">{{ attempt.timeAgo }}</span> <lucide-icon [img]="ArrowRight" class="text-muted-foreground size-3.5" /> </div> </div> } </div> </div> </div> <!-- Active Delivery Detail Inspector --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2"> <span ui-badge [variant]="activeDelivery().status === 200 ? 'success' : 'destructive'" class="font-mono text-xs" > {{ activeDelivery().status }} {{ activeDelivery().statusText }} </span> <span class="text-foreground font-mono text-xs font-semibold">{{ activeDelivery().id }}</span> </div> <p class="text-muted-foreground truncate font-mono text-xs">{{ activeDelivery().endpointUrl }}</p> </div> <div class="flex items-center gap-2"> <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium" (click)="retryDelivery(activeDelivery())" > <lucide-icon [img]="RotateCw" class="size-3.5" /> <span>Retry</span> </button> </div> </div> </div> <div ui-card-content class="pt-0"> <ui-tabs [value]="activeInspectorTab()" (valueChange)="activeInspectorTab.set($event)" class="w-full"> <ui-tabs-list class="grid w-full grid-cols-3"> <button ui-tabs-trigger value="request" class="text-xs">Request</button> <button ui-tabs-trigger value="response" class="text-xs">Response</button> <button ui-tabs-trigger value="timings" class="text-xs">Diagnostics</button> </ui-tabs-list> <!-- Request Tab --> <ui-tabs-content value="request" class="space-y-4 pt-3"> <!-- Request Headers Table --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">Request Headers</label> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs" (click)="handleCopy(stringify(activeDelivery().requestHeaders), 'req-headers')" > @if (copiedKey() === 'req-headers') { <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> } @else { <lucide-icon [img]="Copy" class="mr-1 size-3" /> } <span>{{ copiedKey() === 'req-headers' ? 'Copied' : 'Copy' }}</span> </button> </div> <div class="border-border bg-muted/20 overflow-hidden rounded-md border"> <table ui-table> <thead ui-table-header> <tr ui-table-row> <th ui-table-head class="h-8 text-xs font-medium">Header</th> <th ui-table-head class="h-8 text-xs font-medium">Value</th> </tr> </thead> <tbody ui-table-body> @for (row of headerEntries(activeDelivery().requestHeaders); track row[0]) { <tr ui-table-row> <td ui-table-cell class="text-muted-foreground py-1.5 font-mono text-xs">{{ row[0] }}</td> <td ui-table-cell class="text-foreground max-w-[200px] truncate py-1.5 font-mono text-xs"> {{ row[1] }} </td> </tr> } </tbody> </table> </div> </div> <!-- Request Body --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">Request Body Payload</label> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs" (click)="handleCopy(activeDelivery().requestBody, 'req-body')" > @if (copiedKey() === 'req-body') { <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> } @else { <lucide-icon [img]="Copy" class="mr-1 size-3" /> } <span>{{ copiedKey() === 'req-body' ? 'Copied' : 'Copy' }}</span> </button> </div> <pre class="border-border bg-muted/40 text-foreground max-h-[220px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed" ><code>{{ activeDelivery().requestBody }}</code></pre> </div> </ui-tabs-content> <!-- Response Tab --> <ui-tabs-content value="response" class="space-y-4 pt-3"> <!-- Response Headers Table --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">Response Headers</label> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs" (click)="handleCopy(stringify(activeDelivery().responseHeaders), 'res-headers')" > @if (copiedKey() === 'res-headers') { <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> } @else { <lucide-icon [img]="Copy" class="mr-1 size-3" /> } <span>{{ copiedKey() === 'res-headers' ? 'Copied' : 'Copy' }}</span> </button> </div> <div class="border-border bg-muted/20 overflow-hidden rounded-md border"> <table ui-table> <thead ui-table-header> <tr ui-table-row> <th ui-table-head class="h-8 text-xs font-medium">Header</th> <th ui-table-head class="h-8 text-xs font-medium">Value</th> </tr> </thead> <tbody ui-table-body> @for (row of headerEntries(activeDelivery().responseHeaders); track row[0]) { <tr ui-table-row> <td ui-table-cell class="text-muted-foreground py-1.5 font-mono text-xs">{{ row[0] }}</td> <td ui-table-cell class="text-foreground max-w-[200px] truncate py-1.5 font-mono text-xs"> {{ row[1] }} </td> </tr> } </tbody> </table> </div> </div> <!-- Response Body Payload --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label class="text-foreground text-xs font-medium">Response Body</label> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs" (click)="handleCopy(activeDelivery().responseBody, 'res-body')" > @if (copiedKey() === 'res-body') { <lucide-icon [img]="Check" class="text-success mr-1 size-3" /> } @else { <lucide-icon [img]="Copy" class="mr-1 size-3" /> } <span>{{ copiedKey() === 'res-body' ? 'Copied' : 'Copy' }}</span> </button> </div> <pre [class]="responseBodyClass"><code>{{ activeDelivery().responseBody }}</code></pre> </div> </ui-tabs-content> <!-- Diagnostics & Timeline Tab --> <ui-tabs-content value="timings" class="space-y-4 pt-3"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center"> <p class="text-muted-foreground text-xs">DNS Lookup</p> <p class="text-foreground font-mono text-sm font-semibold"> {{ activeDelivery().timings.dns }}ms </p> </div> <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center"> <p class="text-muted-foreground text-xs">TLS Handshake</p> <p class="text-foreground font-mono text-sm font-semibold"> {{ activeDelivery().timings.tls }}ms </p> </div> <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center"> <p class="text-muted-foreground text-xs">Server TTFB</p> <p class="text-foreground font-mono text-sm font-semibold"> {{ activeDelivery().timings.ttfb }}ms </p> </div> <div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center"> <p class="text-muted-foreground text-xs">Total Roundtrip</p> <p class="text-primary font-mono text-sm font-semibold">{{ activeDelivery().timings.total }}ms</p> </div> </div> <div class="border-border bg-muted/20 space-y-2 rounded-lg border p-3"> <h4 class="text-foreground text-xs font-semibold">Security & Delivery Verification</h4> <ul class="space-y-1.5 text-xs"> <li class="text-success flex items-center gap-2"> <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" /> <span>HMAC signature matched endpoint verification secret</span> </li> <li class="text-success flex items-center gap-2"> <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" /> <span>TLS 1.3 certificate chain verified (Cloudflare Inc)</span> </li> <li class="text-success flex items-center gap-2"> <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" /> <span>Timestamp within tolerance envelope (300s window)</span> </li> </ul> </div> </ui-tabs-content> </ui-tabs> </div> <div ui-card-footer class="border-border text-muted-foreground flex items-center justify-between border-t pt-3 text-xs" > <span class="flex items-center gap-1.5"> <lucide-icon [img]="Clock" class="size-3.5" /> <span>Dispatched at: {{ activeDelivery().timestamp }}</span> </span> <span class="font-mono">Latency: {{ activeDelivery().latencyMs }}ms</span> </div> </div> </div> </div> </div> `, }) export class UiWebhookTesterComponent implements OnInit, OnDestroy { protected readonly Activity = Activity protected readonly ArrowRight = ArrowRight protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly Copy = Copy protected readonly FileCode = FileCode protected readonly Globe = Globe protected readonly Key = Key protected readonly Layers = Layers protected readonly RefreshCw = RefreshCw protected readonly RotateCw = RotateCw protected readonly Send = Send protected readonly ShieldCheck = ShieldCheck protected readonly Zap = Zap protected readonly presets = eventPresets @Input('class') className?: string readonly endpointUrl = signal('https://api.acme.dev/webhooks') readonly signingSecret = signal('whsec_9f83a8b271d4e680c102a9b6c7a3efd8') readonly selectedEvent = signal('customer.created') readonly currentPayloadStr = signal(JSON.stringify(eventPresets[0]!.defaultPayload, null, 2)) readonly signatureFormat = signal<WebhookSignatureFormat>('stripe') readonly activeInspectorTab = signal('request') readonly isSending = signal(false) readonly copiedKey = signal<string | null>(null) private copyTimer: ReturnType<typeof setTimeout> | undefined readonly deliveryHistory = signal<DeliveryAttempt[]>([...initialDeliveries]) readonly selectedDeliveryId = signal<string>(initialDeliveries[0]!.id) readonly currentTimestamp = signal(1787313600) private readonly keyHandler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault() this.sendWebhook() } } ngOnInit(): void { window.addEventListener('keydown', this.keyHandler) } ngOnDestroy(): void { window.removeEventListener('keydown', this.keyHandler) clearTimeout(this.copyTimer) } readonly activeDelivery = computed(() => { const history = this.deliveryHistory() return history.find((d) => d.id === this.selectedDeliveryId()) ?? history[0]! }) readonly activePreset = computed(() => { return eventPresets.find((p) => p.id === this.selectedEvent()) ?? eventPresets[0]! }) readonly computedSignatureHex = computed(() => { return generateDeterministicSignature(this.signingSecret(), this.currentPayloadStr(), this.currentTimestamp()) }) readonly computedSignatureHeader = computed(() => { if (this.signatureFormat() === 'stripe') { return `Stripe-Signature: t=${this.currentTimestamp()},v1=${this.computedSignatureHex()}` } const b64 = typeof window !== 'undefined' ? btoa(this.computedSignatureHex().slice(0, 32)) : '' return `webhook-signature: v1,${b64}` }) readonly computedAllHeaders = computed(() => { const t = this.currentTimestamp() const sigHex = this.computedSignatureHex() if (this.signatureFormat() === 'stripe') { return `POST ${this.endpointUrl()}\nHost: api.acme.dev\nContent-Type: application/json\nUser-Agent: UIPKGE-Webhooks/2.0\nStripe-Signature: t=${t},v1=${sigHex}` } const b64 = typeof window !== 'undefined' ? btoa(sigHex.slice(0, 32)) : sigHex.slice(0, 32) return `POST ${this.endpointUrl()}\nHost: api.acme.dev\nContent-Type: application/json\nUser-Agent: UIPKGE-Webhooks/2.0\nwebhook-id: msg_${sigHex.slice(0, 14)}\nwebhook-timestamp: ${t}\nwebhook-signature: v1,${b64}` }) get rootClass(): string { return cn('w-full space-y-6', this.className) } get responseBodyClass(): string { return cn( 'max-h-[220px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed', this.activeDelivery().status === 200 ? 'border-border bg-muted/40 text-foreground' : 'border-destructive/30 bg-destructive/10 text-destructive', ) } presetChipClass(id: string): string { return cn( 'min-h-6 cursor-pointer rounded border px-2 py-0.5 font-mono text-xs transition-colors', this.selectedEvent() === id ? 'border-primary bg-primary/10 text-primary font-medium' : 'border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground', ) } formatChipClass(format: WebhookSignatureFormat): string { return cn( 'min-h-6 cursor-pointer rounded px-2 py-0.5 font-mono text-xs transition-colors', this.signatureFormat() === format ? 'bg-primary text-primary-foreground font-medium' : 'bg-muted text-muted-foreground hover:text-foreground', ) } attemptRowClass(id: string): string { return cn( 'flex cursor-pointer items-center justify-between gap-3 p-3.5 text-xs transition-colors', this.selectedDeliveryId() === id ? 'bg-muted/80 font-medium' : 'hover:bg-muted/40', ) } headerEntries(headers: Record<string, string>): [string, string][] { return Object.entries(headers) } stringify(value: object): string { return JSON.stringify(value, null, 2) } handleEventChange(eventId: string): void { this.selectedEvent.set(eventId) const preset = eventPresets.find((p) => p.id === eventId) if (preset) { this.currentPayloadStr.set(JSON.stringify(preset.defaultPayload, null, 2)) } } handleFormatJson(): void { try { const parsed = JSON.parse(this.currentPayloadStr()) this.currentPayloadStr.set(JSON.stringify(parsed, null, 2)) } catch { // Keep as is if invalid } } handleResetPayload(): void { const preset = eventPresets.find((p) => p.id === this.selectedEvent()) if (preset) { this.currentPayloadStr.set(JSON.stringify(preset.defaultPayload, null, 2)) } } handleRegenerateSecret(): void { const chars = 'abcdef0123456789' let result = 'whsec_' for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)) } this.signingSecret.set(result) } handleCopy(text: string, key: string): void { try { navigator.clipboard?.writeText(text) } catch { // Clipboard blocked: the payload stays visible and selectable. } this.copiedKey.set(key) clearTimeout(this.copyTimer) const current = key this.copyTimer = setTimeout(() => { if (this.copiedKey() === current) this.copiedKey.set(null) }, 2000) } sendWebhook(): void { if (this.isSending()) return this.isSending.set(true) const nowSec = Math.floor(Date.now() / 1000) this.currentTimestamp.set(nowSec) setTimeout(() => { let parsedBody: Record<string, string> = {} let isInvalidJson = false try { parsedBody = JSON.parse(this.currentPayloadStr()) as Record<string, string> } catch { isInvalidJson = true } const url = this.endpointUrl() const isFailureUrl = url.includes('fail') || url.includes('error') const status = isInvalidJson ? 400 : isFailureUrl ? 500 : 200 const statusText = isInvalidJson ? 'Bad Request' : isFailureUrl ? 'Internal Server Error' : 'OK' const latency = isInvalidJson ? 32 : isFailureUrl ? 850 : Math.floor(Math.random() * 90) + 65 const deliveryId = `del_${Math.random().toString(36).substring(2, 11).toUpperCase()}` const eventId = parsedBody['id'] ?? `evt_${Math.random().toString(36).substring(2, 10)}` const eventType = parsedBody['type'] ?? this.selectedEvent() const sig = `t=${nowSec},v1=${this.computedSignatureHex()}` const newDelivery: DeliveryAttempt = { id: deliveryId, eventId, eventType, status, statusText, timestamp: 'Just now', timeAgo: 'Just now', latencyMs: latency, endpointUrl: url, signature: sig, requestHeaders: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'UIPKGE-Webhooks/2.0 (webhook-tester)', 'stripe-signature': sig, 'webhook-id': `msg_${deliveryId}`, 'webhook-timestamp': String(nowSec), }, requestBody: this.currentPayloadStr(), responseHeaders: { 'content-type': 'application/json; charset=utf-8', date: new Date().toUTCString(), server: 'acme-edge-router/1.8', 'x-request-id': `req_${Math.random().toString(36).substring(2, 12)}`, }, responseBody: isInvalidJson ? JSON.stringify({ error: 'BadRequest', message: 'Payload is not valid RFC 8259 JSON' }, null, 2) : isFailureUrl ? JSON.stringify( { error: 'InternalServerError', message: 'Webhook handler threw uncaught exception' }, null, 2, ) : JSON.stringify( { received: true, event_type: eventType, delivery_id: deliveryId, status: 'acknowledged', timestamp: new Date().toISOString(), }, null, 2, ), timings: { dns: Math.floor(latency * 0.1), tls: Math.floor(latency * 0.2), ttfb: Math.floor(latency * 0.6), download: Math.floor(latency * 0.1), total: latency, }, } this.deliveryHistory.update((prev) => [newDelivery, ...prev]) this.selectedDeliveryId.set(newDelivery.id) this.isSending.set(false) }, 450) } retryDelivery(attempt: DeliveryAttempt): void { this.currentPayloadStr.set(attempt.requestBody) this.endpointUrl.set(attempt.endpointUrl) this.selectedEvent.set(attempt.eventType) this.sendWebhook() } }src/app/components/blocks/webhook-tester/index.ts0.2 kB
export { UiWebhookTesterComponent, generateDeterministicSignature, type WebhookEventPreset, type DeliveryAttempt, type WebhookSignatureFormat, } from './webhook-tester.component'
Raw manifest:https://uipkge.dev/r/angular/webhook-tester.json