In this tutorial, we'll build a comprehensive outbound webhook system that integrates with Hookdeck Event Gateway to provide reliable webhook delivery and management capabilities. This system allows your SaaS or developer platform to send webhook notifications to your users when events occur within your application.
Hookdeck Event Gateway acts as a reliable webhook delivery service that sits between your application and your users' webhook endpoints. When something happens in your platform (like a user signs up, a payment is processed, or a deployment completes), you can use Hookdeck to reliably deliver webhook notifications to your users' configured endpoints with automatic retries, detailed logging, and delivery guarantees.
For example, if you're building a CI/CD platform, when a deployment completes, you'd use Hookdeck to send webhook notifications to all the endpoints your users have configured. Hookdeck ensures these webhooks are delivered reliably, even if the user's endpoint is temporarily down, with automatic retries and comprehensive monitoring.
This is what the end result should look like:
A modern webhook management dashboard showing:
To get started, you'll need Encore installed on your machine:
$ brew install encoredev/tap/encore
Once that's ready, we can create our project:
encore app create webhook-system --example=hello-world
We'll structure our outbound webhook system as a single service that handles all webhook operations. In Encore, a service is a logical grouping of related functionality—in our case, everything related to webhook endpoint management, event triggering, and Hookdeck integration.
Let's create a dedicated webhook service that will be responsible for:
First, create the service directory:
mkdir backend/webhook
Now define the service by creating backend/webhook/encore.service.ts
:
import { Service } from "encore.dev/service";
export default new Service("webhook");
This tells Encore that the webhook directory contains a service called "webhook". Encore will automatically discover all APIs, databases, and other resources we define within this service.
We'll use a SQL database to store webhook endpoint configurations and event tracking. While Hookdeck handles the actual webhook delivery infrastructure, we need to store our own records for endpoint management and analytics.
Create backend/webhook/migrations/1_create_subscriptions.up.sql
:
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
destination_url TEXT NOT NULL,
hookdeck_connection_id TEXT NOT NULL,
hookdeck_destination_id TEXT NOT NULL,
hookdeck_source_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_hookdeck_connection_id ON subscriptions(hookdeck_connection_id);
Create backend/webhook/migrations/2_add_source_url.up.sql
:
ALTER TABLE subscriptions ADD COLUMN hookdeck_source_url TEXT;
Create backend/webhook/migrations/3_update_existing_subscriptions.up.sql
:
-- Update existing subscriptions that don't have hookdeck_source_url
-- This is a one-time migration to fix existing data
UPDATE subscriptions
SET hookdeck_source_url = 'https://hkdk.events/' || hookdeck_source_id
WHERE hookdeck_source_url IS NULL OR hookdeck_source_url = '';
This schema stores webhook endpoint configurations and tracks events. The hookdeck_*
fields link our records to Hookdeck's managed sources and destinations, while the events table provides detailed tracking of webhook deliveries.
Create backend/webhook/db.ts
to initialize the database:
import { SQLDatabase } from "encore.dev/storage/sqldb";
export const webhookDB = new SQLDatabase("webhook", {
migrations: "./migrations",
});
Hookdeck provides a REST API for managing webhook infrastructure. We'll create a client that handles all interactions with their Event Gateway service.
Create backend/webhook/hookdeck.ts
for the Hookdeck API client:
import { secret } from "encore.dev/config";
const hookdeckApiKey = secret("HookdeckApiKey");
export interface HookdeckDestination {
id: string;
name: string;
url: string;
}
export interface HookdeckSource {
id: string;
name: string;
url: string;
}
export interface HookdeckConnection {
id: string;
name: string;
source: HookdeckSource;
destination: HookdeckDestination;
}
export interface CreateConnectionRequest {
name: string;
source: {
name: string;
type: "MANAGED";
};
destination: {
name: string;
url: string;
};
}
export interface CreateConnectionResponse {
id: string;
name: string;
source: HookdeckSource;
destination: HookdeckDestination;
}
export interface PublishEventRequest {
data: Record<string, any>;
headers?: Record<string, string>;
event_type?: string;
}
class HookdeckClient {
private baseUrl = "https://api.hookdeck.com/2025-01-01";
private publishUrl = "https://hkdk.events/v1/publish";
private async request<T>(
method: string,
path: string,
body?: any
): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
"Authorization": `Bearer ${hookdeckApiKey()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Hookdeck API error: ${response.status} ${errorText}`);
}
return response.json();
}
async createConnection(request: CreateConnectionRequest): Promise<CreateConnectionResponse> {
return this.request<CreateConnectionResponse>("POST", "/connections", request);
}
async deleteConnection(connectionId: string): Promise<void> {
await this.request("DELETE", `/connections/${connectionId}`);
}
async publishEvent(sourceId: string, request: PublishEventRequest): Promise<void> {
// Use the Hookdeck Publish API to send events
const response = await fetch(this.publishUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${hookdeckApiKey()}`,
"Content-Type": "application/json",
"X-Hookdeck-Source-Id": sourceId,
"X-Event-Type": request.event_type || "test.event",
...request.headers,
},
body: JSON.stringify(request.data),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to publish event: ${response.status} ${errorText}`);
}
}
}
export const hookdeckClient = new HookdeckClient();
The Hookdeck client now uses the latest 2025-01-01 API version and the Publish API for sending events. When creating connections, we specify the source type as "MANAGED" which is the correct type for outbound webhook scenarios.
Let's define TypeScript interfaces for type safety across our application:
Create backend/webhook/types.ts
:
export interface Subscription {
id: number;
name: string;
destinationUrl: string;
hookdeckConnectionId: string;
hookdeckDestinationId: string;
hookdeckSourceId: string;
hookdeckSourceUrl: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateSubscriptionRequest {
name: string;
destinationUrl: string;
}
export interface CreateSubscriptionResponse {
subscription: Subscription;
}
export interface ListSubscriptionsResponse {
subscriptions: Subscription[];
}
export interface TestWebhookRequest {
subscriptionId: number;
eventType: string;
payload: Record<string, any>;
}
export interface TestWebhookResponse {
success: boolean;
message: string;
eventId?: string;
}
export interface DeleteSubscriptionRequest {
id: number;
}
Now let's create the core APIs for webhook endpoint management:
Create backend/webhook/create.ts
:
import { api, APIError } from "encore.dev/api";
import { webhookDB } from "./db";
import { hookdeckClient } from "./hookdeck";
import type { CreateSubscriptionRequest, CreateSubscriptionResponse, Subscription } from "./types";
// Helper function to sanitize names for Hookdeck API
function sanitizeHookdeckName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\-_]/g, '-') // Replace invalid characters with hyphens
.replace(/-+/g, '-') // Replace multiple consecutive hyphens with single hyphen
.replace(/^-+|-+$/g, ''); // Remove leading and trailing hyphens
}
// Creates a new webhook endpoint and corresponding Hookdeck connection.
export const create = api<CreateSubscriptionRequest, CreateSubscriptionResponse>(
{ expose: true, method: "POST", path: "/subscriptions" },
async (req) => {
// Validate subscription name
if (!req.name || req.name.trim().length === 0) {
throw APIError.invalidArgument("Webhook endpoint name is required");
}
if (req.name.trim().length < 3) {
throw APIError.invalidArgument("Webhook endpoint name must be at least 3 characters long");
}
if (req.name.trim().length > 100) {
throw APIError.invalidArgument("Webhook endpoint name must be less than 100 characters");
}
// Check for invalid characters in name (allow more characters for display name)
const namePattern = /^[a-zA-Z0-9\s\-_.,()]+$/;
if (!namePattern.test(req.name.trim())) {
throw APIError.invalidArgument("Webhook endpoint name can only contain letters, numbers, spaces, hyphens, underscores, periods, commas, and parentheses");
}
// Validate destination URL
if (!req.destinationUrl || req.destinationUrl.trim().length === 0) {
throw APIError.invalidArgument("Destination URL is required");
}
try {
new URL(req.destinationUrl.trim());
} catch {
throw APIError.invalidArgument("Invalid destination URL format");
}
const trimmedName = req.name.trim();
const trimmedUrl = req.destinationUrl.trim();
// Check if subscription name already exists
const existingSubscription = await webhookDB.queryRow<{ id: number }>`
SELECT id FROM subscriptions WHERE LOWER(name) = LOWER(${trimmedName})
`;
if (existingSubscription) {
throw APIError.alreadyExists("A webhook endpoint with this name already exists");
}
// Sanitize names for Hookdeck API (must match pattern: /^[A-z0-9-_]+$/)
const sanitizedName = sanitizeHookdeckName(trimmedName);
// Ensure sanitized name is not empty and has minimum length
if (sanitizedName.length < 3) {
throw APIError.invalidArgument("Webhook endpoint name must contain at least 3 valid characters (letters, numbers, hyphens, or underscores)");
}
// Add timestamp to ensure uniqueness in Hookdeck
const timestamp = Date.now();
const hookdeckConnectionName = `${sanitizedName}-conn-${timestamp}`;
const hookdeckSourceName = `${sanitizedName}-src-${timestamp}`;
const hookdeckDestinationName = `${sanitizedName}-dest-${timestamp}`;
try {
// Create connection in Hookdeck with MANAGED source type
const connectionRequest = {
name: hookdeckConnectionName,
source: {
name: hookdeckSourceName,
type: "MANAGED" as const,
},
destination: {
name: hookdeckDestinationName,
url: trimmedUrl,
},
};
const hookdeckConnection = await hookdeckClient.createConnection(connectionRequest);
// Store subscription in database
const row = await webhookDB.queryRow<{
id: number;
name: string;
destination_url: string;
hookdeck_connection_id: string;
hookdeck_destination_id: string;
hookdeck_source_id: string;
hookdeck_source_url: string;
created_at: Date;
updated_at: Date;
}>`
INSERT INTO subscriptions (
name,
destination_url,
hookdeck_connection_id,
hookdeck_destination_id,
hookdeck_source_id,
hookdeck_source_url
)
VALUES (${trimmedName}, ${trimmedUrl}, ${hookdeckConnection.id}, ${hookdeckConnection.destination.id}, ${hookdeckConnection.source.id}, ${hookdeckConnection.source.url})
RETURNING *
`;
if (!row) {
throw APIError.internal("Failed to create webhook endpoint");
}
const subscription: Subscription = {
id: row.id,
name: row.name,
destinationUrl: row.destination_url,
hookdeckConnectionId: row.hookdeck_connection_id,
hookdeckDestinationId: row.hookdeck_destination_id,
hookdeckSourceId: row.hookdeck_source_id,
hookdeckSourceUrl: row.hookdeck_source_url,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
return { subscription };
} catch (error) {
if (error instanceof APIError) {
throw error;
}
if (error instanceof Error) {
throw APIError.internal(`Failed to create webhook endpoint: ${error.message}`);
}
throw APIError.internal("Failed to create webhook endpoint");
}
}
);
Create backend/webhook/list.ts
:
import { api } from "encore.dev/api";
import { webhookDB } from "./db";
import type { ListSubscriptionsResponse, Subscription } from "./types";
// Retrieves all webhook endpoints.
export const list = api<void, ListSubscriptionsResponse>(
{ expose: true, method: "GET", path: "/subscriptions" },
async () => {
const rows = await webhookDB.queryAll<{
id: number;
name: string;
destination_url: string;
hookdeck_connection_id: string;
hookdeck_destination_id: string;
hookdeck_source_id: string;
hookdeck_source_url: string;
created_at: Date;
updated_at: Date;
}>`
SELECT * FROM subscriptions ORDER BY created_at DESC
`;
const subscriptions: Subscription[] = rows.map(row => ({
id: row.id,
name: row.name,
destinationUrl: row.destination_url,
hookdeckConnectionId: row.hookdeck_connection_id,
hookdeckDestinationId: row.hookdeck_destination_id,
hookdeckSourceId: row.hookdeck_source_id,
// Ensure we always have a source URL, fallback to constructed URL if missing
hookdeckSourceUrl: row.hookdeck_source_url || `https://hkdk.events/${row.hookdeck_source_id}`,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
return { subscriptions };
}
);
Create backend/webhook/delete.ts
:
import { api, APIError } from "encore.dev/api";
import { webhookDB } from "./db";
import { hookdeckClient } from "./hookdeck";
import type { DeleteSubscriptionRequest } from "./types";
// Deletes a webhook endpoint and its corresponding Hookdeck connection.
export const deleteSubscription = api<DeleteSubscriptionRequest, void>(
{ expose: true, method: "DELETE", path: "/subscriptions/:id" },
async (req) => {
// Get subscription details
const subscription = await webhookDB.queryRow<{
hookdeck_connection_id: string;
}>`
SELECT hookdeck_connection_id FROM subscriptions WHERE id = ${req.id}
`;
if (!subscription) {
throw APIError.notFound("Webhook endpoint not found");
}
try {
// Delete connection from Hookdeck
await hookdeckClient.deleteConnection(subscription.hookdeck_connection_id);
} catch (error) {
// Log error but continue with database deletion
console.error("Failed to delete Hookdeck connection:", error);
}
// Delete from database
await webhookDB.exec`
DELETE FROM subscriptions WHERE id = ${req.id}
`;
}
);
Create backend/webhook/test.ts
for testing webhook delivery:
import { api, APIError } from "encore.dev/api";
import { webhookDB } from "./db";
import { hookdeckClient } from "./hookdeck";
import type { TestWebhookRequest, TestWebhookResponse } from "./types";
// Triggers a test webhook event for an endpoint via Hookdeck Publish API.
export const test = api<TestWebhookRequest, TestWebhookResponse>(
{ expose: true, method: "POST", path: "/subscriptions/test" },
async (req) => {
// Get subscription details
const subscription = await webhookDB.queryRow<{
hookdeck_source_id: string;
name: string;
}>`
SELECT hookdeck_source_id, name FROM subscriptions WHERE id = ${req.subscriptionId}
`;
if (!subscription) {
throw APIError.notFound("Webhook endpoint not found");
}
try {
// Use Hookdeck Publish API to send the test event
await hookdeckClient.publishEvent(subscription.hookdeck_source_id, {
data: req.payload,
event_type: req.eventType,
headers: {
"X-Event-Type": req.eventType,
"X-Subscription-Name": subscription.name,
"X-Test-Event": "true",
},
});
return {
success: true,
message: "Test webhook event published successfully",
};
} catch (error) {
if (error instanceof Error) {
throw APIError.internal(`Failed to publish test webhook: ${error.message}`);
}
throw APIError.internal("Failed to publish test webhook");
}
}
);
hkdk_...
Set your Hookdeck API key in Encore:
encore secret set HookdeckApiKey
# Paste your Hookdeck API key when prompted
Now let's build a React frontend that provides an intuitive interface for managing webhook endpoints and testing webhook delivery.
Create frontend/App.tsx
:
import { useState } from "react";
import { Toaster } from "@/components/ui/toaster";
import { SubscriptionList } from "./components/SubscriptionList";
import { CreateSubscriptionForm } from "./components/CreateSubscriptionForm";
import { TestWebhookForm } from "./components/TestWebhookForm";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Webhook, Plus, TestTube } from "lucide-react";
export default function App() {
const [refreshTrigger, setRefreshTrigger] = useState(0);
const handleSubscriptionCreated = () => {
setRefreshTrigger(prev => prev + 1);
};
const handleSubscriptionDeleted = () => {
setRefreshTrigger(prev => prev + 1);
};
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Webhook className="h-8 w-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">Webhook Notifications</h1>
</div>
<p className="text-gray-600">
Manage webhook endpoints for your platform notifications using Hookdeck
</p>
</div>
<Tabs defaultValue="subscriptions" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="subscriptions" className="flex items-center gap-2">
<Webhook className="h-4 w-4" />
Endpoints
</TabsTrigger>
<TabsTrigger value="create" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add Endpoint
</TabsTrigger>
<TabsTrigger value="test" className="flex items-center gap-2">
<TestTube className="h-4 w-4" />
Test Event
</TabsTrigger>
</TabsList>
<TabsContent value="subscriptions">
<SubscriptionList
refreshTrigger={refreshTrigger}
onSubscriptionDeleted={handleSubscriptionDeleted}
/>
</TabsContent>
<TabsContent value="create">
<CreateSubscriptionForm onSubscriptionCreated={handleSubscriptionCreated} />
</TabsContent>
<TabsContent value="test">
<TestWebhookForm refreshTrigger={refreshTrigger} />
</TabsContent>
</Tabs>
</div>
<Toaster />
</div>
);
}
Create frontend/components/SubscriptionList.tsx
:
import { useEffect, useState } from "react";
import backend from "~backend/client";
import type { Subscription } from "~backend/webhook/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "@/components/ui/use-toast";
import { Trash2, ExternalLink, Calendar, Globe, Link } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
interface SubscriptionListProps {
refreshTrigger: number;
onSubscriptionDeleted: () => void;
}
export function SubscriptionList({ refreshTrigger, onSubscriptionDeleted }: SubscriptionListProps) {
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<number | null>(null);
const loadSubscriptions = async () => {
try {
setLoading(true);
const response = await backend.webhook.list();
setSubscriptions(response.subscriptions);
} catch (error) {
console.error("Failed to load webhook endpoints:", error);
toast({
title: "Error",
description: "Failed to load webhook endpoints",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
try {
setDeleting(id);
await backend.webhook.deleteSubscription({ id });
toast({
title: "Success",
description: "Webhook endpoint deleted successfully",
});
onSubscriptionDeleted();
} catch (error) {
console.error("Failed to delete webhook endpoint:", error);
toast({
title: "Error",
description: "Failed to delete webhook endpoint",
variant: "destructive",
});
} finally {
setDeleting(null);
}
};
useEffect(() => {
loadSubscriptions();
}, [refreshTrigger]);
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</CardContent>
</Card>
);
}
if (subscriptions.length === 0) {
return (
<Card>
<CardContent className="p-6">
<div className="text-center text-gray-500">
<Globe className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-lg font-medium mb-2">No webhook endpoints yet</p>
<p>Add your first webhook endpoint to start receiving notifications</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
Webhook Endpoints ({subscriptions.length})
</h2>
</div>
<div className="grid gap-4">
{subscriptions.map((subscription) => (
<Card key={subscription.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{subscription.name}</CardTitle>
<div className="flex items-center gap-2 mt-2">
<Badge variant="secondary" className="text-xs">
ID: {subscription.id}
</Badge>
<Badge variant="outline" className="text-xs">
{subscription.hookdeckConnectionId}
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(subscription.id)}
disabled={deleting === subscription.id}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{deleting === subscription.id ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-600"></div>
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<ExternalLink className="h-4 w-4" />
<span className="font-medium">Destination:</span>
<a
href={subscription.destinationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 underline break-all"
>
{subscription.destinationUrl}
</a>
</div>
{subscription.hookdeckSourceUrl && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Link className="h-4 w-4" />
<span className="font-medium">Hookdeck Source:</span>
<a
href={subscription.hookdeckSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 underline break-all"
>
{subscription.hookdeckSourceUrl}
</a>
</div>
)}
<div className="flex items-center gap-2 text-sm text-gray-600">
<Calendar className="h-4 w-4" />
<span className="font-medium">Created:</span>
<span>
{formatDistanceToNow(new Date(subscription.createdAt), { addSuffix: true })}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
Create frontend/components/CreateSubscriptionForm.tsx
:
import { useState } from "react";
import backend from "~backend/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/use-toast";
import { Plus, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface CreateSubscriptionFormProps {
onSubscriptionCreated: () => void;
}
export function CreateSubscriptionForm({ onSubscriptionCreated }: CreateSubscriptionFormProps) {
const [name, setName] = useState("");
const [destinationUrl, setDestinationUrl] = useState("");
const [loading, setLoading] = useState(false);
const [nameError, setNameError] = useState("");
const [urlError, setUrlError] = useState("");
const validateName = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
return "Webhook endpoint name is required";
}
if (trimmed.length < 3) {
return "Name must be at least 3 characters long";
}
if (trimmed.length > 100) {
return "Name must be less than 100 characters";
}
const namePattern = /^[a-zA-Z0-9\s\-_.,()]+$/;
if (!namePattern.test(trimmed)) {
return "Name can only contain letters, numbers, spaces, hyphens, underscores, periods, commas, and parentheses";
}
// Check if the name contains at least 3 valid characters for Hookdeck
const validChars = trimmed.replace(/[^a-zA-Z0-9\-_]/g, '');
if (validChars.length < 3) {
return "Name must contain at least 3 letters, numbers, hyphens, or underscores";
}
return "";
};
const validateUrl = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
return "Destination URL is required";
}
try {
new URL(trimmed);
return "";
} catch {
return "Please enter a valid URL";
}
};
const handleNameChange = (value: string) => {
setName(value);
setNameError(validateName(value));
};
const handleUrlChange = (value: string) => {
setDestinationUrl(value);
setUrlError(validateUrl(value));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const nameValidationError = validateName(name);
const urlValidationError = validateUrl(destinationUrl);
setNameError(nameValidationError);
setUrlError(urlValidationError);
if (nameValidationError || urlValidationError) {
return;
}
try {
setLoading(true);
await backend.webhook.create({
name: name.trim(),
destinationUrl: destinationUrl.trim(),
});
toast({
title: "Success",
description: "Webhook endpoint created successfully",
});
setName("");
setDestinationUrl("");
setNameError("");
setUrlError("");
onSubscriptionCreated();
} catch (error: any) {
console.error("Failed to create webhook endpoint:", error);
let errorMessage = "Failed to create webhook endpoint";
if (error?.message) {
if (error.message.includes("already exists")) {
errorMessage = "A webhook endpoint with this name already exists";
} else if (error.message.includes("name")) {
errorMessage = error.message;
} else if (error.message.includes("URL")) {
errorMessage = error.message;
} else {
errorMessage = error.message;
}
}
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Add Webhook Endpoint
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Endpoint Name</Label>
<Input
id="name"
type="text"
placeholder="e.g., Production API Webhook"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
disabled={loading}
className={nameError ? "border-red-500 focus:border-red-500" : ""}
/>
{nameError && (
<Alert variant="destructive" className="py-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">{nameError}</AlertDescription>
</Alert>
)}
<p className="text-sm text-gray-500">
Must be 3-100 characters and contain at least 3 letters, numbers, hyphens, or underscores
</p>
</div>
<div className="space-y-2">
<Label htmlFor="destinationUrl">Destination URL</Label>
<Input
id="destinationUrl"
type="url"
placeholder="https://api.yourapp.com/webhooks"
value={destinationUrl}
onChange={(e) => handleUrlChange(e.target.value)}
disabled={loading}
className={urlError ? "border-red-500 focus:border-red-500" : ""}
/>
{urlError && (
<Alert variant="destructive" className="py-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">{urlError}</AlertDescription>
</Alert>
)}
<p className="text-sm text-gray-500">
The URL where webhook events will be delivered
</p>
</div>
<Button
type="submit"
disabled={loading || !!nameError || !!urlError || !name.trim() || !destinationUrl.trim()}
className="w-full"
>
{loading ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Creating...
</div>
) : (
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add Endpoint
</div>
)}
</Button>
</form>
</CardContent>
</Card>
);
}
Create frontend/components/TestWebhookForm.tsx
:
import { useState, useEffect } from "react";
import backend from "~backend/client";
import type { Subscription } from "~backend/webhook/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { TestTube, Send } from "lucide-react";
interface TestWebhookFormProps {
refreshTrigger: number;
}
export function TestWebhookForm({ refreshTrigger }: TestWebhookFormProps) {
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<string>("");
const [eventType, setEventType] = useState("user.created");
const [payload, setPayload] = useState(`{
"id": "user_123",
"email": "[email protected]",
"name": "John Doe",
"timestamp": "${new Date().toISOString()}"
}`);
const [loading, setLoading] = useState(false);
const [loadingSubscriptions, setLoadingSubscriptions] = useState(true);
const loadSubscriptions = async () => {
try {
setLoadingSubscriptions(true);
const response = await backend.webhook.list();
setSubscriptions(response.subscriptions);
} catch (error) {
console.error("Failed to load webhook endpoints:", error);
toast({
title: "Error",
description: "Failed to load webhook endpoints",
variant: "destructive",
});
} finally {
setLoadingSubscriptions(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedSubscriptionId || !eventType.trim() || !payload.trim()) {
toast({
title: "Error",
description: "Please fill in all fields",
variant: "destructive",
});
return;
}
let parsedPayload;
try {
parsedPayload = JSON.parse(payload);
} catch (error) {
toast({
title: "Error",
description: "Invalid JSON payload",
variant: "destructive",
});
return;
}
try {
setLoading(true);
const response = await backend.webhook.test({
subscriptionId: parseInt(selectedSubscriptionId),
eventType: eventType.trim(),
payload: parsedPayload,
});
toast({
title: "Success",
description: `Test webhook sent successfully${response.eventId ? ` (Event ID: ${response.eventId})` : ""}`,
});
} catch (error) {
console.error("Failed to send test webhook:", error);
toast({
title: "Error",
description: "Failed to send test webhook",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleEventTypeChange = (newEventType: string) => {
setEventType(newEventType);
// Update payload based on event type
const payloadTemplates: Record<string, any> = {
"user.created": {
id: "user_123",
email: "[email protected]",
name: "John Doe",
timestamp: new Date().toISOString(),
},
"user.updated": {
id: "user_123",
email: "[email protected]",
name: "John Smith",
changes: ["email", "name"],
timestamp: new Date().toISOString(),
},
"user.deleted": {
id: "user_123",
timestamp: new Date().toISOString(),
},
"order.created": {
id: "order_456",
userId: "user_123",
amount: 99.99,
currency: "USD",
items: [
{ id: "item_1", name: "Product A", quantity: 2, price: 49.99 }
],
timestamp: new Date().toISOString(),
},
"payment.completed": {
id: "payment_789",
orderId: "order_456",
amount: 99.99,
currency: "USD",
status: "completed",
timestamp: new Date().toISOString(),
},
};
const template = payloadTemplates[newEventType] || {
message: "Custom event",
timestamp: new Date().toISOString(),
};
setPayload(JSON.stringify(template, null, 2));
};
useEffect(() => {
loadSubscriptions();
}, [refreshTrigger]);
if (loadingSubscriptions) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</CardContent>
</Card>
);
}
if (subscriptions.length === 0) {
return (
<Card>
<CardContent className="p-6">
<div className="text-center text-gray-500">
<TestTube className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-lg font-medium mb-2">No webhook endpoints available</p>
<p>Add a webhook endpoint first to test webhook events</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TestTube className="h-5 w-5" />
Test Webhook Event
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subscription">Webhook Endpoint</Label>
<Select value={selectedSubscriptionId} onValueChange={setSelectedSubscriptionId}>
<SelectTrigger>
<SelectValue placeholder="Select a webhook endpoint" />
</SelectTrigger>
<SelectContent>
{subscriptions.map((subscription) => (
<SelectItem key={subscription.id} value={subscription.id.toString()}>
{subscription.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="eventType">Event Type</Label>
<Select value={eventType} onValueChange={handleEventTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user.created">user.created</SelectItem>
<SelectItem value="user.updated">user.updated</SelectItem>
<SelectItem value="user.deleted">user.deleted</SelectItem>
<SelectItem value="order.created">order.created</SelectItem>
<SelectItem value="payment.completed">payment.completed</SelectItem>
<SelectItem value="custom.event">custom.event</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="payload">Payload (JSON)</Label>
<Textarea
id="payload"
placeholder="Enter JSON payload"
value={payload}
onChange={(e) => setPayload(e.target.value)}
disabled={loading}
rows={12}
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500">
The JSON data that will be sent in the webhook event
</p>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending...
</div>
) : (
<div className="flex items-center gap-2">
<Send className="h-4 w-4" />
Send Test Event
</div>
)}
</Button>
</form>
</CardContent>
</Card>
);
}
Now that we've built both the backend and frontend, let's bring everything together and see our outbound webhook system in action.
Launch your Encore application with a single command:
encore run
This starts both your backend services and the local development environment. Encore automatically handles:
Once the server is running, your webhook management application will be available at multiple endpoints:
The development dashboard provides you with:
Here's how to verify everything is working:
Once your application is working locally, you can deploy it to production.
Deploy your application using Git:
git add .
git commit -m "Initial outbound webhook system"
git push encore
This triggers an automatic deployment that:
Your Hookdeck API key needs to be set in the production environment:
# Set secrets for your production environment
encore secret set --env=production HookdeckApiKey
Your outbound webhook system is now live! Here are some ideas for extending it:
Congratulations! You've built a production-ready outbound webhook system that leverages Hookdeck's Event Gateway for reliable delivery while providing a custom management interface for your platform's webhook notifications.