Developing Webhook Management (Dashboard for Hook Management)
A webhook without a dashboard is a black box. You don't know if a notification was delivered, how many attempts were made, what the recipient responded with. A webhook management dashboard provides full visibility: all sends, statuses, request and response bodies, retries.
Database Schema
CREATE TABLE webhook_endpoints (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
secret VARCHAR(64) NOT NULL, -- for HMAC signature
events TEXT[] NOT NULL, -- ['order.created', 'order.shipped']
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Delivery settings
timeout_ms INTEGER DEFAULT 10000,
max_retries SMALLINT DEFAULT 5,
-- Statistics (denormalized for fast display)
total_sent INTEGER DEFAULT 0,
total_failed INTEGER DEFAULT 0,
last_sent_at TIMESTAMPTZ,
last_error TEXT
);
CREATE TABLE webhook_deliveries (
id BIGSERIAL PRIMARY KEY,
endpoint_id INTEGER REFERENCES webhook_endpoints(id),
event_type VARCHAR(100) NOT NULL,
event_id VARCHAR(100) NOT NULL, -- idempotent event key
payload JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, delivered, failed, retrying
attempt_count SMALLINT DEFAULT 0,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
-- HTTP details of last attempt
last_http_status SMALLINT,
last_response_body TEXT,
last_request_ms INTEGER,
last_error_message TEXT
);
CREATE TABLE webhook_delivery_attempts (
id BIGSERIAL PRIMARY KEY,
delivery_id BIGINT REFERENCES webhook_deliveries(id),
attempt_num SMALLINT NOT NULL,
attempted_at TIMESTAMPTZ DEFAULT NOW(),
http_status SMALLINT,
request_ms INTEGER,
request_body TEXT,
response_body TEXT,
error TEXT
);
CREATE INDEX ON webhook_deliveries (endpoint_id, created_at DESC);
CREATE INDEX ON webhook_deliveries (status, next_retry_at) WHERE status = 'retrying';
CREATE INDEX ON webhook_deliveries (event_id, endpoint_id) UNIQUE;
Delivery Service
class WebhookDeliveryService
{
public function dispatch(string $eventType, string $eventId, array $payload): void
{
// Find active endpoints subscribed to this event
$endpoints = $this->endpointRepo->findActiveForEvent($eventType);
foreach ($endpoints as $endpoint) {
// Idempotency: don't duplicate if we already created a record for this event
$delivery = $this->deliveryRepo->findOrCreate(
$endpoint->id,
$eventId,
[
'event_type' => $eventType,
'payload' => $payload,
'status' => 'pending',
]
);
// Queue for async delivery
dispatch(new DeliverWebhookJob($delivery->id));
}
}
}
class DeliverWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1; // retry logic managed manually
public int $timeout = 30;
public function handle(WebhookDeliveryService $service): void
{
$delivery = WebhookDelivery::with('endpoint')->find($this->deliveryId);
if (!$delivery || $delivery->status === 'delivered') return;
$endpoint = $delivery->endpoint;
$attempt = $delivery->attempt_count + 1;
$body = json_encode([
'id' => $delivery->event_id,
'type' => $delivery->event_type,
'created_at' => $delivery->created_at->toIso8601String(),
'data' => $delivery->payload,
]);
// HMAC signature
$signature = 'sha256=' . hash_hmac('sha256', $body, $endpoint->secret);
$startTime = microtime(true);
try {
$response = Http::timeout($endpoint->timeout_ms / 1000)
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-ID' => $delivery->id,
'X-Webhook-Event' => $delivery->event_type,
'X-Webhook-Signature-256' => $signature,
'User-Agent' => 'YourApp-Webhooks/1.0',
])
->post($endpoint->url, json_decode($body, true));
$elapsed = (int)((microtime(true) - $startTime) * 1000);
$this->recordAttempt($delivery, $attempt, $response->status(), $body, $response->body(), $elapsed);
if ($response->successful()) {
$delivery->update([
'status' => 'delivered',
'delivered_at' => now(),
'last_http_status' => $response->status(),
'last_request_ms' => $elapsed,
'attempt_count' => $attempt,
]);
} else {
$this->scheduleRetry($delivery, $attempt, $endpoint, "HTTP {$response->status()}");
}
} catch (\Exception $e) {
$elapsed = (int)((microtime(true) - $startTime) * 1000);
$this->recordAttempt($delivery, $attempt, null, $body, null, $elapsed, $e->getMessage());
$this->scheduleRetry($delivery, $attempt, $endpoint, $e->getMessage());
}
}
private function scheduleRetry(WebhookDelivery $delivery, int $attempt,
WebhookEndpoint $endpoint, string $error): void
{
if ($attempt >= $endpoint->max_retries) {
$delivery->update(['status' => 'failed', 'last_error_message' => $error]);
return;
}
// Exponential backoff: 5s, 25s, 125s, 625s, 3125s
$delaySeconds = 5 ** $attempt;
$nextRetryAt = now()->addSeconds($delaySeconds);
$delivery->update([
'status' => 'retrying',
'attempt_count' => $attempt,
'next_retry_at' => $nextRetryAt,
'last_error_message' => $error,
]);
dispatch(new DeliverWebhookJob($delivery->id))->delay($nextRetryAt);
}
}
Admin Dashboard API
// Get list of endpoints with aggregated statistics
public function endpoints(Request $request): JsonResponse
{
$endpoints = WebhookEndpoint::withCount([
'deliveries as pending_count' => fn($q) => $q->where('status', 'pending'),
'deliveries as failed_count' => fn($q) => $q->where('status', 'failed'),
'deliveries as delivered_count' => fn($q) => $q->where('status', 'delivered'),
])
->orderByDesc('created_at')
->paginate(20);
return response()->json($endpoints);
}
// Detailed delivery log with filtering
public function deliveries(Request $request, int $endpointId): JsonResponse
{
$deliveries = WebhookDelivery::where('endpoint_id', $endpointId)
->when($request->status, fn($q) => $q->where('status', $request->status))
->when($request->event_type, fn($q) => $q->where('event_type', $request->event_type))
->with('attempts')
->orderByDesc('created_at')
->paginate(50);
return response()->json($deliveries);
}
// Manual retry of specific delivery
public function retry(int $deliveryId): JsonResponse
{
$delivery = WebhookDelivery::findOrFail($deliveryId);
$delivery->update(['status' => 'pending', 'next_retry_at' => null]);
dispatch(new DeliverWebhookJob($deliveryId));
return response()->json(['queued' => true]);
}
// Test webhook (ping)
public function ping(int $endpointId): JsonResponse
{
$endpoint = WebhookEndpoint::findOrFail($endpointId);
$this->webhookService->dispatch('webhook.ping', uniqid('ping_'), [
'message' => 'Test webhook from dashboard',
'timestamp' => now()->toIso8601String(),
]);
return response()->json(['sent' => true]);
}
Signature Verification on Recipient Side
// Node.js — recipient verifies HMAC signature
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
// Timing-safe comparison — protection from timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks/yourapp', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-webhook-signature-256'];
if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Respond 200 immediately, process async
res.status(200).send('OK');
processEventAsync(event);
});
Timeline
Day 1 — Database schema, basic DeliveryService with HMAC signature, Job with retry logic.
Day 2 — REST API for Admin UI: CRUD endpoints, filtered delivery log, manual retry.
Day 3 — Frontend Admin UI (endpoints table, delivery log with drill-down to request/response bodies), ping test, monitoring failed/pending counts.







